v3.9.5版本发布 vue3

This commit is contained in:
JEECG 2026-08-21 17:01:39 +08:00
parent 6f90858bcb
commit 2d401c923d
336 changed files with 13150 additions and 2410 deletions

View File

@ -12,6 +12,7 @@
*.iws
*.ipr
out/
.playwright-mcp
# Eclipse
.classpath
@ -38,6 +39,7 @@ logs/
# Node (frontend artifacts if any)
node_modules/
dist/
mock/
# Docker volumes / data
docker/data/

View File

@ -29,7 +29,7 @@ VITE_APP_SUB_jeecg-app-1 = '//localhost:8092'
# 全局隐藏哪些布局。可选属性:sider,header,multi-tabs;多个用逗号隔开
#VITE_GLOB_HIDE_LAYOUT_TYPES=sider,header,multi-tabs
# 在线文档编辑版本。可选属性:wps, offlineWps(离线版) ,onlyoffice
# 在线文档编辑版本。可选属性:wps, newWps(新版本wps), offlineWps(离线版) ,onlyoffice
VITE_GLOB_ONLINE_DOCUMENT_VERSION=wps
# icon组件的iconify图标库使用在线还是本地。可选属性:online, local

View File

@ -11,7 +11,7 @@ VITE_GLOB_DOMAIN_URL=https://api3.boot.jeecg.com
# 接口父路径前缀
VITE_GLOB_API_URL_PREFIX=
# 在线文档编辑版本。可选属性:wps, offlineWps(离线版), onlyoffice
# 在线文档编辑版本。可选属性:wps, newWps(新版本wps), offlineWps(离线版), onlyoffice
VITE_GLOB_ONLINE_DOCUMENT_VERSION=wps
# 全局隐藏哪些布局。可选属性:sider,header,multi-tabs;多个用逗号隔开

View File

@ -30,7 +30,7 @@ VITE_GLOB_API_URL_PREFIX=
# 全局隐藏哪些布局。可选属性:sider,header,multi-tabs;多个用逗号隔开
#VITE_GLOB_HIDE_LAYOUT_TYPES=sider,header,multi-tabs
# 在线文档编辑版本。可选属性:wps, offlineWps(离线版), onlyoffice
# 在线文档编辑版本。可选属性:wps, newWps(新版本wps), offlineWps(离线版), onlyoffice
VITE_GLOB_ONLINE_DOCUMENT_VERSION=wps
# icon组件的iconify图标库使用在线还是本地。可选属性:online, local

59
jeecgboot-vue3/.ignore Normal file
View File

@ -0,0 +1,59 @@
# Git
.git/
.gitignore
.gitmodules
# SVN
.svn/
# IntelliJ IDEA
.idea/
*.iml
*.iws
*.ipr
out/
.playwright-mcp
# Eclipse
.classpath
.project
.settings/
# VS Code
.vscode/
# Maven / Gradle build output
target/
build/
!.mvn/wrapper/maven-wrapper.jar
# OS files
.DS_Store
Thumbs.db
desktop.ini
# Logs
*.log
logs/
# Node (frontend artifacts if any)
node_modules/
dist/
mock/
# Docker volumes / data
docker/data/
# Compiled classes
*.class
# Custom
*.qqy
代码修改.log
代码修改日志
*.zip
backup/
.history/
.cursor/
doc/
docs/

158
jeecgboot-vue3/AGENTS.md Normal file
View File

@ -0,0 +1,158 @@
# AGENTS.md
This file provides repository guidance for Codex when working in this project.
## Communication
- Always answer in Simplified Chinese first, unless the user explicitly requests another language.
## Project Overview
JeecgBoot Vue3 (v3.8.3) is an enterprise low-code admin platform frontend. It is built on Vue 3, TypeScript, Vite, Ant Design Vue 4, and Pinia. It was originally forked from Vben Admin and customized with JeecgBoot-specific components such as online forms, code generation, workflow, and related low-code modules.
This workspace uses SVN for version control. Do not assume Git is available in this directory.
Backend: Spring Boot (JeecgBoot), expected at `http://127.0.0.1:8080/jeecg-boot` during development.
## Related Projects
| Project | Path |
| --- | --- |
| Frontend (Vue3, this repo) | `E:\workspace-cc-jeecg\jeecgboot-vue3-2026` |
| Backend (Spring Boot 3) | `E:\workspace-cc-jeecg\jeecg-boot-framework-2026` |
## Commands
```bash
pnpm install
pnpm dev
pnpm build
pnpm batch:prettier
```
- `pnpm dev` starts the dev server on port 3100.
- `pnpm build` creates the production build in `dist/`.
- `pnpm batch:prettier` formats all matching files under `src/`; run it only when broad formatting is intended.
- No regular test script is configured in `package.json`; a Jest config exists, but there is no `test` script.
## Path Aliases
- `/@/` or `@/` maps to `src/`.
- `/#/` or `#/` maps to `types/`.
- Prefer the `/@/` prefix with the leading slash, which is the convention used throughout this codebase.
## Source Structure
| Directory | Purpose |
| --- | --- |
| `src/api/` | HTTP request functions organized by domain, such as `sys/` and `common/`. |
| `src/components/` | Reusable components, including generic components and JeecgBoot-specific components under `jeecg/`. |
| `src/hooks/` | Composition API hooks, including `web/`, `setting/`, and `system/`. |
| `src/layouts/` | App layouts, including `default/`, `iframe/`, and `page/`. |
| `src/router/` | Vue Router setup with dynamic route registration based on backend permissions. |
| `src/store/` | Pinia stores for app, user, permission, tabs, locale, and lock state. |
| `src/settings/` | Global project, component, and design settings. |
| `src/utils/` | HTTP client, auth/token management, encryption, dict helpers, and shared utilities. |
| `src/views/` | Feature pages, including `system/`, `dashboard/`, `monitor/`, and `super/`. |
| `src/locales/` | i18n files such as `zh_CN` and `en`. |
| `src/enums/` | TypeScript enums for constants. |
| `src/directives/` | Custom directives such as `v-auth`, `v-loading`, `v-click-outside`, and `v-ripple`. |
## View Conventions
Each feature module in `src/views/` typically includes:
- `index.vue` for the main list page.
- `*.data.ts` for table column definitions and form schemas.
- `*.api.ts` for API endpoint calls.
- `*Modal.vue` or `*Drawer.vue` for detail and edit components.
## Component Patterns
Use `BasicTable` and `BasicForm` for list pages:
```ts
import { BasicTable, useTable } from '/@/components/Table';
const [registerTable] = useTable({ api, columns, formConfig: { schemas } });
```
Use `useModal` and `useDrawer` for detail panels:
```ts
const [registerModal, { openModal }] = useModal();
openModal(true, { isUpdate: true, record });
```
Use `useListPage` from `src/hooks/system/` for standard CRUD pages that combine table, form, modal, and drawer behavior.
Form schemas use `FormSchema[]` with `component` specifying Ant Design Vue or custom components such as `Input`, `Select`, `JDictSelectTag`, and `JSearchSelect`.
## HTTP Client
`defHttp` in `src/utils/http/axios/` wraps Axios with:
- Token injection through interceptors.
- MD5 request signing for API security.
- Multi-tenant header support.
- Standard response unwrapping through the `result` field.
API functions usually follow this pattern:
```ts
enum Api {
List = '/sys/user/list',
}
export const list = (params) => defHttp.get({ url: Api.List, params });
```
## Authentication And Permissions
- Tokens are stored in localStorage through auth utilities.
- `src/router/guard/permissionGuard.ts` fetches user info and dynamic menus from the backend.
- Permission mode is `BACK`, meaning routes and button permissions are backend-driven.
- Button-level auth uses the `v-auth="'system:user:add'"` directive or the `usePermission()` hook.
## Component Settings
Table pagination uses `pageNo` and `pageSize` params and expects `records` and `total` in the response. This is configured in `src/settings/componentSetting.ts`.
## Environment Variables
- `.env` contains base config such as port, app title, SSO, and qiankun toggles.
- `.env.development` contains dev proxy, mock toggle, and backend URL.
- `.env.production` contains production API URL and gzip config.
- Key variables include `VITE_GLOB_DOMAIN_URL`, `VITE_GLOB_API_URL`, and `VITE_PROXY`.
## Build System
`vite.config.ts` delegates plugin setup to `build/vite/plugin/`. Plugins include HTML template handling, mock data, gzip compression, SVG sprites, dynamic theme, qiankun micro-frontend, and PWA. Build scripts under `build/script/` handle post-build tasks.
## Online Forms
Online forms are a JeecgBoot low-code core feature. They generate CRUD pages from configuration. The full schema reference is in `src/views/super/online/cgform/online-form-schema.md` and covers table metadata, field configuration, control types, validation rules, and JavaScript enhancements.
## Code Style
- Prettier uses 150 character line width, single quotes, trailing commas for ES5, semicolons, and 2-space indentation.
- ESLint uses `vue/vue3-recommended`, `@typescript-eslint/recommended`, and Prettier with Prettier rules disabled.
- Unused variables prefixed with `_` are allowed.
- Prefer `<script setup>` for new Vue components.
- Use Less for styling.
## Working Exclusions
Treat the existing `.claudeignore` as the source for routine Codex work exclusions.
Avoid broad searches, reads, or edits in these paths unless the task explicitly targets them or verification requires them:
- `.git/`, `.svn/`, `.idea/`, `.vscode/`
- `node_modules/`, `dist/`, `target/`, `docker/data/`
- `backup/`, `.history/`, `.cursor/`
- `doc/`, `docs/`
- IDE metadata such as `*.iml`, `*.iws`, `*.ipr`, `.classpath`, `.project`, `.settings/`, and `out/`
- OS files such as `.DS_Store`, `Thumbs.db`, and `desktop.ini`
- Logs and generated artifacts such as `*.log`, `logs/`, `*.class`, `*.zip`, `*.qqy`, `代码修改.log`, and `代码修改日志`
The `.claudeignore` file lists `build/`, but this frontend uses `build/` for Vite plugins and build scripts. Avoid broad scans there, but allow targeted reads and edits when working on build tooling.

View File

@ -1,9 +1,9 @@
import { generate } from '@ant-design/colors';
import setting from '../../src/settings/projectSetting';
// 构建期默认主题色/主题模式:从 src/settings/projectSetting.ts 动态读取,保持与运行时默认值一致
// 运行时会从 store 拿真实值并显式传入,这里只做 build 阶段的占位与默认色板生成
// 注意:projectSetting.ts 及其依赖链(enums、designSetting、getConfigByMenuType)均已改为相对路径导入,
// 因为 vite 8 的 config 文件打包(rolldown)不解析 tsconfig 路径别名(/@/、@/ 等)
// 代码逻辑说明: 【JHHB-579】去掉写死的主题色,根据导航栏模式确定主题色
export const primaryColor = setting.themeColor;
export const darkMode = setting.themeMode;
type Fn = (...arg: any) => any;

View File

@ -62,7 +62,17 @@ export async function createVitePlugins(
}),
];
vitePlugins.push(UnoCSS({ presets: [presetUno(), presetTypography()] }));
// update-begin--author:copilot---date:20260711---for:【依赖升级排查】屏蔽被 UnoCSS 误识别为原子类的业务字符串/注释文本
// 源码中存在 '[orderby:...]'、'[09:00]' 等业务字符串/注释文本,会被 UnoCSS 静态扫描误判为形如 [prop:value] 的
// 任意值原子类,生成的 CSS 规则值不是合法 CSS(如包含 JS 表达式片段),旧版 esbuild/lightningcss minify 较宽松未报错,
// vite8 lightningcss 严格校验会直接构建失败,故通过 blocklist 屏蔽这些非预期匹配。
vitePlugins.push(
UnoCSS({
presets: [presetUno(), presetTypography()],
blocklist: [/^\[orderby:.*\]$/, /^\[uploading:.*\]$/, /^\[\d{2}:\d{2}\]$/],
})
);
// update-end--author:copilot---date:20260711---for:【依赖升级排查】屏蔽被 UnoCSS 误识别为原子类的业务字符串/注释文本
// update-begin--author:liaozhiyang---date:20260302---for:【QQYUN-14806】antd采用unplugin-vue-components实现按需加载
// unplugin-vue-components: ant-design-vue 组件自动按需导入
vitePlugins.push(

View File

@ -24,15 +24,15 @@
},
"dependencies": {
"@jeecg/aiflow": "3.9.3",
"@logicflow/core": "^2.2.4",
"@logicflow/extension": "^2.3.0",
"@logicflow/vue-node-registry": "^1.2.4",
"@iconify/iconify": "^3.1.1",
"@ant-design/colors": "^7.2.1",
"@ant-design/icons-vue": "^7.0.1",
"@vue/shared": "^3.5.39",
"@vueuse/core": "^10.11.1",
"@tinymce/tinymce-vue": "4.0.7",
"@logicflow/core": "^2.2.4",
"@logicflow/extension": "^2.3.0",
"@logicflow/vue-node-registry": "^1.2.4",
"@zxcvbn-ts/core": "^3.0.4",
"ant-design-vue": "^4.2.6",
"axios": "^1.18.1",
@ -84,6 +84,7 @@
"vxe-table-plugin-antd": "4.0.8",
"xe-utils": "3.5.26",
"xss": "^1.0.15",
"@onlyoffice/document-editor-vue": "^1.6.1",
"vue-grid-layout-v3": "^3.1.2",
"lunar-javascript": "^1.7.7",
"perfect-scrollbar": "^1.5.6",

File diff suppressed because one or more lines are too long

View File

@ -45,6 +45,8 @@ export interface GetUserInfoModel {
userInfo?: any;
// 缓存字典项
sysAllDictItems?: any;
// 三级等保强密码开关
enableStrongPwd?: boolean;
}
/**

View File

@ -11,10 +11,14 @@ const { uploadUrl = '' } = useGlobSetting();
export function uploadApi(params: UploadFileParams, onUploadProgress: (progressEvent: ProgressEvent) => void) {
return defHttp.uploadFile<UploadApiResult>(
{
url: uploadUrl,
url: `${uploadUrl}/sys/common/upload`,
onUploadProgress,
},
params
params,
// update-begin--author:liaozhiyang---date:20260804---for:【LHZP-1385】修复上传示例报错
// 返回上传接口完整响应,避免调用方拿到 undefined 被当成失败
{ isReturnResponse: true }
// update-end--author:liaozhiyang---date:20260804---for:【LHZP-1385】修复上传示例报错
);
}
/**

View File

@ -24,6 +24,10 @@
* @default: #ffffff
*/
color: { type: String, default: '#ffffff' },
/**
* Tooltip background color
*/
backgroundColor: { type: String },
/**
* Help text font size
* @default: 14px
@ -77,6 +81,7 @@
<Tooltip
overlayClassName={`${prefixCls}__wrap`}
title={<div style={unref(getTooltipStyle)}>{renderTitle()}</div>}
color={props.backgroundColor}
autoAdjustOverflow={true}
overlayStyle={unref(getOverlayStyle)}
placement={props.placement as 'right'}

View File

@ -91,6 +91,8 @@ componentMap.set('RangeDate', createAsyncComponent(() => import('./jeecg/compone
componentMap.set('RangeTime', createAsyncComponent(() => import('./jeecg/components/JRangeTime.vue')));
componentMap.set('RoleSelect', createAsyncComponent(() => import('./jeecg/components/roleSelect/RoleSelectInput.vue')));
componentMap.set('JInputSelect', createAsyncComponent(() => import('./jeecg/components/JInputSelect.vue')));
componentMap.set('JAuthRuleValue', createAsyncComponent(() => import('./jeecg/components/JAuthRuleValue.vue')));
componentMap.set('JMultiTextTagInput', createAsyncComponent(() => import('./jeecg/components/JMultiTextTagInput.vue')));
componentMap.set('JSelectDepartPost', createAsyncComponent(() => import('./jeecg/components/JSelectDepartPost.vue')));
componentMap.set('JSelectUserByDeptPost', createAsyncComponent(() => import('./jeecg/components/JSelectUserByDeptPost.vue')));

View File

@ -4,10 +4,11 @@
v-model:value="state"
:options="getOptions"
show-search
:filter-option="filterOption"
:filter-option="getFilterOption"
@change="handleChange"
@dropdownVisibleChange="handleFetch"
@popupScroll="handlePopupScroll"
@search="handleSearch"
>
<template #[item]="data" v-for="item in Object.keys($slots)">
<slot :name="item" v-bind="data || {}"></slot>
@ -66,6 +67,7 @@
labelField: propTypes.string.def('label'),
valueField: propTypes.string.def('value'),
immediate: propTypes.bool.def(true),
searchDebounce: propTypes.number.def(300),
},
emits: ['options-change', 'change'],
setup(props, { emit }) {
@ -74,6 +76,8 @@
const isFirstLoad = ref(true);
const emitData = ref<any[]>([]);
const attrs = useAttrs();
const searchValue = ref('');
let searchTimer: ReturnType<typeof setTimeout> | null = null;
const { t } = useI18n();
// 代码逻辑说明: 【QQYUN-11831】ApiSelect 分页下拉方案 #7883
const hasMore = ref(true);
@ -82,7 +86,14 @@
pageSize: 10,
total: 0,
});
const defPageConfig = { isPage: false, pageField: 'pageNo', pageSizeField: 'pageSize', totalField: 'total', listField: 'records' };
// update-begin--author:liaozhiyang---date:20260617---for:【issue/9689】开启分页 + 搜索,过滤不到未加载数据
const defPageConfig = { isPage: false, pageField: 'pageNo', pageSizeField: 'pageSize', totalField: 'total', listField: 'records', searchField: '' };
const mergedPageConfig = computed(() => ({ ...defPageConfig, ...props.pageConfig }));
const isRemoteSearch = computed(() => {
const { isPage, searchField } = mergedPageConfig.value;
return !!(isPage && searchField);
});
// update-end--author:liaozhiyang---date:20260617---for:【issue/9689】开启分页 + 搜索,过滤不到未加载数据
// update-end--author:liusq---date:20250407---for:【QQYUN-11831】ApiSelect 分页下拉方案 #7883
// Embedded in the form, just use the hook binding to perform form verification
const [state, setState] = useRuleFormItem(props, 'value', 'change', emitData);
@ -159,6 +170,22 @@
const filterOption = (input: string, option: any) => {
return option.value.toLowerCase().indexOf(input.toLowerCase()) >= 0 || option.label.indexOf(input) >= 0;
};
// update-begin--author:liaozhiyang---date:20260617---for:【issue/9689】开启分页 + 搜索,过滤不到未加载数据
const getFilterOption = computed(() => {
return isRemoteSearch.value ? false : filterOption;
});
function handleSearch(input: string) {
if (!isRemoteSearch.value) return;
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
if (searchValue.value === input) return;
searchValue.value = input;
pagination.value.pageNo = 1;
hasMore.value = true;
fetch();
}, props.searchDebounce);
}
// update-end--author:liaozhiyang---date:20260617---for:【issue/9689】开启分页 + 搜索,过滤不到未加载数据
async function fetch() {
const api = props.api;
if (!api || !isFunction(api)) return;
@ -168,10 +195,16 @@
}
try {
loading.value = true;
let { isPage, pageField, pageSizeField, totalField, listField } = { ...defPageConfig, ...props.pageConfig };
let { isPage, pageField, pageSizeField, totalField, listField, searchField } = mergedPageConfig.value;
let params = isPage
? { ...props.params, [pageField]: pagination.value.pageNo, [pageSizeField]: pagination.value.pageSize }
: { ...props.params };
// update-begin--author:liaozhiyang---date:20260617---for:【issue/9689】开启分页 + 搜索,过滤不到未加载数据
// 携带远程搜索关键字到后端
if (isPage && searchField && searchValue.value) {
params[searchField] = searchValue.value;
}
// update-end--author:liaozhiyang---date:20260617---for:【issue/9689】开启分页 + 搜索,过滤不到未加载数据
const res = await api(params);
if (isPage) {
// 代码逻辑说明: 【QQYUN-11831】ApiSelect 分页下拉方案 #7883
@ -236,7 +269,7 @@
fetch();
}
}
return { state, attrs_, attrs, getOptions, loading, t, handleFetch, handleChange, handlePopupScroll,filterOption };
return { state, attrs_, attrs, getOptions, loading, t, handleFetch, handleChange, handlePopupScroll, filterOption, getFilterOption, handleSearch };
},
});
</script>

View File

@ -164,24 +164,31 @@
return { isShow, isIfShow };
}
// 代码逻辑说明: 【TV360X-434】validator校验执行两次
//update-begin---author:liaozhiyang ---date:20260518 for:[issues/9638]新增prevValueArr,值发生变化时强制重新执行validator,避免上传/输入后必填红色提示不消失-----------
let vSwitchArr: any = [],
prevValidatorArr: any = [];
prevValidatorArr: any = [],
prevValueArr: any = [];
const hijackValidator = (rules) => {
vSwitchArr = [];
prevValidatorArr = [];
prevValueArr = [];
rules.forEach((item, index) => {
const fn = item.validator;
vSwitchArr.push(true);
prevValidatorArr.push(null);
prevValueArr.push(undefined);
if (isFunction(fn)) {
item.validator = (rule, value, callback) => {
if (vSwitchArr[index]) {
// 100ms内不允许多次调用,除非值变了
const valueChanged = !Object.is(prevValueArr[index], value);
if (vSwitchArr[index] || valueChanged) {
vSwitchArr[index] = false;
setTimeout(() => {
vSwitchArr[index] = true;
}, 100);
const result = fn(rule, value, callback);
prevValidatorArr[index] = result;
prevValueArr[index] = value;
return result;
} else {
return prevValidatorArr[index];
@ -190,6 +197,7 @@
}
});
};
//update-end---author:liaozhiyang ---date:20260518 for:[issues/9638]新增prevValueArr,值发生变化时强制重新执行validator,避免上传/输入后必填红色提示不消失-----------
function handleRules(): ValidationRule[] {
const { rules: defRules = [], component, rulesMessageJoinLabel, label, dynamicRules, required, auth, field } = props.schema;
// 代码逻辑说明: 【TV360X-857】解决禁用状态下触发校验

View File

@ -26,6 +26,11 @@ export function createPlaceholderMessage(component: ComponentType) {
// return `请选择${label}`;
return t('common.chooseText');
}
//update-begin---author:liaozhiyang ---date:20260518 for:[issues/9638]BasicForm里使用JUpload且必填时验证总是提示未上传,补Upload类必填提示前缀-----------
if (component.includes('Upload')) {
return t('common.uploadText');
}
//update-end---author:liaozhiyang ---date:20260518 for:[issues/9638]BasicForm里使用JUpload且必填时验证总是提示未上传,补Upload类必填提示前缀-----------
return '';
}

View File

@ -1,5 +1,12 @@
<template>
<JCascader v-bind="attrs" :value="cascaderValue" :showLastLevelOnly="showLastLevelOnly" :options="getOptions" @change="handleChange" />
<JCascader
v-bind="attrs"
:disabled="disabled"
:value="cascaderValue"
:showLastLevelOnly="showLastLevelOnly"
:options="getOptions"
@change="handleChange"
/>
</template>
<script lang="ts">
import { defineComponent, ref, watchEffect, computed } from 'vue';
@ -23,6 +30,9 @@
displayLevel: propTypes.oneOf(['province', 'city', 'region', 'all']),
//是否显示区县
showArea: propTypes.bool.def(true),
// update-begin--author:liaozhiyang---date:20260804---for:【LHZP-707】修复省市区禁用状态下还可以点击x删除
disabled: propTypes.bool.def(false),
// update-end--author:liaozhiyang---date:20260804---for:【LHZP-707】修复省市区禁用状态下还可以点击x删除
// 存储数据 (all时:传递到外面的是数组;province, city, region传递外面的是字符串)
saveCode: propTypes.oneOf(['province', 'city', 'region', 'all']).def('all'),
},

View File

@ -0,0 +1,230 @@
<template>
<div class="JAuthRuleValue">
<a-input-group compact>
<!-- 系统变量选择器:使用 Dropdown 而非 Select,避免被外层 FormItem 二次收集 -->
<a-dropdown class="sys-var-dropdown" :trigger="['click']" :getPopupContainer="getPopupContainer" v-model:open="dropdownVisible">
<a-button type="default" size="middle" :class="isSysVarMode ? 'sys-var-button-active' : 'sys-var-button-inactive'">
<span class="sys-var-label">{{ selectedSysVarLabel || selectPlaceholder }}</span>
<DownOutlined />
</a-button>
<template #overlay>
<a-menu @click="handleMenuClick">
<a-menu-item v-for="item in options" :key="item.value">{{ item.label }}</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<!-- 默认文本输入(未配置专业组件 或 当前为系统变量模式) -->
<a-input
v-if="!innerComponent || isSysVarMode"
allow-clear
:placeholder="inputPlaceholder"
v-model:value="inputVal"
:getPopupContainer="getPopupContainer"
@change="handleInputChange"
/>
<!-- 字典选择 -->
<JDictSelectTag v-else-if="innerComponent === 'JDictSelectTag'" v-bind="innerProps" :value="inputVal" @change="handleCustomChange" />
<!-- 关联记录 -->
<div v-else-if="innerComponent === 'JLinkTableCard'" class="link-table-rule-value">
<JLinkTableCard v-bind="innerProps" :value="inputVal" @change="handleCustomChange" />
</div>
<!-- Popup 字典 -->
<JPopupDict v-else-if="innerComponent === 'JPopupDict'" v-bind="innerProps" :value="inputVal" @change="handleCustomChange" />
<!-- 用户选择 -->
<JSelectUser v-else-if="innerComponent === 'JSelectUser'" v-bind="innerProps" :value="inputVal" @change="handleCustomChange" />
<!-- 部门选择 -->
<JSelectDept v-else-if="innerComponent === 'JSelectDept'" v-bind="innerProps" :value="inputVal" @change="handleCustomChange" />
<!-- 省市区 -->
<JAreaLinkage v-else-if="innerComponent === 'JAreaLinkage'" v-bind="innerProps" :value="inputVal" @change="handleCustomChange" />
<!-- 日期 -->
<DatePicker
v-else-if="innerComponent === 'DatePicker'"
v-bind="innerProps"
:value="datePickerValue"
v-model:open="datePickerOpen"
@change="handleDatePickerChange"
/>
<!-- 时间 -->
<TimePicker v-else-if="innerComponent === 'TimePicker'" v-bind="innerProps" :value="inputVal" @change="handleCustomChange" />
</a-input-group>
</div>
</template>
<script setup name="JAuthRuleValue" lang="ts">
import { ref, watchEffect, computed } from 'vue';
import { propTypes } from '/@/utils/propTypes';
import { DownOutlined } from '@ant-design/icons-vue';
import JDictSelectTag from './JDictSelectTag.vue';
import JLinkTableCard from './JLinkTableCard/JLinkTableCard.vue';
import JPopupDict from './JPopupDict.vue';
import JSelectUser from './JSelectUser.vue';
import JSelectDept from './JSelectDept.vue';
import JAreaLinkage from './JAreaLinkage.vue';
import { DatePicker, TimePicker } from 'ant-design-vue';
import dayjs from 'dayjs';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import quarterOfYear from 'dayjs/plugin/quarterOfYear';
dayjs.extend(weekOfYear);
dayjs.extend(quarterOfYear);
const props = defineProps({
value: propTypes.oneOfType([propTypes.string, propTypes.number, propTypes.array]),
// 内部实际组件配置 { component: 'JSelectUser', componentProps: {...} }
customComponent: propTypes.object.def(null),
// 系统变量选项
options: propTypes.array.def([]),
selectPlaceholder: propTypes.string.def('选择系统变量'),
inputPlaceholder: propTypes.string.def('请输入'),
getPopupContainer: {
type: Function,
default: () => document.body,
},
});
const emit = defineEmits(['update:value', 'change']);
const inputVal = ref<any>();
const dropdownVisible = ref(false);
const datePickerOpen = ref(false);
const innerComponent = computed(() => props.customComponent?.component);
const innerProps = computed(() => props.customComponent?.componentProps || {});
// 兼容此前按年/月/周/季度显示格式保存的规则值,传给 DatePicker 前转换成稳定的 YYYY-MM-DD
const datePickerValue = computed(() => {
const value = inputVal.value;
const picker = innerProps.value?.picker;
if (!picker || typeof value !== 'string' || /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
if (picker === 'year' && /^\d{4}$/.test(value)) return `${value}-01-01`;
if (picker === 'month' && /^\d{4}-\d{2}$/.test(value)) return `${value}-01`;
const quarterMatch = value.match(/^(\d{4})-[Qq]([1-4])$/);
if (picker === 'quarter' && quarterMatch) {
return dayjs(`${quarterMatch[1]}-01-01`).quarter(Number(quarterMatch[2])).startOf('quarter').format('YYYY-MM-DD');
}
const weekMatch = value.match(/^(\d{4})-(\d+)/);
if (picker === 'week' && weekMatch) {
return dayjs(`${weekMatch[1]}-01-01`).week(Number(weekMatch[2])).startOf('week').format('YYYY-MM-DD');
}
return value;
});
const isSysVar = (val) => val != null && props.options.some((item) => item.value === val);
// 当前值为系统变量时,右侧统一回退为普通输入框,避免专业组件解析 #{...} 字符串
const isSysVarMode = computed(() => isSysVar(inputVal.value));
const selectedSysVarLabel = computed(() => {
const found = props.options.find((item) => item.value === inputVal.value);
return found ? found.label : '';
});
const handleInputChange = (e) => {
emits(e.target.value);
};
const handleMenuClick = ({ key }) => {
inputVal.value = key;
emits(key);
dropdownVisible.value = false;
};
const handleCustomChange = (val) => {
inputVal.value = val;
emits(val);
};
const handleDatePickerChange = (val) => {
handleCustomChange(val);
datePickerOpen.value = false;
};
const emits = (val) => {
emit('update:value', val);
emit('change', val);
};
watchEffect(() => {
inputVal.value = props.value;
});
</script>
<style lang="less" scoped>
.JAuthRuleValue {
.ant-input-group {
display: flex;
width: 100%;
}
// a-dropdown 不生成包裹节点,class 会直接合并到内部按钮,需使用同级类选择器
.sys-var-dropdown.ant-btn {
flex: 0 0 auto;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
box-shadow: none;
transition:
background-color 0.2s,
border-color 0.2s,
color 0.2s;
}
.sys-var-dropdown.sys-var-button-inactive {
border-color: @border-color-base !important;
background: fade(@text-color, 2%) !important;
color: fade(@text-color, 35%) !important;
font-weight: 400;
&:hover,
&:focus,
&:active {
border-color: @border-color-base !important;
background: fade(@text-color, 5%) !important;
color: fade(@text-color, 50%) !important;
}
}
.sys-var-dropdown.sys-var-button-active {
border-color: @primary-color !important;
background: fade(@primary-color, 8%) !important;
color: @primary-color !important;
&:hover,
&:focus,
&:active {
background: fade(@primary-color, 12%) !important;
color: @primary-color !important;
}
}
.sys-var-label {
display: inline-block;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.ant-input-group > .ant-input,
.ant-input-group > :deep(.ant-select),
.ant-input-group > :deep(.ant-radio-group),
.ant-input-group > .JselectUser,
.ant-input-group > .JSelectDept,
.ant-input-group > .JAreaLinkage,
.ant-input-group > :deep(.JPopupDict),
.ant-input-group > .link-table-rule-value,
.ant-input-group > .ant-picker {
flex: 1;
min-width: 0;
}
:deep(.JPopupDict > .ant-select),
:deep(.JPopupDict > .ant-input) {
width: 100%;
}
:deep(.JPopupDict > .ant-select .ant-select-selector),
:deep(.JPopupDict > .ant-input) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.link-table-rule-value {
padding-left: 8px;
}
}
</style>

View File

@ -4,8 +4,15 @@
-->
<template>
<div class="cascader" ref="rootRef">
<AInput :value="displayText" :placeholder="inputPlaceholder" readonly :disabled="inputDisabled" v-bind="attrs"
:class="{ 'cascader-input-open': visible }" @click="toggle">
<AInput
:value="displayText"
:placeholder="inputPlaceholder"
readonly
:disabled="inputDisabled"
v-bind="inputAttrs"
:class="{ 'cascader-input-open': visible }"
@click="toggle"
>
<template #suffix>
<span v-if="displayText && !inputDisabled" class="ant-input-clear-icon ant-input-clear-icon-has-suffix"
role="button" tabindex="-1" @click.stop="handleClear" @mousedown.prevent>
@ -36,7 +43,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue';
import { ref, computed, watch, onBeforeUnmount, nextTick, unref } from 'vue';
import { Input as AInput } from 'ant-design-vue';
import { DownOutlined, RightOutlined, CloseCircleFilled } from '@ant-design/icons-vue';
import { useAttrs } from '/@/hooks/core/useAttrs';
@ -81,8 +88,15 @@ const emit = defineEmits<{
}>();
const attrs = useAttrs();
const inputPlaceholder = computed(() => String((attrs as Record<string, unknown>).placeholder ?? '请选择'));
const inputDisabled = computed(() => Boolean((attrs as Record<string, unknown>).disabled));
const inputPlaceholder = computed(() => String(unref(attrs as Record<string, unknown>).placeholder ?? '请选择'));
const inputDisabled = computed(() => Boolean(unref(attrs as Record<string, unknown>).disabled));
// update-begin--author:liaozhiyang---date:20260804---for:【LHZP-707】修复省市区禁用状态下还可以点击x删除
/** 排除 allowClear/disabled,清空按钮由 suffix 自定义实现 */
const inputAttrs = computed(() => {
const { allowClear: _allowClear, disabled: _disabled, ...rest } = unref(attrs as Record<string, unknown>);
return rest;
});
// update-end--author:liaozhiyang---date:20260804---for:【LHZP-707】修复省市区禁用状态下还可以点击x删除
const rootRef = ref<HTMLElement>();
const dropdownRef = ref<HTMLElement>();
/** 下拉是否展开 */
@ -179,6 +193,7 @@ function toggle() {
/** 清除选中:与 UI 库 readonly 下不显示清除按钮的补偿,在 suffix 中手动实现 */
function handleClear() {
if (inputDisabled.value) return;
emit('change', []);
emit('update:value', []);
visible.value = false;
@ -197,12 +212,40 @@ function handleClickOutside(e: MouseEvent) {
function updatePopupPosition() {
if (!rootRef.value) return;
const rect = rootRef.value.getBoundingClientRect();
dropdownPlaceStyle.value = {
left: `${rect.left}px`,
top: `${rect.bottom + DROPDOWN_GAP}px`,
};
// update-begin--author:liaozhiyang---date:20260714---for【LHZP-100】省市区在页面边缘时,最后一级没法选
const margin = 8;
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
// 浮层宽高(未渲染出来时退化为仅按触发器定位)
const dropdownWidth = dropdownRef.value?.offsetWidth ?? 0;
const dropdownHeight = dropdownRef.value?.offsetHeight ?? 0;
// 水平:默认与触发器左对齐;右侧放不下则向左偏移使其完整显示
let left = rect.left;
if (dropdownWidth && left + dropdownWidth > viewportWidth - margin) {
left = viewportWidth - dropdownWidth - margin;
}
if (left < margin) left = margin;
// 垂直:默认在下方;下方放不下且上方空间足够则翻到上方
let top = rect.bottom + DROPDOWN_GAP;
if (dropdownHeight && top + dropdownHeight > viewportHeight - margin) {
const aboveTop = rect.top - DROPDOWN_GAP - dropdownHeight;
top = aboveTop >= margin ? aboveTop : Math.max(margin, viewportHeight - dropdownHeight - margin);
}
dropdownPlaceStyle.value = { left: `${left}px`, top: `${top}px` };
// update-end--author:liaozhiyang---date:20260714---for:【LHZP-100】省市区在页面边缘时,最后一级没法选
}
// update-begin--author:liaozhiyang---date:20260714---for:【LHZP-100】省市区在页面边缘时,最后一级没法选
watch(columns, () => {
if (visible.value) {
nextTick(updatePopupPosition);
}
});
// update-end--author:liaozhiyang---date:20260714---for:【LHZP-100】省市区在页面边缘时,最后一级没法选
/** 展开时:用当前 value 初始化 editingPath、计算定位、注册点击外部关闭;收起时移除监听 */
watch(visible, (v) => {
if (v) {
@ -248,6 +291,19 @@ onBeforeUnmount(() => {
opacity: 0;
}
}
&.ant-input-affix-wrapper-disabled,
&.ant-input-affix-wrapper-disabled:hover {
cursor: not-allowed;
.ant-input-clear-icon {
display: none !important;
}
.cascader-arrow {
opacity: 1;
}
}
}
.cascader-arrow {

View File

@ -7,7 +7,7 @@
</template>
<script lang="ts">
import { defineComponent, onMounted, reactive, ref, watch, unref, computed } from 'vue';
import { defineComponent, onMounted, reactive, ref, watch, unref, computed, nextTick } from 'vue';
import { propTypes } from '/@/utils/propTypes';
import { useRuleFormItem } from '/@/hooks/component/useFormItem';
// 引入全局实例
@ -73,7 +73,7 @@
// 代码提示
keywords: propTypes.array.def([]),
},
emits: ['change', 'update:value'],
emits: ['change', 'update:value', 'ready'],
setup(props, { emit }) {
const { getDarkMode } = useRootSetting();
const containerRef = ref(null);
@ -161,6 +161,8 @@
// 代码逻辑说明: 【QQYUN-8473】代码编辑器首次加载会有遮挡
setTimeout(() => {
refresh();
// 初始化 + refresh 全部完成后通知父组件
emit('ready');
}, 150);
});
@ -221,6 +223,11 @@
// 切换全屏状态
function onToggleFullScreen() {
isFullScreen.value = !isFullScreen.value;
// update-begin--author:liaozhiyang---date:20260713---for:【LHZP-236】onlinejs增强查看历史里面的数据,放大查看,内容无法滚动
nextTick(() => {
setTimeout(() => refresh(), 100);
});
// update-end--author:liaozhiyang---date:20260713---for:【LHZP-236】onlinejs增强查看历史里面的数据,放大查看,内容无法滚动
}
// 代码逻辑说明: codeEditor禁用功能
@ -342,7 +349,9 @@
.full-screen-child,
.CodeMirror {
height: 100%;
// update-begin--author:liaozhiyang---date:20260713---for:【LHZP-236】onlinejs增强查看历史里面的数据,放大查看,内容无法滚动
height: 100% !important;
// update-end--author:liaozhiyang---date:20260713---for:【LHZP-236】onlinejs增强查看历史里面的数据,放大查看,内容无法滚动
max-height: 100%;
min-height: 100%;
}

View File

@ -1,17 +1,64 @@
<template>
<div>
<BasicModal v-bind="$attrs" @register="register" title="导入EXCEL" :width="600" @cancel="handleClose" :confirmLoading="uploading" destroyOnClose>
<!--是否校验-->
<div style="margin: 0 5px 5px" v-if="online">
<span style="display: inline-block; height: 32px; line-height: 32px; vertical-align: middle">是否开启校验:</span>
<span style="margin-left: 6px">
<BasicModal
v-bind="$attrs"
@register="register"
title="导入 Excel"
:width="620"
:minHeight="0"
:bodyStyle="{ padding: '20px 24px 16px' }"
:canFullscreen="false"
@cancel="handleClose"
:confirmLoading="uploading"
destroyOnClose
>
<div class="j-import-modal">
<!--是否校验-->
<div v-if="online" class="validate-setting">
<div class="validate-setting__content">
<div class="validate-setting__title">导入数据校验</div>
<div class="validate-setting__description">开启后将校验文件内容,并反馈不符合规则的数据</div>
</div>
<a-switch :checked="validateStatus == 1" @change="handleChangeValidateStatus" checked-children="是" un-checked-children="否" />
</span>
</div>
<!--上传-->
<a-upload-dragger
class="import-uploader"
name="file"
accept=".xls,.xlsx"
:multiple="true"
:fileList="fileList"
:showUploadList="false"
@remove="handleRemove"
:beforeUpload="beforeUpload"
>
<template v-if="fileList.length === 0">
<div class="upload-icon">
<InboxOutlined />
</div>
<div class="upload-title">点击或拖拽 Excel 文件到此处</div>
<div class="upload-description">支持 .xls、.xlsx 格式,可一次选择多个文件</div>
</template>
<div v-else class="selected-files">
<div class="selected-files__header">
<div>
<div class="selected-files__title"><CheckCircleFilled /> 已选择 {{ fileList.length }} 个文件</div>
<div class="selected-files__description">点击此区域可继续添加文件</div>
</div>
<span class="selected-files__add">继续添加</span>
</div>
<div class="selected-files__list">
<div v-for="(file, index) in fileList" :key="file.uid || `${file.name}-${index}`" class="selected-file" @click.stop>
<span class="selected-file__icon"><FileExcelOutlined /></span>
<span class="selected-file__name" :title="file.name">{{ file.name }}</span>
<a-button type="text" class="selected-file__remove" title="移除文件" @click.stop="handleRemove(file)">
<DeleteOutlined />
</a-button>
</div>
</div>
</div>
</a-upload-dragger>
</div>
<!--上传-->
<a-upload name="file" accept=".xls,.xlsx" :multiple="true" :fileList="fileList" @remove="handleRemove" :beforeUpload="beforeUpload">
<a-button preIcon="ant-design:upload-outlined">选择导入文件</a-button>
</a-upload>
<!--页脚-->
<template #footer>
<a-button @click="handleClose">关闭</a-button>
@ -24,7 +71,8 @@
</template>
<script lang="ts">
import { defineComponent, ref, unref, watchEffect, computed } from 'vue';
import { CheckCircleFilled, DeleteOutlined, FileExcelOutlined, InboxOutlined } from '@ant-design/icons-vue';
import { defineComponent, ref, unref, watchEffect, computed, h } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { defHttp } from '/@/utils/http/axios';
@ -36,6 +84,10 @@
name: 'JImportModal',
components: {
BasicModal,
CheckCircleFilled,
DeleteOutlined,
FileExcelOutlined,
InboxOutlined,
},
props: {
url: {
@ -71,7 +123,7 @@
const uploadAction = ref('');
const foreignKeys = ref('');
//校验状态
const validateStatus = ref(0);
const validateStatus = ref(1);
const getBindValue = Object.assign({}, unref(props), unref(attrs));
//监听url
watchEffect(() => {
@ -123,8 +175,8 @@
if (!!online) {
formData.append('validateStatus', unref(validateStatus));
}
unref(fileList).forEach((file) => {
formData.append('files[]', file);
unref(fileList).forEach((file, index) => {
formData.append(`files[${index}]`, file);
});
uploading.value = true;
@ -153,15 +205,29 @@
//错误信息提示
function errorTip(tipMessage, fileUrl) {
let href = glob.uploadUrl + fileUrl;
//update-begin---author:scott ---date:20260813 for:优化Excel导入弹窗并修复错误提示HTML注入风险-----------
const resolvedUrl = new URL(`${glob.uploadUrl}${fileUrl}`, window.location.origin);
const href = ['http:', 'https:'].includes(resolvedUrl.protocol) ? resolvedUrl.href : '#';
const resultMessage = String(tipMessage ?? '')
.replace(/,/g, ',')
.replace(/:/g, ':');
createWarningModal({
title: '导入成功,但是有错误数据!',
centered: false,
content: `<div>
<span>${tipMessage}</span><br/>
<span>具体详情请<a href = ${href} target="_blank"> 点击下载 </a> </span>
</div>`,
title: '导入完成,部分数据未导入',
centered: true,
width: 480,
okText: '知道了',
content: h('div', { class: 'j-import-result-content' }, [
h('div', { class: 'j-import-result-summary' }, [
h('div', { class: 'j-import-result-summary__label' }, '导入结果'),
h('div', { class: 'j-import-result-summary__message' }, resultMessage),
]),
h('a', { class: 'j-import-result-download', href, target: '_blank', rel: 'noopener noreferrer' }, [
h('span', { class: 'j-import-result-download__main' }, [h(FileExcelOutlined), h('span', '下载错误数据明细')]),
h('span', { class: 'j-import-result-download__meta' }, 'Excel'),
]),
]),
});
//update-end---author:scott ---date:20260813 for:优化Excel导入弹窗并修复错误提示HTML注入风险-----------
}
//重置
@ -169,7 +235,7 @@
fileList.value = [];
uploading.value = false;
foreignKeys.value = arg;
validateStatus.value = 0;
validateStatus.value = 1;
}
return {
@ -188,3 +254,239 @@
},
});
</script>
<style scoped lang="less">
.j-import-modal {
.validate-setting {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-bottom: 16px;
padding: 12px 16px;
border: 1px solid #edf0f5;
border-radius: 8px;
background: #f7f9fc;
}
.validate-setting__content {
min-width: 0;
}
.validate-setting__title {
color: #1f2937;
font-size: 14px;
font-weight: 500;
line-height: 22px;
}
.validate-setting__description {
margin-top: 2px;
color: #8c8c8c;
font-size: 12px;
line-height: 20px;
}
.import-uploader {
:deep(.ant-upload-drag) {
border-color: #cbd5e1;
border-radius: 10px;
background: #fbfcfe;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
}
:deep(.ant-upload-drag:hover) {
border-color: @primary-color;
background: fade(@primary-color, 3%);
}
:deep(.ant-upload-btn) {
padding: 28px 16px 26px;
}
}
.upload-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
margin-bottom: 12px;
border-radius: 14px;
color: @primary-color;
background: fade(@primary-color, 10%);
font-size: 26px;
}
.upload-title {
color: #262626;
font-size: 15px;
font-weight: 500;
line-height: 24px;
}
.upload-description {
margin-top: 4px;
color: #8c8c8c;
font-size: 13px;
line-height: 22px;
}
.selected-files {
width: 100%;
text-align: left;
}
.selected-files__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.selected-files__title {
color: #262626;
font-size: 14px;
font-weight: 500;
line-height: 22px;
:deep(.anticon) {
margin-right: 6px;
color: #52c41a;
}
}
.selected-files__description {
margin-top: 2px;
color: #8c8c8c;
font-size: 12px;
line-height: 20px;
}
.selected-files__add {
flex: none;
color: @primary-color;
font-size: 13px;
}
.selected-files__list {
max-height: 116px;
overflow-y: auto;
}
.selected-file {
display: flex;
align-items: center;
min-height: 38px;
padding: 4px 6px;
border: 1px solid #e8edf3;
border-radius: 6px;
background: #f5f7fa;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
& + & {
margin-top: 4px;
}
&:hover {
border-color: fade(@primary-color, 18%);
background: fade(@primary-color, 6%);
.selected-file__remove {
opacity: 1;
}
}
}
.selected-file__icon {
flex: none;
margin-right: 8px;
color: #22a06b;
font-size: 18px;
}
.selected-file__name {
min-width: 0;
flex: 1;
overflow: hidden;
color: #4b5563;
font-size: 13px;
line-height: 22px;
text-overflow: ellipsis;
white-space: nowrap;
}
.selected-file__remove {
flex: none;
color: #7a8491;
opacity: 0.75;
&:hover {
color: #ff4d4f;
}
}
}
:global(.j-import-result-content) {
margin-top: 4px;
}
:global(.j-import-result-summary) {
padding: 12px 14px;
border: 1px solid #ffe7a3;
border-radius: 8px;
background: #fffaf0;
}
:global(.j-import-result-summary__label) {
margin-bottom: 4px;
color: #ad6800;
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
:global(.j-import-result-summary__message) {
color: #595959;
font-size: 14px;
line-height: 22px;
}
:global(.j-import-result-download) {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding: 10px 12px;
border: 1px solid #d9eaf7;
border-radius: 8px;
background: #f7fbff;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
&:hover {
border-color: fade(@primary-color, 35%);
background: fade(@primary-color, 6%);
}
}
:global(.j-import-result-download__main) {
display: inline-flex;
align-items: center;
gap: 8px;
color: @primary-color;
font-size: 14px;
font-weight: 500;
}
:global(.j-import-result-download__meta) {
color: #8c8c8c;
font-size: 12px;
}
</style>

View File

@ -85,7 +85,10 @@
}
switch (props.type) {
case JInputTypeEnum.JINPUT_QUERY_LIKE:
text = '*' + text + '*';
// 如果输入以 ! 开头,表示走 NE(不等于)查询,不再拼 * 走 LIKE
if (!text.startsWith('!')) {
text = '*' + text + '*';
}
break;
case JInputTypeEnum.JINPUT_QUERY_NE:
text = '!' + text;

View File

@ -0,0 +1,138 @@
<template>
<span ref="rootRef" class="j-left-ellipsis" :class="contentClass" :title="fullText">{{ displayText }}</span>
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
import { propTypes } from '/@/utils/propTypes';
/**
* 左侧省略:空间不足时显示 "...右侧文字",保证末尾内容完整可见。
* 按指定容器(默认 .ant-select-selector)的实际宽度测量,避免被文案自身撑开导致测不准。
*/
defineOptions({ name: 'JLeftEllipsis' });
const props = defineProps({
/** 完整文案 */
text: propTypes.string.def(''),
/** 用于测量可用宽度的容器选择器 */
containerSelector: propTypes.string.def('.ant-select-selector'),
/** 额外预留宽度(关闭按钮、padding 等),单位 px */
reserveWidth: propTypes.number.def(40),
/** 附加到根节点的 class */
contentClass: propTypes.string.def(''),
});
const rootRef = ref<HTMLElement | null>(null);
const displayText = ref(props.text || '');
let resizeObserver: ResizeObserver | null = null;
let measureCtx: CanvasRenderingContext2D | null = null;
const fullText = computed(() => (props.text == null ? '' : String(props.text)));
function getMeasureCtx(el: HTMLElement) {
if (!measureCtx) {
measureCtx = document.createElement('canvas').getContext('2d');
}
if (measureCtx) {
const style = getComputedStyle(el);
measureCtx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
}
return measureCtx;
}
function getMaxTextWidth(el: HTMLElement) {
const box = (el.closest(props.containerSelector) || el.parentElement) as HTMLElement | null;
if (!box) {
return 0;
}
const boxStyle = getComputedStyle(box);
const paddingX = (parseFloat(boxStyle.paddingLeft) || 0) + (parseFloat(boxStyle.paddingRight) || 0);
return Math.max(box.clientWidth - paddingX - (props.reserveWidth || 0), 24);
}
function updateDisplay() {
const el = rootRef.value;
const full = fullText.value;
if (!el || !full) {
displayText.value = full;
return;
}
const maxWidth = getMaxTextWidth(el);
if (maxWidth <= 0) {
displayText.value = full;
return;
}
const ctx = getMeasureCtx(el);
if (!ctx) {
displayText.value = full;
return;
}
if (ctx.measureText(full).width <= maxWidth) {
displayText.value = full;
return;
}
const ellipsis = '...';
const ellipsisWidth = ctx.measureText(ellipsis).width;
let lo = 0;
let hi = full.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
const suffix = full.slice(-mid);
if (ellipsisWidth + ctx.measureText(suffix).width <= maxWidth) {
lo = mid;
} else {
hi = mid - 1;
}
}
displayText.value = lo > 0 ? ellipsis + full.slice(-lo) : ellipsis;
}
function bindObserver() {
resizeObserver?.disconnect();
resizeObserver = null;
if (typeof ResizeObserver === 'undefined' || !rootRef.value) {
return;
}
const el = rootRef.value;
const box = (el.closest(props.containerSelector) || el.parentElement) as HTMLElement | null;
resizeObserver = new ResizeObserver(() => updateDisplay());
if (box) {
resizeObserver.observe(box);
} else {
resizeObserver.observe(el);
}
}
watch(
() => [props.text, props.reserveWidth, props.containerSelector],
() => {
nextTick(() => {
requestAnimationFrame(updateDisplay);
});
}
);
onMounted(() => {
nextTick(() => {
updateDisplay();
requestAnimationFrame(updateDisplay);
bindObserver();
});
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
resizeObserver = null;
});
</script>
<style lang="less" scoped>
.j-left-ellipsis {
display: block;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: clip;
}
</style>

View File

@ -35,7 +35,8 @@
</a-row>
</div>
</div>
<LinkTableListModal @register="registerListModal" :multi="multi" :id="popTableName" @success="addCard" />
<LinkTableListModal v-if="queryMode === 'online'" @register="registerListModal" :multi="multi" :id="popTableName" @success="addCard" />
<AllTableListModal v-else @register="registerListModal" :tableName="tableName" :textField="textField" :valueField="valueField" :imageField="imageField" :multi="multi" @success="addCard" />
</div>
</template>
@ -69,11 +70,14 @@
detail: propTypes.bool.def(false),
// 图片字段
imageField: propTypes.string.def(''),
// 查询模式:'online'(仅限 Online 表单配置的表)| 'table'(数据库中的所有表)
queryMode: propTypes.string.def('online'),
},
components: {
PlusOutlined,
MinusCircleFilled,
LinkTableListModal: createAsyncComponent(() => import('./components/LinkTableListModal.vue'), { loading: true }),
AllTableListModal: createAsyncComponent(() => import('./components/AllTableListModal.vue'), { loading: true }),
},
emits: ['change', 'update:value'],
setup(props, { emit }) {

View File

@ -0,0 +1,528 @@
<template>
<BasicModal
@register="registerModal"
:width="popModalFixedWidth"
:dialogStyle="{ top: '70px' }"
:bodyStyle="modalBodyStyle"
title="选择记录"
wrapClassName="jeecg-all-table-pop-list-modal"
>
<template #footer>
<a-button key="back" @click="handleCancel">关闭</a-button>
<a-button :disabled="submitDisabled" key="submit" type="primary" @click="handleSubmit" :loading="submitLoading">确定</a-button>
</template>
<a-form-item-rest>
<a-form-item class="atm-form-item">
<div class="atm-layout">
<!-- 搜索栏 + 已选提示 -->
<div class="atm-toolbar">
<a-input-search
v-model:value="searchText"
placeholder="请输入关键词,按回车搜索"
style="width: 240px"
@search="doSearch"
@pressEnter="doSearch"
/>
<span v-if="selectedKeys.length > 0" class="atm-selected-tip">
已选中 {{ selectedKeys.length }} 条记录
<a class="atm-clear-link" @click="clearSelection">清空</a>
</span>
</div>
<div class="atm-content">
<!-- 加载中 -->
<div v-if="loading" class="atm-spin-wrap">
<a-spin />
</div>
<!-- 无数据 -->
<a-empty v-else-if="dataList.length === 0" class="atm-empty" description="暂无数据" />
<!-- 卡片网格 -->
<div v-else class="atm-card-grid">
<div
v-for="record in dataList"
:key="getRecordKey(record)"
class="atm-card"
:class="{ 'atm-card--selected': isSelected(record) }"
@click="handleToggle(record)"
>
<!-- 多选模式才显示右上角选中图标 -->
<span v-if="multi" class="atm-card-check">
<CheckSquareFilled v-if="isSelected(record)" class="atm-check-icon--on" />
<BorderOutlined v-else class="atm-check-icon--off" />
</span>
<!-- 内容区 -->
<div class="atm-card-inner" :class="{ 'atm-has-image': hasImage(record) }">
<div class="atm-card-title">{{ getMainContent(record) }}</div>
<div class="atm-card-fields">
<div v-for="field in otherTextFields" :key="field" class="atm-card-field-row">
<span class="atm-field-label">{{ field }}</span>
<span class="atm-field-value">{{ record[field] }}</span>
</div>
</div>
</div>
<!-- 图片区 -->
<div v-if="hasImage(record)" class="atm-card-image">
<img :src="getImageSrc(record)" alt="" @error="handleImageError" />
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="atm-pagination">
<a-pagination
v-model:current="currentPage"
:total="total"
:pageSize="pageSize"
:show-total="(t) => `共 ${t} 条`"
show-size-changer
@change="handlePageChange"
@showSizeChange="handleSizeChange"
/>
</div>
</div>
</a-form-item>
</a-form-item-rest>
</BasicModal>
</template>
<script lang="ts">
import { defineComponent, ref, computed } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { defHttp } from '/@/utils/http/axios';
import { useFixedHeightModal } from '../hooks/useLinkTable';
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
import { CheckSquareFilled, BorderOutlined } from '@ant-design/icons-vue';
import placeholderImage from '/@/assets/images/placeholderImage.png';
/** 调用后端 /sys/dict/queryTableDataForLinkRecord 接口 */
function queryTableData(params: Record<string, any>) {
return defHttp.get({ url: '/sys/dict/queryTableDataForLinkRecord', params });
}
export default defineComponent({
name: 'AllTableListModal',
components: { BasicModal, CheckSquareFilled, BorderOutlined },
props: {
tableName: { type: String, default: '' },
textField: { type: String, default: '' },
valueField: { type: String, default: '' },
imageField: { type: String, default: '' },
multi: { type: Boolean, default: false },
},
emits: ['success', 'register'],
setup(props, { emit }) {
const { popModalFixedWidth, resetBodyStyle, popBodyStyle } = useFixedHeightModal();
const modalBodyStyle = computed(() => ({ ...popBodyStyle.value, overflow: 'hidden', padding: '16px 20px' }));
// ---------- 数据状态 ----------
const loading = ref(false);
const dataList = ref<any[]>([]);
const total = ref(0);
const currentPage = ref(1);
const pageSize = ref(10);
const searchText = ref('');
// ---------- 选中状态(跨页持久化) ----------
const selectedKeys = ref<string[]>([]);
const selectedRecordsMap = ref<Record<string, any>>({});
function getRecordKey(record: any): string {
return String(record[props.valueField] ?? '');
}
function isSelected(record: any): boolean {
return selectedKeys.value.includes(getRecordKey(record));
}
function handleToggle(record: any) {
const key = getRecordKey(record);
if (props.multi) {
const idx = selectedKeys.value.indexOf(key);
if (idx >= 0) {
selectedKeys.value.splice(idx, 1);
delete selectedRecordsMap.value[key];
} else {
selectedKeys.value.push(key);
selectedRecordsMap.value[key] = record;
}
} else {
selectedKeys.value = [key];
selectedRecordsMap.value = { [key]: record };
}
}
function clearSelection() {
selectedKeys.value = [];
selectedRecordsMap.value = {};
}
// ---------- 字段计算 ----------
const mainTextField = computed(() => {
return (props.textField || '').split(',').filter(Boolean)[0] || props.valueField;
});
const otherTextFields = computed(() => {
return (props.textField || '').split(',').filter(Boolean).slice(1);
});
function getMainContent(record: any): string {
return record[mainTextField.value] ?? '';
}
function hasImage(record: any): boolean {
return !!(props.imageField && record[props.imageField]);
}
function getImageSrc(record: any): string {
if (!props.imageField) return '';
let url = record[props.imageField];
if (typeof url === 'string') url = url.split(',')[0];
return getFileAccessHttpUrl(url);
}
function handleImageError(e: Event) {
(e.target as HTMLImageElement).src = placeholderImage;
}
// ---------- 数据加载 ----------
async function loadData() {
if (!props.tableName || !props.valueField || !props.textField) return;
const showFieldSet = new Set((props.textField || '').split(',').filter(Boolean));
if (props.imageField) showFieldSet.add(props.imageField);
const showFields = Array.from(showFieldSet).join(',');
loading.value = true;
try {
const res = await queryTableData({
tableName: props.tableName,
showFields,
valueField: props.valueField,
keyword: searchText.value || undefined,
pageNo: currentPage.value,
pageSize: pageSize.value,
});
dataList.value = res?.records || [];
total.value = res?.total || 0;
} catch (e) {
console.error('[AllTableListModal] 加载数据失败', e);
dataList.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
function doSearch() {
currentPage.value = 1;
loadData();
}
function handlePageChange(page: number) {
currentPage.value = page;
loadData();
}
function handleSizeChange(_current: number, size: number) {
pageSize.value = size;
currentPage.value = 1;
loadData();
}
// ---------- 弹窗生命周期 ----------
const [registerModal, { closeModal }] = useModalInner((data) => {
// 重置搜索和分页
searchText.value = '';
currentPage.value = 1;
dataList.value = [];
total.value = 0;
// 恢复传入的已选记录
selectedKeys.value = (data.selectedRowKeys || []).map(String);
selectedRecordsMap.value = {};
for (const row of data.selectedRows || []) {
if (row && row[props.valueField] != null) {
selectedRecordsMap.value[String(row[props.valueField])] = row;
}
}
// 延迟加载(等弹窗完全挂载后再请求)
setTimeout(() => {
loadData();
resetBodyStyle();
}, 100);
});
// ---------- 提交 ----------
const submitLoading = ref(false);
const submitDisabled = computed(() => selectedKeys.value.length === 0);
function handleCancel() {
closeModal();
}
function handleSubmit() {
submitLoading.value = true;
const rows = Object.values(selectedRecordsMap.value);
if (rows.length > 0) {
emit('success', rows);
closeModal();
}
setTimeout(() => {
submitLoading.value = false;
}, 200);
}
return {
registerModal,
popModalFixedWidth,
modalBodyStyle,
loading,
dataList,
total,
currentPage,
pageSize,
searchText,
selectedKeys,
getRecordKey,
isSelected,
handleToggle,
clearSelection,
otherTextFields,
getMainContent,
hasImage,
getImageSrc,
handleImageError,
doSearch,
handlePageChange,
handleSizeChange,
handleCancel,
handleSubmit,
submitDisabled,
submitLoading,
};
},
});
</script>
<style scoped lang="less">
.atm-form-item {
height: 100%;
margin-bottom: 0;
:deep(.ant-form-item-row),
:deep(.ant-form-item-control),
:deep(.ant-form-item-control-input),
:deep(.ant-form-item-control-input-content) {
height: 100%;
}
}
.atm-layout {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.atm-toolbar {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 12px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}
.atm-selected-tip {
padding: 4px 10px;
font-size: 13px;
color: #1890ff;
background: #e6f7ff;
border-radius: 4px;
}
.atm-clear-link {
margin-left: 6px;
color: #ff4d4f;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.atm-spin-wrap {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.atm-content {
flex: 1;
min-height: 0;
padding: 16px 2px;
overflow-y: auto;
}
.atm-empty {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
margin: 0;
}
.atm-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
align-content: start;
gap: 12px;
}
/* 卡片 */
.atm-card {
position: relative;
display: inline-flex;
flex-direction: row;
width: 100%;
background: #fff;
min-height: 104px;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
cursor: pointer;
transition:
box-shadow 0.2s ease,
border-color 0.2s ease,
transform 0.2s ease;
&:hover {
border-color: #91caff;
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.1);
transform: translateY(-1px);
}
&.atm-card--selected {
border-color: #1890ff;
background: #f6fbff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.12);
}
}
/* 选中图标(右上角,位于卡片内部) */
.atm-card-check {
position: absolute;
top: 10px;
right: 10px;
z-index: 1;
line-height: 1;
font-size: 18px;
}
.atm-check-icon--on {
color: #1890ff;
}
.atm-check-icon--off {
color: #d9d9d9;
}
/* 内容区 */
.atm-card-inner {
flex: 1;
min-width: 0;
padding: 16px 34px 14px 16px;
overflow: hidden;
&.atm-has-image {
width: calc(100% - 80px);
}
}
.atm-card-title {
margin-bottom: 8px;
font-size: 15px;
font-weight: 600;
color: #262626;
line-height: 22px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.atm-card-fields {
display: flex;
flex-direction: column;
gap: 3px;
}
.atm-card-field-row {
display: flex;
align-items: center;
gap: 6px;
line-height: 20px;
overflow: hidden;
}
.atm-field-label {
font-size: 12px;
color: #8c8c8c;
white-space: nowrap;
flex-shrink: 0;
}
.atm-field-value {
font-size: 12px;
color: #595959;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* 图片区 */
.atm-card-image {
width: 88px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 12px 16px 12px 0;
overflow: hidden;
img {
width: 64px;
height: 64px;
border-radius: 50%;
background: #f5f5f5;
border: 1px solid #f0f0f0;
object-fit: cover;
}
}
/* 分页 */
.atm-pagination {
display: flex;
justify-content: flex-end;
flex-shrink: 0;
padding-top: 14px;
border-top: 1px solid #f0f0f0;
}
@media (max-width: 992px) {
.atm-card-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.atm-toolbar {
flex-wrap: wrap;
}
}
</style>

View File

@ -12,306 +12,549 @@
<a-button :disabled="submitDisabled" key="submit" type="primary" @click="handleSubmit" :loading="submitLoading">确定</a-button>
</template>
<BasicTable ref="tableRef" @register="registerTable" :rowSelection="rowSelection">
<template #tableTitle>
<a-input-search v-model:value="searchText" @search="onSearch" placeholder="请输入关键词,按回车搜索" style="width: 240px" />
</template>
<!-- 工具栏:搜索 + 已选提示 -->
<div class="ltm-toolbar">
<a-input-search
v-model:value="searchText"
@search="onSearch"
@pressEnter="onSearch"
placeholder="请输入关键词,按回车搜索"
style="width: 240px"
/>
<span v-if="cardSelectedKeys.length > 0" class="atm-selected-tip">
已选中 {{ cardSelectedKeys.length }} 条记录
<a class="atm-clear-link" @click="clearCardSelection">清空</a>
</span>
</div>
<!--操作栏-->
<template #action="{ record }">
<TableAction :actions="getTableAction(record)"> </TableAction>
</template>
<!-- 卡片视图 -->
<div v-if="cardLoading" class="atm-spin-wrap">
<a-spin />
</div>
<a-empty v-else-if="cardDataList.length === 0" style="margin: 40px 0" description="暂无数据" />
<div v-else class="atm-card-grid">
<div
v-for="record in cardDataList"
:key="getCardKey(record)"
class="atm-card"
:class="{ 'atm-card--selected': isCardSelected(record) }"
@click="handleCardToggle(record)"
>
<!-- 多选模式右上角图标 -->
<span v-if="multi" class="atm-card-check">
<CheckSquareFilled v-if="isCardSelected(record)" class="atm-check-icon--on" />
<BorderOutlined v-else class="atm-check-icon--off" />
</span>
<template #fileSlot="{ text }">
<span v-if="!text" style="font-size: 12px; font-style: italic">无文件</span>
<a-button v-else :ghost="true" type="primary" preIcon="ant-design:download" size="small" @click="downloadRowFile(text)"> 下载 </a-button>
</template>
<!-- 内容区 -->
<div class="atm-card-inner" :class="{ 'atm-has-image': hasCardImage(record) }">
<div class="atm-card-title">{{ getCardMainContent(record) }}</div>
<div class="atm-card-fields">
<div v-for="field in cardOtherDisplayFields" :key="field.key" class="atm-card-field-row">
<span class="atm-field-label">{{ field.title }}</span>
<span class="atm-field-value">{{ record[field.key] }}</span>
</div>
</div>
</div>
<template #imgSlot="{ text }">
<span v-if="!text" style="font-size: 12px; font-style: italic">无图片</span>
<img v-else :src="getImgView(text)" alt="图片不存在" class="online-cell-image" @click="viewOnlineCellImage(text)" />
</template>
<!-- 图片区 -->
<div v-if="hasCardImage(record)" class="atm-card-image">
<img :src="getCardImageSrc(record)" alt="" @error="handleCardImageError" />
</div>
</div>
</div>
<template #htmlSlot="{ text }">
<div v-html="text"></div>
</template>
<template #pcaSlot="{ text, column }">
<div :title="getPcaText(text, column)">{{ getPcaText(text, column) }}</div>
</template>
<template #dateSlot="{ text, column }">
<span>{{ getFormatDate(text, column) }}</span>
</template>
</BasicTable>
<!-- 卡片分页 -->
<div class="atm-pagination">
<a-pagination
v-model:current="cardCurrentPage"
:total="cardTotal"
:pageSize="cardPageSize"
:show-total="(t) => `共 ${t} 条`"
show-size-changer
@change="handleCardPageChange"
@showSizeChange="handleCardSizeChange"
/>
</div>
</BasicModal>
</template>
<script lang="ts">
import { defineComponent, watch, ref, toRaw, computed, nextTick } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import { useMessage } from '/@/hooks/web/useMessage';
import { defHttp } from '/@/utils/http/axios';
import { useTableColumns } from '@/views/super/online/cgform/hooks/auto/useTableColumns';
import { createAsyncComponent } from '@/utils/factory/createAsyncComponent';
import { useFixedHeightModal } from '../hooks/useLinkTable';
import { defineComponent, watch, ref, toRaw, computed } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { defHttp } from '/@/utils/http/axios';
import { useTableColumns } from '@/views/super/online/cgform/hooks/auto/useTableColumns';
import { useFixedHeightModal } from '../hooks/useLinkTable';
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
import { CheckSquareFilled, BorderOutlined } from '@ant-design/icons-vue';
import placeholderImage from '/@/assets/images/placeholderImage.png';
export default defineComponent({
name: 'LinkTableListModal',
props: {
/**可以是表名 可以是ID*/
id: {
type: String,
default: '',
},
multi: {
type: Boolean,
default: false,
},
addAuth: {
type: Boolean,
default: true,
},
export default defineComponent({
name: 'LinkTableListModal',
props: {
/**可以是表名 可以是ID*/
id: {
type: String,
default: '',
},
components: {
BasicModal,
BasicTable: createAsyncComponent(() => import('/@/components/Table/src/BasicTable.vue'), { loading: true, delay: 1000 }),
TableAction: createAsyncComponent(() => import('/@/components/Table/src/components/TableAction.vue'), { loading: true, delay: 1000 }),
multi: {
type: Boolean,
default: false,
},
emits: ['success', 'register'],
setup(props, { emit }) {
const { createMessage: $message } = useMessage();
const tableRef = ref(null);
// 弹窗高度控制
const { popModalFixedWidth, resetBodyStyle, popBodyStyle } = useFixedHeightModal();
const searchText = ref('');
const modalWidth = ref(800);
//useModalInner
const [registerModal, { closeModal }] = useModalInner((data) => {
searchText.value = '';
selectedRowKeys.value = data.selectedRowKeys;
selectedRows.value = data.selectedRows;
setTimeout(async () => {
await setPagination({ current: 1 });
await reload(); // 等待表格加载
resetBodyStyle();
}, 100);
});
addAuth: {
type: Boolean,
default: true,
},
},
components: {
BasicModal,
CheckSquareFilled,
BorderOutlined,
},
emits: ['success', 'register'],
setup(props, { emit }) {
const { createMessage: $message } = useMessage();
// 弹窗高度控制
const { popModalFixedWidth, resetBodyStyle, popBodyStyle } = useFixedHeightModal();
const searchText = ref('');
function handleCancel() {
closeModal();
}
const submitDisabled = computed(() => {
const arr = selectedRowKeys.value;
if (arr && arr.length > 0) {
return false;
// ==================== 卡片模式状态 ====================
const cardLoading = ref(false);
const cardDataList = ref<any[]>([]);
const cardTotal = ref(0);
const cardCurrentPage = ref(1);
const cardPageSize = ref(10);
// 卡片模式选中状态
const cardSelectedKeys = ref<string[]>([]);
const cardSelectedRecordsMap = ref<Record<string, any>>({});
function getCardKey(record: any): string {
return String(record['id'] ?? '');
}
function isCardSelected(record: any): boolean {
return cardSelectedKeys.value.includes(getCardKey(record));
}
function handleCardToggle(record: any) {
const key = getCardKey(record);
if (props.multi) {
const idx = cardSelectedKeys.value.indexOf(key);
if (idx >= 0) {
cardSelectedKeys.value.splice(idx, 1);
delete cardSelectedRecordsMap.value[key];
} else {
cardSelectedKeys.value.push(key);
cardSelectedRecordsMap.value[key] = record;
}
return true;
});
const submitLoading = ref(false);
function handleSubmit() {
submitLoading.value = true;
let arr = toRaw(selectedRows.value);
if (arr && arr.length > 0) {
emit('success', arr);
closeModal();
}
setTimeout(() => {
submitLoading.value = false;
}, 200);
} else {
cardSelectedKeys.value = [key];
cardSelectedRecordsMap.value = { [key]: record };
}
}
//---------------------列表------------------------
function queryTableData(params) {
const url = '/online/cgform/api/getData/' + props.id;
return defHttp.get({ url, params });
}
function clearCardSelection() {
cardSelectedKeys.value = [];
cardSelectedRecordsMap.value = {};
}
function list(params) {
params['column'] = 'id';
return new Promise(async (resolve, _reject) => {
const aa = await queryTableData(params);
resolve(aa);
// ==================== 卡片字段计算(基于 columns) ====================
// 主显示字段(第一个非图片/文件列)
const cardMainField = computed(() => {
const cols = columns.value || [];
const first = cols.find((c) => !isImageOrFileColumn(c) && c.dataIndex !== 'action');
return first?.dataIndex || '';
});
// 其他文本字段(排除主字段、图片、文件列,最多3个)
const cardOtherDisplayFields = computed(() => {
const cols = columns.value || [];
const mainKey = cardMainField.value;
return cols
.filter((c) => c.dataIndex !== mainKey && c.dataIndex !== 'action' && !isImageOrFileColumn(c))
.slice(0, 3)
.map((c) => ({ key: c.dataIndex, title: c.title || c.dataIndex }));
});
// 图片字段(第一个 imgSlot 列)
const cardImageField = computed(() => {
const cols = columns.value || [];
const imgCol = cols.find((c) => getColumnSlot(c) === 'imgSlot');
return imgCol?.dataIndex || '';
});
function getColumnSlot(col: any): string {
return col?.slots?.customRender || col?.customRender || '';
}
function isImageOrFileColumn(col: any): boolean {
const slot = getColumnSlot(col);
return slot === 'imgSlot' || slot === 'fileSlot';
}
function getCardMainContent(record: any): string {
return record[cardMainField.value] ?? '';
}
function hasCardImage(record: any): boolean {
const field = cardImageField.value;
return !!(field && record[field]);
}
function getCardImageSrc(record: any): string {
const field = cardImageField.value;
if (!field) return '';
let url = record[field];
if (typeof url === 'string') url = url.split(',')[0];
return getFileAccessHttpUrl(url);
}
function handleCardImageError(e: Event) {
(e.target as HTMLImageElement).src = placeholderImage;
}
// ==================== 卡片数据加载 ====================
async function loadCardData() {
if (!props.id) return;
cardLoading.value = true;
try {
const params = addQueryParams({
column: 'id',
pageNo: cardCurrentPage.value,
pageSize: cardPageSize.value,
});
const res = await queryTableData(params);
cardDataList.value = res?.records || [];
cardTotal.value = res?.total || 0;
} catch (e) {
console.error('[LinkTableListModal] 卡片数据加载失败', e);
cardDataList.value = [];
cardTotal.value = 0;
} finally {
cardLoading.value = false;
}
}
const onlineTableContext = {
isPopList: true,
reloadTable() {
console.log('reloadTable');
},
isTree() {
return false;
},
};
const extConfigJson = ref<any>({});
function handleCardPageChange(page: number) {
cardCurrentPage.value = page;
loadCardData();
}
// 处理 BasicTable 的配置
const { columns, downloadRowFile, getImgView, getPcaText, getFormatDate, handleColumnResult, hrefComponent, viewOnlineCellImage } =
useTableColumns(onlineTableContext, extConfigJson);
function handleCardSizeChange(_current: number, size: number) {
cardPageSize.value = size;
cardCurrentPage.value = 1;
loadCardData();
}
/**
* 查询table列信息 及其他配置
*/
function getColumnList() {
const url = '/online/cgform/api/getColumns/' + props.id;
return new Promise((resolve, reject) => {
defHttp.get({ url }, { isTransformResponse: false }).then((res) => {
if (res.success) {
resolve(res.result);
} else {
$message.warning(res.message);
reject();
}
});
});
}
// ==================== 弹窗 ====================
const [registerModal, { closeModal }] = useModalInner((data) => {
searchText.value = '';
const modalTitle = ref('');
watch(
() => props.id,
async () => {
let columnResult: any = await getColumnList();
handleColumnResult(columnResult);
modalTitle.value = columnResult.description;
},
{ immediate: true }
);
const { tableContext } = useListPage({
designScope: 'process-design',
pagination: true,
tableProps: {
title: '',
api: list,
clickToRowSelect: true,
columns: columns,
showTableSetting: false,
immediate: false,
//showIndexColumn: true,
canResize: false,
showActionColumn: false,
actionColumn: {
dataIndex: 'action',
slots: { customRender: 'action' },
},
useSearchForm: false,
beforeFetch: (params) => {
return addQueryParams(params);
},
},
});
const [registerTable, { reload, setPagination }, { rowSelection, selectedRowKeys, selectedRows }] = tableContext;
watch(
() => props.multi,
(val) => {
if (val == true) {
rowSelection.type = 'checkbox';
} else {
rowSelection.type = 'radio';
}
},
{ immediate: true }
);
/**
* 操作栏
*/
function getTableAction(record) {
return [
{
label: '编辑',
onClick: handleUpdate.bind(null, record),
},
];
}
function handleUpdate(record) {
console.log('handleUpdate', record);
}
function onSearch() {
reload();
}
const eqConditonTypes = ['int', 'double', 'Date', 'Datetime', 'BigDecimal'];
function addQueryParams(params) {
let text = searchText.value;
if (!text) {
params['superQueryMatchType'] = 'or';
params['superQueryParams'] = '';
return params;
// 同步卡片选中状态
cardSelectedKeys.value = (data.selectedRowKeys || []).map(String);
cardSelectedRecordsMap.value = {};
for (const row of data.selectedRows || []) {
if (row && row['id'] != null) {
cardSelectedRecordsMap.value[String(row['id'])] = row;
}
let arr = columns.value;
let conditions: any[] = [];
if (arr && arr.length > 0) {
for (let item of arr) {
if (item.dbType) {
if (item.dbType == 'string') {
conditions.push({ field: item.dataIndex, type: item.dbType.toLowerCase(), rule: 'like', val: text });
} else if (item.dbType == 'Date') {
if (text.length == '2020-10-10'.length) {
conditions.push({ field: item.dataIndex, type: item.dbType.toLowerCase(), rule: 'eq', val: text });
}
} else if (item.dbType == 'Datetime') {
if (text.length == '2020-10-10 10:10:10'.length) {
conditions.push({ field: item.dataIndex, type: item.dbType.toLowerCase(), rule: 'eq', val: text });
}
} else if (eqConditonTypes.indexOf(item.dbType)) {
conditions.push({ field: item.dataIndex, type: item.dbType.toLowerCase(), rule: 'eq', val: text });
} else {
//text blob不做处理
}
}
}
}
params['superQueryMatchType'] = 'or';
params['superQueryParams'] = encodeURI(JSON.stringify(conditions));
return params;
}
// modal数据新增完成 直接关闭list,将新增的数据带回表单
function handleDataSave(data) {
console.log('handleDateSave', data);
// 代码逻辑说明: 【issues/8163】关联记录新增丢失
let arr = [data, ...selectedRows.value];
setTimeout(async () => {
cardCurrentPage.value = 1;
await loadCardData();
resetBodyStyle();
}, 100);
});
function handleCancel() {
closeModal();
}
const submitDisabled = computed(() => cardSelectedKeys.value.length === 0);
const submitLoading = ref(false);
function handleSubmit() {
submitLoading.value = true;
const arr = Object.values(cardSelectedRecordsMap.value);
if (arr && arr.length > 0) {
emit('success', arr);
closeModal();
//reload();
}
setTimeout(() => {
submitLoading.value = false;
}, 200);
}
return {
registerModal,
modalWidth,
handleCancel,
submitDisabled,
submitLoading,
handleSubmit,
// ==================== 数据请求 ====================
function queryTableData(params) {
const url = '/online/cgform/api/getData/' + props.id;
return defHttp.get({ url, params });
}
registerTable,
getTableAction,
searchText,
onSearch,
const onlineTableContext = {
isPopList: true,
reloadTable() {},
isTree() {
return false;
},
};
const extConfigJson = ref<any>({});
downloadRowFile,
getImgView,
getPcaText,
getFormatDate,
hrefComponent,
viewOnlineCellImage,
rowSelection,
modalTitle,
const { columns, handleColumnResult } = useTableColumns(onlineTableContext, extConfigJson);
reload,
function getColumnList() {
const url = '/online/cgform/api/getColumns/' + props.id;
return new Promise((resolve, reject) => {
defHttp.get({ url }, { isTransformResponse: false }).then((res) => {
if (res.success) {
resolve(res.result);
} else {
$message.warning(res.message);
reject();
}
});
});
}
popModalFixedWidth,
popBodyStyle,
handleDataSave,
const modalTitle = ref('');
watch(
() => props.id,
async () => {
let columnResult: any = await getColumnList();
handleColumnResult(columnResult);
modalTitle.value = columnResult.description;
},
{ immediate: true }
);
tableRef,
};
},
});
function onSearch() {
cardCurrentPage.value = 1;
loadCardData();
}
const eqConditonTypes = ['int', 'double', 'Date', 'Datetime', 'BigDecimal'];
function addQueryParams(params) {
let text = searchText.value;
if (!text) {
params['superQueryMatchType'] = 'or';
params['superQueryParams'] = '';
return params;
}
let arr = columns.value;
let conditions: any[] = [];
if (arr && arr.length > 0) {
for (let item of arr) {
if (item.dbType) {
if (item.dbType == 'string') {
conditions.push({ field: item.dataIndex, type: item.dbType.toLowerCase(), rule: 'like', val: text });
} else if (item.dbType == 'Date') {
if (text.length == '2020-10-10'.length) {
conditions.push({ field: item.dataIndex, type: item.dbType.toLowerCase(), rule: 'eq', val: text });
}
} else if (item.dbType == 'Datetime') {
if (text.length == '2020-10-10 10:10:10'.length) {
conditions.push({ field: item.dataIndex, type: item.dbType.toLowerCase(), rule: 'eq', val: text });
}
} else if (eqConditonTypes.indexOf(item.dbType)) {
conditions.push({ field: item.dataIndex, type: item.dbType.toLowerCase(), rule: 'eq', val: text });
}
}
}
}
params['superQueryMatchType'] = 'or';
params['superQueryParams'] = encodeURI(JSON.stringify(conditions));
return params;
}
return {
registerModal,
handleCancel,
submitDisabled,
submitLoading,
handleSubmit,
searchText,
onSearch,
modalTitle,
popModalFixedWidth,
popBodyStyle,
// 卡片模式
cardLoading,
cardDataList,
cardTotal,
cardCurrentPage,
cardPageSize,
cardSelectedKeys,
getCardKey,
isCardSelected,
handleCardToggle,
clearCardSelection,
cardOtherDisplayFields,
getCardMainContent,
hasCardImage,
getCardImageSrc,
handleCardImageError,
handleCardPageChange,
handleCardSizeChange,
};
},
});
</script>
<style scoped></style>
<style scoped lang="less">
/* ===== 工具栏 ===== */
.ltm-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
/* ===== 卡片通用样式 ===== */
.atm-selected-tip {
font-size: 13px;
color: #1890ff;
}
.atm-clear-link {
margin-left: 6px;
color: #ff4d4f;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.atm-spin-wrap {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.atm-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
padding: 0 4px;
min-height: 100px;
}
.atm-card {
position: relative;
display: inline-flex;
flex-direction: row;
width: 100%;
background: #fff;
border-radius: 4px;
border: 2px solid transparent;
box-shadow:
0 1px 4px rgba(0, 0, 0, 0.12),
0 0 2px rgba(0, 0, 0, 0.08);
cursor: pointer;
transition:
box-shadow 0.2s,
border-color 0.2s;
&:hover {
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
}
&.atm-card--selected {
border-color: #1890ff;
}
}
.atm-card-check {
position: absolute;
top: 6px;
right: 8px;
z-index: 1;
line-height: 1;
font-size: 16px;
}
.atm-check-icon--on {
color: #1890ff;
}
.atm-check-icon--off {
color: #d9d9d9;
}
.atm-card-inner {
flex: 1;
padding: 12px 28px 12px 14px;
overflow: hidden;
&.atm-has-image {
width: calc(100% - 80px);
}
}
.atm-card-title {
font-size: 14px;
font-weight: 500;
color: #333;
line-height: 20px;
margin-bottom: 6px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.atm-card-fields {
display: flex;
flex-direction: column;
gap: 2px;
}
.atm-card-field-row {
display: flex;
align-items: center;
gap: 4px;
line-height: 20px;
overflow: hidden;
}
.atm-field-label {
font-size: 12px;
color: #9e9e9e;
white-space: nowrap;
flex-shrink: 0;
}
.atm-field-value {
font-size: 12px;
color: #555;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.atm-card-image {
width: 72px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 8px 8px 0;
overflow: hidden;
img {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
}
}
.atm-pagination {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
</style>

View File

@ -13,6 +13,39 @@ function queryTableColumns(tableName, params) {
const url = '/online/cgform/api/getColumns/' + tableName;
return defHttp.get({ url, params });
}
//表格列信息
const tableColumns = ref<any[]>([]);
// 选项
const selectOptions = ref<any[]>([]);
//字典项
const dictOptions = ref<any>({});
/**
* table 模式:调用 /sys/dict/queryTableDataForLinkRecord 接口(一次请求获取多字段数据)
*/
function queryAllTableData(params: {
tableName: string;
showFields: string;
valueField: string;
keyword?: string;
searchFields?: string;
condition?: string;
keyValues?: string;
pageNo?: number;
pageSize?: number;
}) {
return defHttp.get({ url: '/sys/dict/queryTableDataForLinkRecord', params });
}
/**
* table 模式:获取需要查询的所有展示字段(textFields + imageField,去重)
*/
function getAllTableShowFields(props): string {
const textFields = (props.textField || '').split(',').filter(Boolean);
const imageField = props.imageField;
const fieldSet = new Set<string>(textFields);
if (imageField) fieldSet.add(imageField);
return Array.from(fieldSet).join(',');
}
export function useLinkTable(props) {
//TODO 目前只支持查询第一页的数据,可以输入关键字搜索
@ -50,6 +83,18 @@ export function useLinkTable(props) {
watchEffect(async () => {
const table = props.tableName;
if (table) {
if (props.queryMode === 'table') {
// table 模式:跳过 Online 接口,根据 textField/imageField 配置初始化展示列
const textFields = (props.textField || '').split(',').filter(Boolean);
mainContentField.value = textFields[0] || '';
// otherColumns:textField 中第一个之后的字段,用于卡片副内容展示
otherColumns.value = textFields.slice(1).map((f) => ({ title: f, dataIndex: f }));
// tableColumns 记录所有字段(transData 中使用,allTable 模式下实为 no-op)
tableColumns.value = textFields.map((f) => ({ title: f, dataIndex: f }));
dictOptions.value = {};
return;
}
const valueField = props.valueField || '';
const textField = props.textField || '';
const arr: any[] = [];
@ -94,10 +139,6 @@ export function useLinkTable(props) {
};
});
// 选项
const selectOptions = ref<any[]>([]);
const tableColumns = ref<any[]>([]);
const dictOptions = ref<any>({});
async function resetTableColumns() {
const params = baseParam.value;
@ -210,6 +251,34 @@ export function useLinkTable(props) {
if (!value) {
return [];
}
// table 模式:调用新接口,按 keyValues 反查所有字段,一次返回完整记录
if (props.queryMode === 'table') {
const showFields = getAllTableShowFields(props);
if (!showFields) return [];
try {
const data = await queryAllTableData({
tableName: props.tableName,
showFields,
valueField: props.valueField,
keyValues: value,
pageNo: 1,
pageSize: 200,
});
const records: any[] = data?.records || [];
if (records.length > 0) return records;
// 接口无数据时用原始 value 兜底展示
const firstTextField = (props.textField || '').split(',').filter(Boolean)[0] || props.valueField;
return value
.split(',')
.filter(Boolean)
.map((v: string) => ({ id: v, [props.valueField]: v, [firstTextField]: v }));
} catch (e) {
console.error('[useLinkTable] table loadOne 失败', e);
return [];
}
}
let valueFieldName = props.valueField;
let params = {
...baseParam.value,

View File

@ -0,0 +1,648 @@
import type { Ref } from 'vue';
import type { ExtConfigType } from '../../types';
import { HrefSlots, OnlineColumn } from '/@/components/jeecg/OnLine/types/onlineConfig';
import { filterMultiDictObjs } from '/@/utils/dict/JDictSelectUtil';
import { computed, defineAsyncComponent, h, reactive, ref, toRaw, unref, watch, markRaw } from 'vue';
import { useRouter } from 'vue-router';
import { Tag as ATag } from 'ant-design-vue';
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
import { getAreaTextByCodeAnyLevel } from '/@/components/Form/src/utils/Area';
import { createImgPreview } from '@/components/Preview';
import { importViewsFile, _eval } from '/@/utils';
import { useModal } from '/@/components/Modal';
import LinkTableListPiece from '../../extend/linkTable/LinkTableListPiece.vue'
import { getToken } from "/@/utils/auth";
import { downloadFile } from '/@/api/common/api';
import { getWeekMonthQuarterYear, split } from '/@/utils';
import { getItemColor } from "@/utils/dict/DictColors";
/**
* 获取实际列表需要的column配置
* @param onlineTableContext 从数据库中查出来的数据
* @param extConfigJson 扩展配置JSON
*/
export function useTableColumns(onlineTableContext, extConfigJson: Ref<ExtConfigType | undefined>) {
// 获取路由器对象 href跳转用到
let router = useRouter();
// 列信息
const columns = ref<Array<OnlineColumn>>([]);
/**
* 20260309
* liaozhiyang
* 【issues/9336】列宽拖动不了
* */
function applyResizableColumns(cols: OnlineColumn[]) {
cols.forEach((column) => {
if (!column.width) {
if (column.fieldType === 'date' || column.fieldType === 'Date') {
column.width = 120;
} else if (column.fieldType === 'link_table') {
column.width = 180;
} else {
column.width = 150;
}
}
column.resizable = true;
});
}
// 是否有bpm_status
//const hasBpmStatus = ref<boolean>(false)
// 字典信息
const dictOptionInfo = ref<any>({});
//已选择的值
const selectedKeys = ref<any[]>([]);
//选择的行记录
//const selectRows = ref<Array<any>>([]);
// 选择列配置 --computed有问题
const rowSelection = ref<any>(null);
// 是否有滚动条
let enableScrollBar = ref(true);
// table属性scroll
let tableScroll = computed(() => {
if (enableScrollBar.value == true) {
return undefined;
} else {
// X轴没有滚动条
return { x: false };
}
});
//用于 online列表的 某列的点击弹窗事件-弹窗显示其他表单
const [registerOnlineHrefModal, { openModal: openOnlineHrefModal }] = useModal();
const hrefMainTableId = ref('')
// 用于 online表单中 弹出别的表单
const [registerPopModal, { openModal: openPopModal }] = useModal();
const popTableId = ref('')
// 对查询列信息的请求结果 处理方法
function handleColumnResult(result, type = 'checkbox') {
// 字典设置
dictOptionInfo.value = result.dictOptions;
// rowSelection设置
if (result.checkboxFlag == 'Y') {
rowSelection.value = {
selectedRowKeys: selectedKeys,
onChange: onSelectChange,
type,
};
} else {
rowSelection.value = null;
}
// 是否允许滚动条
enableScrollBar.value = result.scrollFlag == 1;
let dataColumns = result.columns;
dataColumns.forEach((column) => {
if (extConfigJson?.value?.canResizeColumn === 1) {
// update-begin--author:liaozhiyang---date:20260309---for:【issues/9336】列宽拖动不了
applyResizableColumns([column]);
// update-end--author:liaozhiyang---date:20260309---for:【issues/9336】列宽拖动不了
}
// update-begin--author:liaozhiyang---date:20230818---for:【QQYUN-4161】列支持固定功能
if (column.fieldExtendJson) {
const json = JSON.parse(column.fieldExtendJson);
if (!!json.isFixed) {
column.fixed = 'left';
}
}
// update-end--author:liaozhiyang---date:20230818---for:【QQYUN-4161】列支持固定功能
// update-begin--author:liaozhiyang---date:20240517---for:【TV360X-129】增加富文本控件配置href跳转
if (column.hrefSlotName && column.scopedSlots) {
const obj = result.fieldHrefSlots?.find((item) => item.slotName === column.hrefSlotName);
if (obj) {
column.fieldHref = obj;
}
}
// update-end--author:liaozhiyang---date:20240517---for:【TV360X-129】增加富文本控件配置href跳转
Object.keys(column).map((key) => {
// 删掉空值的字段(不删除 空字符串('') 或 数字 0 )
if (column[key] == null) {
delete column[key];
}
});
});
// href 跳转
let fieldHrefSlots: HrefSlots[] = result.fieldHrefSlots;
const fieldHrefSlotKeysMap = {};
fieldHrefSlots.forEach((item) => (fieldHrefSlotKeysMap[item.slotName] = item));
let tableColumns: OnlineColumn[] = [];
// 处理列中的 href 跳转和 dict 字典,使两者可以兼容存在
tableColumns = handleColumnHrefAndDict(dataColumns, fieldHrefSlotKeysMap);
// 是否有 bpm_status字段 如果有,列表操作按钮需要增加提交流程按钮
bpmStatusFilter(tableColumns);
console.log('-----列表列配置----', tableColumns);
// 如果是树列表 需要设置第一列字段 及 第一列align
if (onlineTableContext.isTree() === true) {
// 找到第一列的配置
let firstField = result.textField;
let index = -1;
for (let i = 0; i < tableColumns.length; i++) {
if (tableColumns[i].dataIndex == firstField) {
index = i;
break;
}
}
if (index > 0) {
//如果是0或是-1不需要处理
let deleteColumns = tableColumns.splice(index, 1);
tableColumns.unshift(deleteColumns[0]);
}
//第一列居左
if (tableColumns.length > 0) {
tableColumns[0].align = 'left';
}
}
columns.value = tableColumns;
// 列发生了变化,需要重新渲染表格
onlineTableContext.reloadTable();
}
/**
* 表格选择事件 [expose]
* @param selectedRowKeys
* @param selectRow
*/
function onSelectChange(selectedRowKeys, selectedRows) {
selectedKeys.value = selectedRowKeys;
onlineTableContext['selectedRows'] = toRaw(selectedRows);
onlineTableContext['selectedRowKeys'] = toRaw(selectedRowKeys);
}
/**
* 处理列的href和字典翻译
*/
function handleColumnHrefAndDict(columns: OnlineColumn[], fieldHrefSlotKeysMap: {}): OnlineColumn[] {
for (let column of columns) {
let { customRender, hrefSlotName, fieldType } = column;
// online 报表中类型配置为日期(yyyy-MM-dd ),但是实际展示为日期时间格式(yyyy-MM-dd HH:mm:ss) issues/3042
if (fieldType == 'date' || fieldType == 'Date') {
column.customRender = ({ text }) => {
if (!text) {
return '';
}
if (text.length > 10) {
return text.substring(0, 10);
}
return text;
};
} else if (fieldType == 'link_table') {
// 关联记录列表展示
// update-begin--author:liaozhiyang---date:20250318---for:【issues/7930】表格列表中支持关联记录配置是否只读
const fieldExtendJson = column.fieldExtendJson ?? '{}';
const json = JSON.parse(fieldExtendJson);
// update-end--author:liaozhiyang---date:20250318---for:【issues/7930】表格列表中支持关联记录配置是否只读
column.customRender = ({ text, record }) => {
if (!text) {
return '';
}
if(onlineTableContext.isPopList===true){
// 如果是弹窗的列表,关联记录的列只支持数据翻译,不需要跳转逻辑
return record[column.dataIndex+"_dictText"]
}else{
let tempIdArray = (text+'').split(',');
//update-begin-author:taoyan date:2023-2-15 for: QQYUN-4286【online表单】主子表开启联合查询 功能测试报错打不开
let tempLabelArray = [];
if(record[column.dataIndex+"_dictText"]){
tempLabelArray = record[column.dataIndex+"_dictText"].split(',');
}
//update-end-author:taoyan date:2023-2-15 for: QQYUN-4286【online表单】主子表开启联合查询 功能测试报错打不开
let renderResult:any = []
for(let i=0;i<tempIdArray.length;i++){
let renderObj = h(
LinkTableListPiece,
{
id: tempIdArray[i],
text: tempLabelArray[i],
onTab:(id)=>handleClickLinkTable(id, hrefSlotName, json.isListReadOnly)
}
);
renderResult.push(renderObj)
}
if(renderResult.length==0){
return ''
}
//如果需要显示全,但是会换行:display: flex;width: 100%;flex-wrap: wrap;flex-direction: row;
return h('div',{style:{'overflow':'hidden'}}, renderResult);
}
};
} else if (fieldType === 'popup_dict') {
// update-begin--author:liaozhiyang---date:20240402---for:【QQYUN-8833】JPopupDict的列表翻译
column.customRender = ({ text, record }) => {
const dict = record[column.dataIndex + '_dictText'];
if (dict != undefined) {
return record[column.dataIndex + '_dictText'];
}
return text;
};
// update-end--author:liaozhiyang---date:20240402---for:【QQYUN-8833】JPopupDict的列表翻译
} else {
if (!hrefSlotName && column.scopedSlots && column.scopedSlots.customRender) {
//【Online报表】字典和href互斥 这里通过fieldHrefSlotKeysMap 先找到是href的列
if (fieldHrefSlotKeysMap.hasOwnProperty(column.scopedSlots.customRender)) {
hrefSlotName = column.scopedSlots.customRender;
}
}
// 如果 customRender 有值则代表使用了字典
// 如果 hrefSlotName 有值则代表使用了href跳转
// 两者可以兼容。兼容的具体思路为:先获取到字典替换的值,再添加href链接跳转
if (customRender || hrefSlotName) {
let dictCode = customRender as string;
let replaceFlag = '_replace_text_';
// 自定义渲染函数的列 需要手动配置ellipsis
column.ellipsis = true;
column.customRender = ({ text, record }) => {
let value = text;
const valueSpan: any[] = [];
const getValue = () => valueSpan.length ? valueSpan : value;
// 如果 dictCode 有值,就进行字典转换
if (dictCode) {
if (dictCode.startsWith(replaceFlag)) {
let textFieldName = dictCode.replace(replaceFlag, '');
value = record[textFieldName];
} else {
const dictItems = filterMultiDictObjs(unref(dictOptionInfo)[dictCode], text);
value = dictItems.map((item) => {
if (item.hasColor) {
//获取字体颜色
const fontColor = getItemColor(item.color);
valueSpan.push(h(ATag, {
color: item.color,
style: {
'color': fontColor,
'margin-left': '5px',
},
}, () => item.text))
}
return item.text;
}).join(',');
}
}
// 扩展参数设置列的内容长度
if (column.showLength) {
if (value && value.length > column.showLength) {
value = value.substr(0, column.showLength) + '...';
}
}
// 如果 hrefSlotName 有值,就生成一个 a 标签,包裹住字典替换后(或原生)的值
if (hrefSlotName) {
let field = fieldHrefSlotKeysMap[hrefSlotName];
if (field) {
return h(
'a',
{
onClick: () => handleClickFieldHref(field, record),
},
getValue(),
);
}
}
return h('span', {}, getValue());
};
}
// 老版本叫scopedSlots 新版叫slots
if (column.scopedSlots) {
// slot的列 需要手动配置ellipsis
column.ellipsis = true;
let slots = column.scopedSlots;
column['slots'] = slots;
delete column.scopedSlots;
}
}
}
return columns;
}
/**
* href 点击事件
* @param field
* @param record
*/
function handleClickFieldHref(field, record) {
let href = field.href;
let urlPattern = /(ht|f)tp(s?)\:\/\/[0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*(:(0-9)*)*(\/?)([a-zA-Z0-9\-\.\?\,\'\/\\\+&amp;%\$#_]*)?/;
let compPattern = /\.vue(\?.*)?$/;
let jsPattern = /{{([^}]+)}}/g; // {{ xxx }}
if (typeof href === 'string') {
if(href.startsWith('ONLINE:')){
// ONLINE:tableId:fieldName
let arr = href.split(':')
hrefMainTableId.value = arr[1];
let fieldName = arr[2];
openOnlineHrefModal(true, {
isUpdate: true,
disableSubmit: true,
hideSub: true,
record:{id: record[fieldName]},
})
}else{
href = href.trim().replace(/\${([^}]+)?}/g, (_s1, s2) => record[s2]);
// 执行 {{...}} JS增强语句
if (jsPattern.test(href)) {
href = href.replace(jsPattern, function (text, s0) {
try {
// 支持 {{ ACCESS_TOKEN }} 占位符
if (s0.trim() === 'ACCESS_TOKEN') {
return getToken()
}
// update-begin--author:liaozhiyang---date:20230904---for:【QQYUN-6390】eval替换成new Function,解决build警告
return _eval(s0);
// update-end--author:liaozhiyang---date:20230904---for:【QQYUN-6390】eval替换成new Function,解决build警告
} catch (e) {
console.error(e);
return text;
}
});
}
if (urlPattern.test(href)) {
window.open(href, '_blank');
} else if (compPattern.test(href)) {
// 处理弹框
openHrefCompModal(href);
} else {
router.push(href);
}
}
}
}
// 样式
const dialogStyle = {
top: 0,
left: 0,
height: '100%',
margin: 0,
padding: 0,
};
// update-begin--author:liaozhiyang---date:20231218---for:【QQYUN-6366】升级到antd4.x
// 弹窗属性配置
const hrefComponent = reactive({
model: {
title: '',
okText: '关闭',
width: '100%',
open: false,
destroyOnClose: true,
style: dialogStyle,
// dialogStyle: dialogStyle,
bodyStyle: { padding: '8px', height: 'calc(100vh - 108px)', overflow: 'auto', overflowX: 'hidden' },
// 隐藏掉取消按钮
cancelButtonProps: { style: { display: 'none' } },
},
on: {
ok: () => (hrefComponent.model.open = false),
cancel: () => (hrefComponent.model.open = false),
},
is: <any>null,
params: {},
});
// update-end--author:liaozhiyang---date:20231218---for:【QQYUN-6366】升级到antd4.x
// 超链点击事件--> 打开一个modal窗口
function openHrefCompModal(href) {
// 解析 href 参数
let index = href.indexOf('?');
let path = href;
if (index !== -1) {
path = href.substring(0, index);
let paramString = href.substring(index + 1, href.length);
let paramArray = paramString.split('&');
let params = {};
paramArray.forEach((paramObject) => {
let paramItem = paramObject.split('=');
params[paramItem[0]] = paramItem[1];
});
hrefComponent.params = params;
} else {
hrefComponent.params = {};
}
// update-begin--author:liaozhiyang---date:20231218---for:【QQYUN-6366】升级到antd4.x
hrefComponent.model.open = true;
// update-end--author:liaozhiyang---date:20231218---for:【QQYUN-6366】升级到antd4.x
hrefComponent.model.title = '操作';
hrefComponent.is = markRaw(defineAsyncComponent(() => importViewsFile(path)));
}
//如果是树列表 操作列只能右侧固定
let fixedAction:any = 'right';
if(onlineTableContext.isTree()){
fixedAction = 'right'
}
const actionColumn = reactive<OnlineColumn>({
title: '操作',
dataIndex: 'action',
slots: { customRender: 'action' },
fixed: fixedAction,
align: 'center',
width: 150,
});
// 监听扩展参数的固定列配置,动态改变操作列的固定方式
watch(() => extConfigJson?.value, () => {
if (extConfigJson?.value?.tableFixedAction === 1) {
actionColumn.fixed = extConfigJson?.value?.tableFixedActionType || 'right';
if (onlineTableContext.isTree()) {
actionColumn.fixed = 'right';
}
}
// update-begin--author:liaozhiyang---date:20260309---for:【issues/9336】列宽拖动不了
if (extConfigJson?.value?.canResizeColumn === 1 && columns.value.length > 0) {
applyResizableColumns(columns.value);
onlineTableContext.reloadTable();
}
// update-end--author:liaozhiyang---date:20260309---for:【issues/9336】列宽拖动不了
});
// 流程按钮状态
function bpmStatusFilter(tableColumns: OnlineColumn[]): boolean {
let flag = false;
for (let i = 0; i < tableColumns.length; i++) {
let item = tableColumns[i];
let fieldName = item.dataIndex;
if (fieldName!.toLowerCase() == 'bpm_status') {
flag = true;
break;
}
}
onlineTableContext['hasBpmStatus'] = flag;
return flag;
}
/**
* 文件
* @param text
*/
function downloadRowFile(text, record, column, id) {
if (!text) {
return;
}
// update-begin--author:liaozhiyang---date:20240124---for:【QQYUN-8020】online 表单有多个文件走下载接口
if (text.indexOf(',') > 0) {
downloadFile(`/online/cgform/field/download/${id}/${record.id}/${column.dataIndex}`, `文件_${record.id}.zip`);
} else {
const url = getFileAccessHttpUrl(text);
window.open(url);
}
// update-end--author:liaozhiyang---date:20240124---for:【QQYUN-8020】online 表单有多个文件走下载接口
}
/**
* 图片
* @param text
*/
function getImgView(text) {
if (text && text.indexOf(',') > 0) {
// update-begin--author:liaozhiyang---date:20250325---for:【issues/7990】图片参数中包含逗号会错误的识别成多张图
text = split(text)[0];
// update-end--author:liaozhiyang---date:20250325---for:【issues/7990】图片参数中包含逗号会错误的识别成多张图
}
return getFileAccessHttpUrl(text);
}
/**
* 根据编码获取省市区文本
* @param code
*/
function getPcaText(code, column) {
if (!code) {
return '';
}
// update-begin--author:liaozhiyang---date:20260204---for:【QQYUN-14694】online支持配置独立的省、市、县
let includeParent = true;
let fieldExtendJson = column?.fieldExtendJson;
let level = 3;
if (fieldExtendJson) {
fieldExtendJson = JSON.parse(fieldExtendJson);
if (['province', 'city', 'region'].includes(fieldExtendJson.displayLevel)) {
if (fieldExtendJson.displayLevel === 'province') {
level = 1;
} else if (fieldExtendJson.displayLevel === 'city') {
level = 2;
} else if (fieldExtendJson.displayLevel === 'region') {
level = 3;
}
includeParent = false;
}
}
return getAreaTextByCodeAnyLevel(code, includeParent, level as 1 | 2 | 3);
// update-end--author:liaozhiyang---date:20260204---for:【QQYUN-14694】online支持配置独立的省、市、县
}
/**
* 日期格式化
* @param text
*/
function getFormatDate(text, column) {
if (!text) {
return '';
}
let a = text;
if (a.length > 10) {
a = a.substring(0, 10);
}
// update-begin--author:liaozhiyang---date:20240430---for:【issues/6094】online 日期(年月日)控件增加年、年月,年周,年季度等格式
let fieldExtendJson = column?.fieldExtendJson;
if (fieldExtendJson) {
fieldExtendJson = JSON.parse(fieldExtendJson);
if (fieldExtendJson.picker && fieldExtendJson.picker != 'default') {
const result = getWeekMonthQuarterYear(a);
return result[fieldExtendJson.picker];
}
}
// update-end--author:liaozhiyang---date:20240430---for:【issues/6094】online 日期(年月日)控件增加年、年月,年周,年季度等格式
return a;
}
watch(selectedKeys, () => {
onlineTableContext['selectedRowKeys'] = toRaw(selectedKeys.value);
});
onlineTableContext['clearSelectedRow'] = () => {
selectedKeys.value = [];
onlineTableContext['selectedRows'] = [];
onlineTableContext['selectedRowKeys'] = [];
};
/**
* 预览列表 cell 图片
* @param text
*/
function viewOnlineCellImage(text) {
if (text) {
let imgList: any = [];
// update-begin--author:liaozhiyang---date:20250325---for:【issues/7990】图片参数中包含逗号会错误的识别成多张图
const arr = split(text);
// update-end--author:liaozhiyang---date:20250325---for:【issues/7990】图片参数中包含逗号会错误的识别成多张图
for (let str of arr) {
if (str) {
imgList.push(getFileAccessHttpUrl(str));
}
}
createImgPreview({ imageList: imgList });
}
}
/**
* link table控件在列表上显示 支持点击跳转表单
* @param id
* @param hrefTableName
*/
const onlinePopModalRef = ref();
async function handleClickLinkTable(id, hrefTableName, isListReadOnly){
popTableId.value = hrefTableName;
let formStatus = await onlinePopModalRef.value.getFormStatus();
// 判断当前表单是否支持编辑,不能编辑跳详情表单
if(formStatus==true){
hrefMainTableId.value = hrefTableName;
openOnlineHrefModal(true, {
isUpdate: true,
disableSubmit: true,
hideSub: true,
record:{id: id},
})
}else{
openPopModal(true, {
isUpdate: true,
// update-begin--author:liaozhiyang---date:20250318---for:【issues/7930】表格列表中支持关联记录配置是否只读
disableSubmit: isListReadOnly ? true : false,
// update-end--author:liaozhiyang---date:20250318---for:【issues/7930】表格列表中支持关联记录配置是否只读
record: {
id: id
}
});
}
}
return {
columns,
actionColumn,
selectedKeys,
rowSelection,
enableScrollBar,
tableScroll,
downloadRowFile,
getImgView,
getPcaText,
getFormatDate,
handleColumnResult,
onSelectChange,
hrefComponent,
viewOnlineCellImage,
hrefMainTableId,
registerOnlineHrefModal,
registerPopModal,
openPopModal,
openOnlineHrefModal,
onlinePopModalRef,
popTableId,
handleClickFieldHref,
};
}

View File

@ -0,0 +1,298 @@
<template>
<div class="multi-text-tag-input" :class="{ 'is-focused': isFocused }" @click="focusInput">
<span v-for="(tag, index) in tags" :key="index" class="mtti-tag">
<span class="mtti-tag-text">{{ tag }}</span>
<span class="mtti-tag-close" @click.stop="removeTag(index)">×</span>
</span>
<input
ref="inputRef"
v-model="inputValue"
class="mtti-input"
:placeholder="tags.length === 0 ? placeholder : ''"
@focus="onFocus"
@blur="onBlur"
@keydown.enter.prevent="handleEnter"
@keydown="handleKeydown"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
/>
<div v-if="showDropdown" class="mtti-dropdown">
<div class="mtti-dropdown-item" @mousedown.prevent="handleDropdownClick">
使用"{{ inputValue }}"
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed, nextTick } from 'vue';
export default defineComponent({
name: 'JMultiTextTagInput',
props: {
value: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '输入后按回车添加,逗号分隔多个关键词',
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ['change', 'update:value'],
setup(props, {emit}) {
const inputRef = ref<HTMLInputElement | null>(null);
const inputValue = ref('');
const isFocused = ref(false);
const isComposing = ref(false);
// 将逗号分隔的字符串转为数组
const tags = computed(() => {
if (!props.value || props.value.trim() === '') {
return [];
}
return props.value.split(',').map(s => s.trim()).filter(s => s !== '');
});
const showDropdown = computed(() => {
return isFocused.value && inputValue.value && inputValue.value.trim() !== '';
});
// 发出变更事件
function emitChange(newTags: string[]) {
const val = newTags.filter(s => s.trim() !== '').join(',');
emit('update:value', val);
emit('change', val);
}
// 添加一个或多个标签
function addTags(values: string[]) {
const existing = [...tags.value];
for (const v of values) {
const trimmed = v.trim();
if (trimmed && !existing.includes(trimmed)) {
existing.push(trimmed);
}
}
emitChange(existing);
}
// 添加单个标签
function addTag(value: string) {
addTags([value]);
}
// 移除标签
function removeTag(index: number) {
const newTags = [...tags.value];
newTags.splice(index, 1);
emitChange(newTags);
}
// 处理回车
function handleEnter() {
if (isComposing.value) {
return;
}
const text = inputValue.value.trim();
if (text) {
// 检查是否包含逗号,自动分割
if (text.includes(',')) {
const parts = text.split(',').map(s => s.trim()).filter(s => s !== '');
addTags(parts);
} else {
addTag(text);
}
inputValue.value = '';
}
}
// 处理按键:逗号自动分割(中文逗号和英文逗号)
function handleKeydown(e: KeyboardEvent) {
if (isComposing.value) {
return;
}
// 如果输入了逗号(英文逗号或中文逗号),自动分割
if (e.key === ',' || e.key === ',') {
e.preventDefault();
const text = inputValue.value.trim();
if (text) {
addTag(text);
inputValue.value = '';
}
}
// 退格键:如果输入框为空,删除最后一个标签
if (e.key === 'Backspace' && inputValue.value === '' && tags.value.length > 0) {
removeTag(tags.value.length - 1);
}
}
// 下拉选项点击
function handleDropdownClick() {
const text = inputValue.value.trim();
if (text) {
addTag(text);
inputValue.value = '';
nextTick(() => {
inputRef.value?.focus();
});
}
}
function onFocus() {
isFocused.value = true;
}
function onBlur() {
// 延迟关闭,让 mousedown 有机会触发
setTimeout(() => {
isFocused.value = false;
// 失焦时自动添加当前输入内容
const text = inputValue.value.trim();
if (text) {
if (text.includes(',')) {
const parts = text.split(',').map(s => s.trim()).filter(s => s !== '');
addTags(parts);
} else {
addTag(text);
}
inputValue.value = '';
}
}, 200);
}
function focusInput() {
inputRef.value?.focus();
}
return {
inputRef,
inputValue,
isFocused,
isComposing,
tags,
showDropdown,
handleEnter,
handleKeydown,
handleDropdownClick,
onFocus,
onBlur,
focusInput,
removeTag,
};
},
});
</script>
<style scoped lang="less">
.multi-text-tag-input {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
min-height: 32px;
padding: 2px 8px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
cursor: text;
transition: border-color 0.3s;
gap: 4px;
&:hover {
border-color: #4096ff;
}
&.is-focused {
border-color: #4096ff;
box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
}
}
.mtti-tag {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 6px;
background: #f5f5f5;
border: 1px solid #e8e8e8;
border-radius: 4px;
font-size: 13px;
color: #333;
max-width: 200px;
overflow: hidden;
}
.mtti-tag-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
}
.mtti-tag-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin-left: 4px;
cursor: pointer;
color: #999;
font-size: 12px;
line-height: 1;
border-radius: 50%;
transition: all 0.2s;
&:hover {
color: #fff;
background: #ff4d4f;
}
}
.mtti-input {
flex: 1;
min-width: 60px;
height: 24px;
border: none;
outline: none;
font-size: 13px;
color: #333;
background: transparent;
padding: 0 2px;
&::placeholder {
color: #bfbfbf;
}
}
.mtti-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 6px;
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08);
z-index: 1050;
overflow: hidden;
}
.mtti-dropdown-item {
padding: 6px 12px;
font-size: 13px;
color: #333;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f5f5;
}
}
</style>

View File

@ -2,9 +2,10 @@
<template>
<div class="JPopup components-input-demo-presuffix" v-if="avalid">
<!--输入框-->
<a-input @click="handleOpen" :value="innerShowText || showText" :placeholder="placeholder" readOnly v-bind="attrs">
<a-input @click="handleOpen" :value="isQueryingShowText ? '加载中...' : (innerShowText || showText)" :placeholder="placeholder" readOnly v-bind="attrs">
<template #prefix>
<Icon icon="ant-design:cluster-outlined"></Icon>
<Icon v-if="!isQueryingShowText" icon="ant-design:cluster-outlined"></Icon>
<Icon v-else icon="ant-design:loading-outlined" spin></Icon>
</template>
<!-- <template #suffix>
<Icon icon="ant-design:close-circle-outlined" @click="handleEmpty" title="清空" v-if="showText"></Icon>
@ -34,6 +35,13 @@
import { propTypes } from '/@/utils/propTypes';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { useMessage } from '/@/hooks/web/useMessage';
import { defHttp } from '/@/utils/http/axios';
// 定义请求 url 信息
const configUrl = reactive({
getColumns: '/online/cgreport/api/getRpColumns/',
getData: '/online/cgreport/api/getData/',
});
export default defineComponent({
name: 'JPopup',
@ -71,6 +79,9 @@
const avalid = ref(true);
const showText = ref('');
const innerShowText = ref('')
const loading = ref(false);
const isQueryingShowText = ref(false);
const cgRpConfigId = ref('');
//注册model
const [regModal, { openModal }] = useModal();
//表单值
@ -92,11 +103,101 @@
watch(
() => props.value,
(val) => {
showText.value = val && val.length > 0 ? val.split(props.spliter).join(',') : '';
// 反查期间先不显示 id,等接口返回后再决定显示文本或回退显示 value
if (!isQueryingShowText.value) {
showText.value = val && val.length > 0 ? val.split(props.spliter).join(',') : '';
}
// 筛选场景下组件重建后 innerShowText 会丢失,需要根据 value 反查 online 报表文本回显
if (props.inSearch && val && val.length > 0 && !innerShowText.value) {
queryShowTextByValue(val);
}
},
{ immediate: true }
);
/**
* 根据 value 反查显示文本
*/
function queryShowTextByValue(val) {
if (!props.code || props.fieldConfig.length === 0) {
return;
}
const sourceField = props.fieldConfig[0]['source'];
const labelField = props.fieldConfig[0]['label'];
if (!sourceField || !labelField) {
return;
}
// 反查期间显示 loading,并先清空 showText 避免闪现 id
isQueryingShowText.value = true;
showText.value = '';
const doQuery = () => {
loadDataForShowText(val, sourceField, labelField);
};
if (cgRpConfigId.value) {
doQuery();
} else {
loadColumnsInfo({ callBack: doQuery });
}
}
/**
* 加载列信息获取 cgRpConfigId
*/
function loadColumnsInfo({ callBack }) {
loading.value = true;
let url = `${configUrl.getColumns}${props.code}`;
defHttp
.get({ url }, { isTransformResponse: false, successMessageMode: 'none' })
.then((res) => {
if (res.success) {
cgRpConfigId.value = res.result.cgRpConfigId;
callBack?.();
}
})
.catch((err) => {
console.error('【JPopup】加载报表列信息失败', err);
})
.finally(() => {
loading.value = false;
// 列信息加载失败时恢复显示 value,并关闭 loading
if (!cgRpConfigId.value) {
isQueryingShowText.value = false;
showText.value = props.value && props.value.length > 0 ? props.value.split(props.spliter).join(',') : '';
}
});
}
/**
* 根据 value 加载显示文本
*/
function loadDataForShowText(val, sourceField, labelField) {
loading.value = true;
let url = `${configUrl.getData}${unref(cgRpConfigId)}`;
defHttp
.get(
{ url, params: { ['force_' + sourceField]: val } },
{ isTransformResponse: false, successMessageMode: 'none' }
)
.then((res) => {
let data = res.result;
if (data.records?.length) {
const labels = data.records.map((item) => item[labelField]);
innerShowText.value = labels.join(',');
}
})
.catch((err) => {
console.error('【JPopup】根据 value 反查显示文本失败', err);
})
.finally(() => {
loading.value = false;
// 反查结束关闭 loading;若未查到文本则回退显示 value
isQueryingShowText.value = false;
if (!innerShowText.value) {
showText.value = props.value && props.value.length > 0 ? props.value.split(props.spliter).join(',') : '';
}
});
}
/**
* 打开pop弹出框
*/
@ -155,6 +256,8 @@
innerShowText,
avalid,
uniqGroupId,
loading,
isQueryingShowText,
attrs,
regModal,
handleOpen,

View File

@ -2,7 +2,8 @@
<template>
<div class="JPopupDict components-input-demo-presuffix">
<!--输入框-->
<a-select v-model:value="showText" v-bind="attrs" :mode="multi ? 'multiple' : ''" @click="handleOpen" readOnly :loading="loading">
<a-input v-if="useInput" v-model:value="showLabel" v-bind="attrs" :disabled="disabled" @click="handleOpen" readonly :loading="loading" />
<a-select v-else v-model:value="showText" v-bind="attrs" :disabled="disabled" :mode="multi ? 'multiple' : ''" @click="handleOpen" readOnly :loading="loading" :open="false">
<a-select-option v-for="item in options" :value="item.value">{{ item.text }}</a-select-option>
</a-select>
<a-form-item>
@ -54,21 +55,25 @@
dictCode: propTypes.string.def(''),
value: propTypes.string.def(''),
sorter: propTypes.string.def(''),
disabled: propTypes.bool.def(false),
multi: propTypes.bool.def(false),
param: propTypes.object.def({}),
spliter: propTypes.string.def(','),
getFormValues: propTypes.func,
getContainer: propTypes.func,
showAdvancedButton: propTypes.bool.def(true),
useInput: propTypes.bool.def(false),
},
emits: ['update:value', 'register', 'change'],
emits: ['update:value', 'register', 'change', 'options-change'],
setup(props, { emit }) {
const { createMessage } = useMessage();
const attrs = useAttrs();
const showText = ref<any>(props.multi ? [] : '');
const showLabel = ref<string>('');
const options = ref<any>([]);
const cgRpConfigId = ref('');
const loading = ref(false);
let syncingFromProps = false;
const code = props.dictCode.split(',')[0];
const labelFiled = props.dictCode.split(',')[1];
const valueFiled = props.dictCode.split(',')[2];
@ -84,29 +89,69 @@
*/
function handleOpen() {
// 代码逻辑说明: 【TV360X-317】禁用后JPopup和JPopupdic还可以点击出弹窗
!attrs.value.disabled && openModal(true);
//update-begin---wangshuai---date:20260104 for:[issue/1560]动态列后面无法接其它列,如动态列最后加汇总就有问题------------
//update-end---author:wangshuai ---date:20260104 for:[issue/1560]动态列后面无法接其它列,如动态列最后加汇总就有问题------------
if (props.disabled) {
return;
}
openModal(true);
}
/**
* liaozhiyang
* 20260522
* 【QQYUN-15493】新增a-input模式下
* */
function syncShowLabel() {
if (!props.useInput) return;
const currentValue = showText.value;
if (props.multi) {
const values = Array.isArray(currentValue) ? currentValue : currentValue ? currentValue.split(props.spliter) : [];
showLabel.value = values
.map((v) => {
const opt = options.value.find((o: any) => String(o.value) === String(v));
return opt?.text || v;
})
.join(props.spliter);
} else {
const opt = options.value.find((o: any) => String(o.value) === String(currentValue));
showLabel.value = opt?.text || currentValue || '';
}
}
/**
* 监听value数值
*/
watch(
() => props.value,
(val) => {
() => {
const callBack = () => {
syncingFromProps = true;
// update-begin--author:liusq---date:20260720---for:【LHZP-1003】单表pop字典,设置后,编辑打开,回显数据陷入循环查询的死循环
// 代码逻辑说明: 必须使用实时的props.value而非闭包捕获的val,否则在异步回调时
// defaultValue与记录值不同时,多个callBack会交替覆盖formModel,导致无限刷新
const currentValue = props.value;
if (props.multi) {
showText.value = val && val.length > 0 ? val.split(props.spliter) : [];
showText.value = currentValue && currentValue.length > 0 ? currentValue.split(props.spliter) : [];
} else {
showText.value = val ?? '';
showText.value = currentValue ?? '';
}
// update-end--author:liusq---date:20260720---for:【LHZP-1003】单表pop字典,设置后,编辑打开,回显数据陷入循环查询的死循环
// update-begin--author:liaozhiyang---date:20260714---for:【LHZP-392】pop字典外面删除了,弹窗里面还是勾选
const currentValues = (props.multi ? showText.value : showText.value ? [showText.value] : []).map((v: any) => String(v));
selected.value = (selected.value || []).filter((r: any) => currentValues.includes(String(r[valueFiled])));
// update-end--author:liaozhiyang---date:20260714---for:【LHZP-392】pop字典外面删除了,弹窗里面还是勾选
syncShowLabel();
nextTick(() => {
syncingFromProps = false;
});
};
// 先回显已保存值,接口返回后再用加载到的 options 替换为显示文本,避免编辑弹窗短暂空白
callBack();
if (props.value || props.defaultValue) {
if (cgRpConfigId.value) {
loadData({ callBack });
} else {
loadColumnsInfo({ callBack });
}
} else {
callBack();
}
},
{ immediate: true }
@ -114,6 +159,8 @@
watch(
() => showText.value,
(val) => {
// props 回显属于单向同步,不能再作为用户操作反向清空或覆盖表单值
if (syncingFromProps) return;
let result;
if (props.multi) {
result = val.join(',');
@ -182,18 +229,30 @@
if (props.multi) {
showText.value = dataValue;
result = dataValue.join(props.spliter);
//update-begin author:liaozhiyang date:20260522 for:【QQYUN-15493】新增a-input模式下
if (props.useInput) {
showLabel.value = dataOptions.map((o: any) => o.text).join(props.spliter);
}
//update-end author:liaozhiyang date:20260522 for:【QQYUN-15493】新增a-input模式下
} else {
showText.value = dataValue[0];
result = dataValue[0];
//update-begin author:liaozhiyang date:20260522 for:【QQYUN-15493】新增a-input模式下
if (props.useInput) {
showLabel.value = dataOptions[0]?.text || '';
}
//update-end author:liaozhiyang date:20260522 for:【QQYUN-15493】新增a-input模式下
}
nextTick(() => {
emit('change', result);
emit('update:value', result);
emit('options-change', options.value);
});
}
return {
showText,
showLabel,
attrs,
regModal,
handleOpen,

View File

@ -1,11 +1,17 @@
<template>
<a-range-picker v-model:value="rangeValue" @change="handleChange" :show-time="datetime" :placeholder="placeholder" :valueFormat="valueFormat"/>
<a-range-picker v-model:value="rangeValue" @change="handleChange" :show-time="datetime" :placeholder="placeholder" :picker="picker" :format="format || valueFormat" :valueFormat="valueFormat"/>
</template>
<script>
import { defineComponent, ref, watch, computed } from 'vue';
import { propTypes } from '/@/utils/propTypes';
import { Form } from 'ant-design-vue';
import dayjs from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import quarterOfYear from 'dayjs/plugin/quarterOfYear';
dayjs.extend(isoWeek);
dayjs.extend(quarterOfYear);
const placeholder = ['开始日期', '结束日期']
/**
@ -17,6 +23,9 @@
value: propTypes.string.def(''),
datetime: propTypes.bool.def(false),
placeholder: propTypes.string.def(''),
picker: propTypes.string.def('date'),
format: propTypes.string.def(''),
valueFormat: propTypes.string.def(''),
},
emits:['change', 'update:value'],
setup(props, {emit}){
@ -32,6 +41,9 @@
}, {immediate: true});
const valueFormat = computed(()=>{
if(props.valueFormat){
return props.valueFormat
}
if(props.datetime === true){
return 'YYYY-MM-DD HH:mm:ss'
}else{
@ -43,12 +55,33 @@
let str = ''
if(arr && arr.length>0){
// 代码逻辑说明: [issues/6368] rangeDate去掉判断允许起始项或结束项为空兼容allowEmpty
str = arr.join(',')
// update-begin--author:wangshuai---date:20260819---for:【LHZP-322】【表单设计器】支持周、季度
str = arr.map(normalizePickerValue).join(',')
// update-end--author:wangshuai---date:20260819---for:【LHZP-322】【表单设计器】支持周、季度
}
emit('change', str);
emit('update:value', str);
formItemContext.onFieldChange();
}
/**
* 返回季度 周 格式化之后的代码
* @param value
* @returns {*|string}
*/
function normalizePickerValue(value){
if(!value){
return value
}
if(props.picker === 'quarter'){
return dayjs(value).startOf('quarter').format('YYYY-MM-DD')
}
if(props.picker === 'week'){
return dayjs(value).startOf('isoWeek').format('YYYY-MM-DD')
}
return value
}
return {
rangeValue,
placeholder,

View File

@ -52,9 +52,13 @@
watch(
() => props.value,
(val) => {
if (val && val.length == 2) {
beginValue.value = val[0];
endValue.value = val[1];
let parseVal = val;
if (typeof val === 'string') {
parseVal = val.split(',');
}
if (Array.isArray(parseVal) && parseVal.length === 2) {
beginValue.value = parseVal[0];
endValue.value = parseVal[1];
} else {
beginValue.value = '';
endValue.value = '';

View File

@ -1,7 +1,8 @@
<!-- 字典下拉单选 -->
<!-- 字典下拉单选 / 远程加载下拉 -->
<template>
<a-select
:value="innerValue"
:mode="mode"
:disabled="disabled"
:placeholder="placeholder"
:filter-option="filterOptionComputed"
@ -10,7 +11,7 @@
:notFoundContent="loading ? undefined : null"
allowClear
@change="handleChange"
@search="handleSearch"
:onSearch="showSearch ? handleSearch : undefined"
@popupScroll="handlePopupScroll"
@dropdownVisibleChange="handleDropdownVisibleChange"
v-bind="$attrs"
@ -51,7 +52,6 @@
style: { backgroundColor: string };
}
/** 下拉单选组件的值类型(可被 change / update:value / input 等复用) */
export type SelectSingleValue = string | number | undefined;
const props = withDefaults(
@ -59,6 +59,7 @@
value?: SelectSingleValue;
placeholder?: string;
readOnly?: boolean;
mode?: 'multiple' | 'tags' | 'combobox';
options?: DictOption[];
triggerChange?: boolean;
popContainer?: string;
@ -67,6 +68,12 @@
useDicColor?: boolean;
pageSize?: number;
scrollLoad?: boolean;
showSearch?: boolean;
filterOption?: (input: string, option: any) => boolean;
onSearch?: (keyword: string) => void;
/** 远程加载模式:启用滚动加载+远程搜索,通过 onLoad 回调获取分页数据 */
remoteLoad?: boolean;
onLoad?: (params: { keyword: string; pageNo: number; pageSize: number }) => Promise<DictOption[]>;
}>(),
{
placeholder: '请选择',
@ -78,6 +85,8 @@
useDicColor: false,
pageSize: 10,
scrollLoad: false,
showSearch: false,
remoteLoad: false,
}
);
const emit = defineEmits<{
@ -88,17 +97,19 @@
}>();
// --- 选项数据 ---
const innerValue = ref<SelectSingleValue>(props.value ?? undefined);
const innerValue = ref<SelectSingleValue>();
const dictOptions = ref<DictOption[]>([]);
const loading = ref(false);
const searchKeyword = ref('');
// --- 字典加载(分页滚动) ---
// --- 分页滚动状态 ---
const scrollState = {
pageNo: 1,
hasMore: true,
loading: false,
};
/** 缓存上次滚动的目标元素,关闭下拉时复位其滚动条 */
let lastScrollTarget: HTMLElement | null = null;
const isDictTable = computed(() => {
if (!props.dictCode) return false;
@ -107,6 +118,10 @@
const useLoadDict = computed(() => props.scrollLoad && isDictTable.value);
const useRemoteLoad = computed(() => props.remoteLoad && typeof props.onLoad === 'function');
const showSearch = computed(() => useLoadDict.value || useRemoteLoad.value || props.showSearch);
const selectOptions = computed<SelectOptionItem[]>(() => {
if (!Array.isArray(dictOptions.value)) return [];
return dictOptions.value.map((item, index) => {
@ -121,6 +136,7 @@
});
});
// --- 字典加载 ---
function fetchDictPage(pageNo: number, append: boolean) {
return defHttp
.get({
@ -128,48 +144,66 @@
params: { pageNo, pageSize: props.pageSize, keyword: searchKeyword.value, order: 'asc' },
})
.then((res: any[]) => {
const items: DictOption[] = (res ?? []).map((it) => ({
const items: DictOption[] = (res ?? []).map((it: any) => ({
value: it.value,
label: it.text ?? it.label,
}));
if (items.length === 0) {
if (!append) dictOptions.value = [];
scrollState.hasMore = false;
return;
}
if (append) {
const existValues = new Set(dictOptions.value.map((o) => String(o.value)));
const newItems = items.filter((it) => !existValues.has(String(it.value)));
if (newItems.length > 0) {
dictOptions.value = [...dictOptions.value, ...newItems];
}
} else {
// 有选中的值且optinos中存在时,需要把选中的值在options中存在且不在新数据中的项保留(防止多次请求)
if (unref(innerValue) && unref(dictOptions).length) {
const existItem = unref(dictOptions).find((item) => String(item.value) === String(unref(innerValue)));
if (existItem && !items.some((item) => String(item.value) === String(existItem.value))) {
items.push(existItem);
}
}
dictOptions.value = items;
}
scrollState.pageNo = pageNo + 1;
processPageResult(items, pageNo, append);
});
}
// --- 远程加载 ---
async function fetchRemotePage(pageNo: number, append: boolean) {
if (!props.onLoad) return;
try {
const items = await props.onLoad({
keyword: searchKeyword.value,
pageNo,
pageSize: props.pageSize,
});
processPageResult(items, pageNo, append);
} catch {
dictOptions.value = [];
}
}
function processPageResult(items: DictOption[], pageNo: number, append: boolean) {
if (!items || items.length === 0) {
if (!append) dictOptions.value = [];
scrollState.hasMore = false;
return;
}
if (append) {
const existValues = new Set(dictOptions.value.map((o) => String(o.value)));
const newItems = items.filter((it) => !existValues.has(String(it.value)));
if (newItems.length > 0) {
dictOptions.value = [...dictOptions.value, ...newItems];
}
} else {
if (unref(innerValue) && unref(dictOptions).length && !searchKeyword.value) {
const existItem = unref(dictOptions).find((item) => String(item.value) === String(unref(innerValue)));
if (existItem && !items.some((item) => String(item.value) === String(existItem.value))) {
items.push(existItem);
}
}
dictOptions.value = items;
}
scrollState.pageNo = pageNo + 1;
scrollState.hasMore = items.length >= (props.pageSize || 10);
}
async function loadDictOptions() {
if (useLoadDict.value) {
scrollState.pageNo = 1;
scrollState.hasMore = true;
loading.value = true;
fetchDictPage(1, false)
.catch(() => {
dictOptions.value = [];
})
.finally(() => {
loading.value = false;
ensureValueInOptions();
});
.catch(() => { dictOptions.value = []; })
.finally(() => { loading.value = false; ensureValueInOptions(); });
} else if (useRemoteLoad.value) {
scrollState.pageNo = 1;
scrollState.hasMore = true;
loadRemoteOptions();
} else {
let code = props.dictCode ?? '';
try {
@ -182,21 +216,26 @@
}
}
/** 根据 value 拉取单条字典项用于回显(编辑时当前值不在已加载选项中时) */
async function loadRemoteOptions() {
loading.value = true;
searchKeyword.value = '';
scrollState.pageNo = 1;
scrollState.hasMore = true;
fetchRemotePage(1, false)
.finally(() => { loading.value = false; });
}
function fetchDictItemByValue(val: SelectSingleValue): Promise<DictOption | null> {
if (val == null) return Promise.resolve(null);
return defHttp
.get({ url: `/sys/dict/loadDictItem/${props.dictCode}`, params: { key: val } })
.then((res: any) => {
if (Array.isArray(res)) {
return { value: val, label: res[0] };
}
if (Array.isArray(res)) return { value: val, label: res[0] };
return null;
})
.catch(() => null);
}
/** 确保当前 value 在选项中(仅 useLoadDict 时:编辑时当前值不在已加载列表中则拉取单条并插入) */
function ensureValueInOptions() {
if (!useLoadDict.value) return;
const val = innerValue.value;
@ -209,53 +248,72 @@
}
const handleSearch = debounce((keyword: string) => {
if (!useLoadDict.value) return;
searchKeyword.value = keyword ?? '';
scrollState.pageNo = 1;
scrollState.hasMore = true;
loading.value = true;
fetchDictPage(1, false)
.catch(() => {
dictOptions.value = [];
})
.finally(() => {
loading.value = false;
ensureValueInOptions();
});
if (useLoadDict.value) {
searchKeyword.value = keyword ?? '';
scrollState.pageNo = 1;
scrollState.hasMore = true;
loading.value = true;
fetchDictPage(1, false)
.catch(() => { dictOptions.value = []; })
.finally(() => { loading.value = false; ensureValueInOptions(); });
} else if (useRemoteLoad.value) {
searchKeyword.value = keyword ?? '';
scrollState.pageNo = 1;
scrollState.hasMore = true;
loading.value = true;
fetchRemotePage(1, false)
.finally(() => { loading.value = false; });
} else if (props.onSearch) {
props.onSearch(keyword ?? '');
}
}, 300);
function handleDropdownVisibleChange(open: boolean) {
if (!open || !useLoadDict.value) return;
scrollState.pageNo = 1;
scrollState.hasMore = true;
searchKeyword.value = '';
loading.value = true;
fetchDictPage(1, false)
.finally(() => {
loading.value = false;
ensureValueInOptions();
});
if (!open) {
// 关闭时:复位缓存的滚动元素,下次打开从顶部开始
if (lastScrollTarget) {
lastScrollTarget.scrollTop = 0;
lastScrollTarget = null;
}
return;
}
// 打开时:清空缓存
lastScrollTarget = null;
if (useLoadDict.value) {
scrollState.pageNo = 1;
scrollState.hasMore = true;
searchKeyword.value = '';
loading.value = true;
fetchDictPage(1, false)
.finally(() => { loading.value = false; ensureValueInOptions(); });
} else if (useRemoteLoad.value) {
loadRemoteOptions();
}
}
function handlePopupScroll(e: Event) {
if (!useLoadDict.value || scrollState.loading || !scrollState.hasMore) return;
const target = e.target as HTMLElement;
const { scrollTop, scrollHeight, clientHeight } = target;
if (scrollTop + clientHeight < scrollHeight - 10) return;
scrollState.loading = true;
fetchDictPage(scrollState.pageNo, true)
.finally(() => {
scrollState.loading = false;
})
.catch(() => {
if (scrollState.pageNo > 1) {
scrollState.pageNo--;
}
});
lastScrollTarget = e.target as HTMLElement;
if (useLoadDict.value) {
if (scrollState.loading || !scrollState.hasMore) return;
const target = e.target as HTMLElement;
const { scrollTop, scrollHeight, clientHeight } = target;
if (scrollTop + clientHeight < scrollHeight - 10) return;
scrollState.loading = true;
fetchDictPage(scrollState.pageNo, true)
.finally(() => { scrollState.loading = false; })
.catch(() => { if (scrollState.pageNo > 1) scrollState.pageNo--; });
} else if (useRemoteLoad.value) {
if (scrollState.loading || !scrollState.hasMore) return;
const target = e.target as HTMLElement;
const { scrollTop, scrollHeight, clientHeight } = target;
if (scrollTop + clientHeight < scrollHeight - 10) return;
scrollState.loading = true;
fetchRemotePage(scrollState.pageNo, true)
.finally(() => { scrollState.loading = false; });
}
}
// --- 弹层挂载 & 搜索过滤 ---
const showSearch = computed(() => useLoadDict.value);
function getPopupContainer(node: HTMLElement) {
return props.popContainer ? setPopContainer(node, props.popContainer) : node?.parentNode;
}
@ -266,8 +324,8 @@
return text.indexOf(input.toLowerCase()) >= 0;
}
const filterOptionComputed = computed(() => (input: string, option: any) => {
if (useLoadDict.value) return true;
return filterOption(input, option);
if (useLoadDict.value || useRemoteLoad.value) return true;
return props.filterOption?.(input, option) ?? filterOption(input, option);
});
// --- 选择变更 ---
@ -283,7 +341,7 @@
// --- 初始化与监听 ---
function syncOptionsFromProps() {
if (props.dictCode) {
if (props.dictCode || props.remoteLoad) {
loadDictOptions();
} else {
dictOptions.value = props.options ?? [];
@ -294,15 +352,23 @@
watch(
() => props.value,
(val) => {
innerValue.value = val ?? undefined;
let parsedVal = val ?? undefined;
let isMulti = props.mode === 'multiple' || props.mode === 'tags';
if (isMulti && typeof parsedVal === 'string') {
parsedVal = parsedVal.split(',').map((v) => v.trim()).filter((v) => v !== '');
}
innerValue.value = parsedVal;
if (useLoadDict.value) ensureValueInOptions();
},
{
immediate: true,
}
);
watch(
() => props.options,
() => {
if (!props.dictCode) dictOptions.value = props.options ?? [];
if (!props.dictCode && !props.remoteLoad) dictOptions.value = props.options ?? [];
}
);

View File

@ -18,7 +18,7 @@
import { unref } from 'vue';
import UserSelectModal from './modal/UserSelectModal.vue';
import JSelectBiz from './base/JSelectBiz.vue';
import { defineComponent, ref, reactive, watchEffect, watch, provide } from 'vue';
import { defineComponent, ref, reactive, watchEffect, watch, provide, computed } from 'vue';
import { useModal } from '/@/components/Modal';
import { propTypes } from '/@/utils/propTypes';
import { useRuleFormItem } from '/@/hooks/component/useFormItem';
@ -147,7 +147,10 @@
selectValues.value = values;
send(values);
}
const getBindValue = Object.assign({}, unref(props), unref(attrs));
// 【修复】原写法是 setup 期一次性 Object.assign 快照,
// 父组件 updateSchema 改 props 后 v-bind="getBindValue" 不会重新求值
// 改为 computed 后,props/attrs 任一变化都会重新合并后透传给 UserSelectModal
const getBindValue = computed(() => Object.assign({}, unref(props), unref(attrs)));
// 代码逻辑说明: 【QQYUN-9366】用户选择组件取消和关闭会把选择数据带入
const handleClose = () => {
if (tempSave.length) {

View File

@ -13,7 +13,6 @@
:multiple="multiple"
v-bind="attrs"
@change="onChange"
@search="onSearch"
:tree-checkable="treeCheckAble"
:tagRender="tagRender"
>
@ -31,7 +30,6 @@
import { defHttp } from '/@/utils/http/axios';
import { propTypes } from '/@/utils/propTypes';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { TreeSelect } from 'ant-design-vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { isObject, isArray } from '/@/utils/is';
import { useI18n } from '/@/hooks/web/useI18n';
@ -71,7 +69,7 @@
//树形下拉数据
const treeData = ref<any[]>([]);
//选择数据
const treeValue = ref<any>(null);
const treeValue = ref<any>();
const tableName = ref<any>('');
const text = ref<any>('');
const code = ref<any>('');
@ -131,7 +129,7 @@
if(props.multiple){
treeValue.value = [];
}else{
treeValue.value = { label: null, value: null };
treeValue.value = undefined;
}
} else {
// 代码逻辑说明: issues/4173 Online JTreeSelect控件changeOptions方法未生效
@ -224,7 +222,7 @@
* @param text
*/
function translateTitle(text) {
if (text.includes("t('") && t) {
if (text?.includes("t('") && t) {
return new Function('t', `return ${text}`)(t);
}
return text;
@ -319,13 +317,6 @@
emit('update:value', value);
}
/**
* 文本框值变化
*/
function onSearch(value) {
console.log(value);
}
/**
* 校验条件配置是否有误
*/
@ -352,7 +343,7 @@
}
// 代码逻辑说明: issues/4173 Online JTreeSelect控件changeOptions方法未生效
watch(()=>props.url, async (val)=>{
watch(()=> [props.url, props.params], async (val)=>{
if(val){
await loadRootByUrl();
}
@ -367,8 +358,8 @@
let res = await defHttp.get({ url, params }, { isTransformResponse: false });
if (res.success && res.result) {
for (let i of res.result) {
i.title = translateTitle(i.title);
i.key = i.value;
i.title = translateTitle(i.title ?? i.name);
i.key = i.value ?? i.id;
i.isLeaf = !!i.leaf;
}
// 代码逻辑说明: 【TV360X-87】树表编辑时不可选自己及子孙节点当父节点

View File

@ -11,6 +11,22 @@
@change="onFileChange"
@preview="onFilePreview"
>
<template #removeIcon="{ file }">
<a-popconfirm
v-if="removeConfirm"
:title="`删除这${isImageMode ? '张图片' : '个文件'}?`"
placement="topRight"
overlayClassName="j-upload-remove-popconfirm"
okText="删除"
cancelText="取消"
@confirm="confirmRemove(file)"
>
<span class="j-upload-remove-trigger" :data-file-uid="file.uid" @click.stop>
<Icon icon="ant-design:delete-outlined" />
</span>
</a-popconfirm>
<Icon v-else icon="ant-design:delete-outlined" />
</template>
<template v-if="isImageMode">
<div v-if="!isMaxCount">
<Icon icon="ant-design:plus-outlined" />
@ -26,9 +42,8 @@
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch, nextTick, createApp,unref } from 'vue';
import { ref, computed, watch, nextTick, createApp,unref } from 'vue';
import { Icon } from '/@/components/Icon';
import { getToken } from '/@/utils/auth';
import { uploadUrl } from '/@/api/common/api';
import { propTypes } from '/@/utils/propTypes';
import { useMessage } from '/@/hooks/web/useMessage';
@ -40,7 +55,7 @@
import UploadItemActions from './components/UploadItemActions.vue';
import { split } from '/@/utils/index';
const { createMessage, createConfirm } = useMessage();
const { createMessage } = useMessage();
const { prefixCls } = useDesign('j-upload');
const attrs = useAttrs();
const emit = defineEmits(['change', 'update:value']);
@ -76,7 +91,8 @@
const fileList = ref<any[]>([]);
const uploadGoOn = ref<boolean>(true);
// refs
const containerRef = ref();
const containerRef = ref<HTMLElement>();
const confirmedRemoveUid = ref<string | number>();
// 是否达到了最大上传数量
const isMaxCount = computed(() => props.maxCount > 0 && fileList.value.length >= props.maxCount);
// 当前是否是上传图片模式
@ -252,19 +268,26 @@
}
// 删除处理事件
function onRemove() {
if (props.removeConfirm) {
return new Promise((resolve) => {
createConfirm({
title: '删除',
content: `确定要删除这${isImageMode.value ? '张图片' : '个文件'}吗?`,
iconType: 'warning',
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
function onRemove(file) {
if (!props.removeConfirm) {
return true;
}
return true;
if (confirmedRemoveUid.value === file.uid) {
confirmedRemoveUid.value = undefined;
return true;
}
nextTick(() => getRemoveTrigger(file)?.click());
return false;
}
function confirmRemove(file) {
confirmedRemoveUid.value = file.uid;
getRemoveTrigger(file)?.closest('button')?.click();
}
function getRemoveTrigger(file): HTMLElement | undefined {
const triggers = containerRef.value?.querySelectorAll<HTMLElement>('.j-upload-remove-trigger') || [];
return Array.from(triggers).find((trigger) => trigger.dataset.fileUid === String(file.uid));
}
// upload组件change事件
@ -402,6 +425,29 @@
}
}
.ant-upload-list-item-actions {
display: block !important;
a,
a.anticon,
.anticon,
.anticon-eye,
.anticon-download,
.upload-action-icon,
.app-iconify {
pointer-events: auto !important;
cursor: pointer !important;
color: #fff !important;
opacity: 1 !important;
}
svg,
svg path {
color: #fff !important;
fill: #fff !important;
}
}
/* update-begin-author:taoyan date:2022-5-24 for:VUEN-1093详情界面 图片下载按钮显示不全*/
.upload-download-handler {
right: 6px !important;
@ -458,4 +504,28 @@
}
}
}
.j-upload-remove-popconfirm {
.ant-popover-inner {
min-width: 148px;
padding: 8px 10px;
}
.ant-popconfirm-message-icon {
display: none;
}
.ant-popconfirm-message-title {
margin-inline-start: 0;
}
.ant-popconfirm-buttons {
margin-top: 6px;
.ant-btn {
padding-inline: 6px;
box-shadow: none;
}
}
}
</style>

View File

@ -1,20 +1,20 @@
<template>
<BasicModal @register="registerModal" :title="modalTitle" :width="width" @ok="handleOk" v-bind="$attrs">
<JUpload ref="uploadRef" :value="value" v-bind="uploadBinds.props" @change="emitValue" />
<BasicModal @register="registerModal" :title="modalTitle" :width="width" :minHeight="160" @ok="handleOk" v-bind="$attrs">
<div class="j-upload-modal-content">
<JUpload ref="uploadRef" :value="value" v-bind="uploadBinds.props" @change="emitValue" />
</div>
</BasicModal>
</template>
<script lang="ts" setup>
import { ref, unref, reactive, computed, nextTick } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import JUpload from './JUpload.vue';
import { UploadTypeEnum } from './upload.data';
import { propTypes } from '/@/utils/propTypes';
const { createMessage } = useMessage();
const emit = defineEmits(['change', 'update:value', 'register']);
const props = defineProps({
defineProps({
value: propTypes.oneOfType([propTypes.string, propTypes.array]),
width: propTypes.number.def(520),
});
@ -43,3 +43,54 @@
emit('update:value', value);
}
</script>
<style lang="less">
.j-upload-modal-content {
.ant-upload-list-text {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
.ant-upload-list-item-container {
margin: 0;
}
.ant-upload-list-item {
min-height: 38px;
margin: 0;
padding: 6px 10px;
border: 1px solid #f0f0f0;
border-radius: 6px;
background-color: #fafafa;
transition:
border-color 0.2s,
background-color 0.2s;
&:hover {
border-color: fade(@primary-color, 25%);
background-color: fade(@primary-color, 4%);
}
}
.ant-upload-list-item-name {
min-width: 0;
overflow: hidden;
color: rgba(0, 0, 0, 0.65);
text-overflow: ellipsis;
white-space: nowrap;
}
.ant-upload-text-icon {
color: rgba(0, 0, 0, 0.45);
}
.ant-upload-list-item:hover {
.ant-upload-list-item-name,
.ant-upload-text-icon {
color: @primary-color;
}
}
}
}
</style>

View File

@ -23,16 +23,24 @@
:maxTagCount="maxTagCount"
@change="handleChange"
style="width: 100%"
@click="!disabled && openModal(false)"
@click="handleSelectClick"
v-bind="attrs"
:class="{ 'j-select-biz-wrap': isMultipleMode, 'j-select-biz-has-omitted-tags': hasOmittedTags }"
>
<template v-if="isCustomRenderTag" #tagRender="{ label, value, option}">
<a-tag class="ant-select-selection-item" style="margin-right: 4px">
<span class="ant-select-selection-item-content" style="font-size: 14px;max-width: 300px" :title="tagRender(label, value, false)">{{ tagRender(label, value, true) }}</span>
<span class="ant-select-selection-item-remove">
<!-- update-begin--author:liaozhiyang---date:20260804---for:【LHZP-602】岗位层级过深时右侧文字完整、左侧省略;多选换行展开 -->
<a-tag class="ant-select-selection-item j-select-biz-tag" style="margin-right: 4px">
<JLeftEllipsis
content-class="ant-select-selection-item-content j-select-biz-tag-content"
:text="getTagFullText(label, value)"
container-selector=".ant-select-selector"
:reserve-width="tagReserveWidth"
/>
<span v-if="!disabled" class="ant-select-selection-item-remove">
<Icon icon="ant-design:close-outlined" size="12" @click="handleRemoveClick(value)"></Icon>
</span>
</a-tag>
<!-- update-end--author:liaozhiyang---date:20260804---for:【LHZP-602】岗位层级过深时右侧文字完整、左侧省略;多选换行展开 -->
</template>
</a-select>
</a-col>
@ -48,15 +56,16 @@
</div>
</template>
<script lang="ts">
import { defineComponent, ref, inject, reactive, watch } from 'vue';
import { computed, defineComponent, ref, inject, watch } from 'vue';
import { propTypes } from '/@/utils/propTypes';
import { useAttrs } from '/@/hooks/core/useAttrs';
import { LoadingOutlined } from '@ant-design/icons-vue';
import { getDepartPathNameByOrgCode } from "@/utils/common/compUtils";
import { getDepartPathNameByOrgCode } from '@/utils/common/compUtils';
import JLeftEllipsis from '../JLeftEllipsis.vue';
export default defineComponent({
name: 'JSelectBiz',
components: { LoadingOutlined },
components: { LoadingOutlined, JLeftEllipsis },
inheritAttrs: false,
props: {
showButton: propTypes.bool.def(true),
@ -94,7 +103,18 @@
//存放部门名称
const departNamePath = ref<Record<string, string>>({});
const isMultipleMode = computed(() => props.multiple === 'multiple' || props.multiple === 'tags');
// update-begin--author:liaozhiyang---date:20260811---for:【LHZP-513】online一对多部门的渲染样式有重叠
const hasOmittedTags = computed(
() => typeof props.maxTagCount === 'number' && Array.isArray(selectValues.value) && selectValues.value.length > props.maxTagCount
);
const tagReserveWidth = computed(() => {
const baseWidth = props.disabled ? 24 : 44;
return hasOmittedTags.value ? baseWidth + 40 : baseWidth;
});
// update-end--author:liaozhiyang---date:20260811---for:【LHZP-513】online一对多部门的渲染样式有重叠
/**
* 打开弹出框
*/
@ -117,49 +137,84 @@
}
/**
* 多选tag自定义渲染
* 多选tag自定义渲染(完整路径,省略交给 JLeftEllipsis)
*
* @param label
* @param value
* @param isEllipsis 是否省略
* @param _isEllipsis 兼容旧调用
*/
function tagRender(label, value, isEllipsis) {
if (departNamePath.value[value]) {
//是否需要省略显示
if(!isEllipsis){
return departNamePath.value[value];
} else {
let departName = departNamePath.value[value];
//超过20则截取后20位的字符,前面加省略号
if(departName && departName.length >= 20){
const name:any = departName.substring(departName.length - 20);
return '...' + name;
} else {
return departName;
}
}
function tagRender(label, value, _isEllipsis) {
return getTagFullText(label, value);
}
// update-begin--author:liaozhiyang---date:20260804---for:【LHZP-602】岗位层级过深时右侧文字完整、左侧省略;多选换行展开
// 岗位层级过深时右侧文字完整、左侧省略
function toDisplayText(val) {
if (val == null || val === '') {
return '';
}
//判断rowKey是否为orgCode
if(props?.rowKey && props?.rowKey === 'orgCode'){
getDepartPathNameByOrgCode(value, label, '').then((data) => {
departNamePath.value[value] = data;
if (typeof val === 'string') {
return val;
}
if (typeof val === 'number' || typeof val === 'boolean') {
return String(val);
}
if (Array.isArray(val)) {
return val.map((item) => toDisplayText(item)).filter(Boolean).join('');
}
if (typeof val === 'object' && typeof val.children === 'string') {
return val.children;
}
return '';
}
/** 获取完整部门/岗位路径(异步回填后响应式更新) */
function getTagFullText(label, value) {
if (departNamePath.value[value]) {
return toDisplayText(departNamePath.value[value]);
}
const fallback = toDisplayText(label);
if (props?.rowKey && props?.rowKey === 'orgCode') {
getDepartPathNameByOrgCode(value, fallback, '').then((data) => {
departNamePath.value[value] = toDisplayText(data) || fallback;
});
} else {
//否则按照id处理
getDepartPathNameByOrgCode('', label, value).then((data) => {
departNamePath.value[value] = data;
getDepartPathNameByOrgCode('', fallback, value).then((data) => {
departNamePath.value[value] = toDisplayText(data) || fallback;
});
}
return fallback;
}
// update-end--author:liaozhiyang---date:20260804---for:【LHZP-602】岗位层级过深时右侧文字完整、左侧省略;多选换行展开
/**
* 点击选择框时打开弹窗
* 排除点击清除按钮的情况,避免清除时误触发弹窗
*/
function handleSelectClick(event) {
if (props.disabled) {
return;
}
// 点击清除按钮(内置clear或tag移除按钮)时不打开弹窗
if (event.target.closest('.ant-select-selection-item-remove') || event.target.closest('.ant-select-clear')) {
return;
}
openModal(false);
}
/**
* tag删除
*
*
* @param value
*/
function handleRemoveClick(value) {
if(selectValues?.value){
let values = selectValues?.value.filter(item => item !== value);
// update-begin--author:liaozhiyang---date:20260714---for:【LHZP-583】部门组件设置为只读之后还可以删除
if (props.disabled) {
return;
}
// update-end--author:liaozhiyang---date:20260714---for:【LHZP-583】部门组件设置为只读之后还可以删除
if (selectValues?.value) {
let values = selectValues?.value.filter((item) => item !== value);
handleChange(values);
}
}
@ -186,7 +241,12 @@
openModal,
detailStr,
tagRender,
getTagFullText,
handleRemoveClick,
handleSelectClick,
isMultipleMode,
hasOmittedTags,
tagReserveWidth,
};
},
});
@ -194,22 +254,95 @@
<style lang="less" scoped>
.j-select-row {
@width: 82px;
flex-wrap: nowrap;
align-items: flex-start;
.left {
// update-begin--author:liaozhiyang---date:20260804---for:【LHZP-602】岗位层级过深时右侧文字完整、左侧省略;多选换行展开
flex: 1 1 auto;
width: calc(100% - @width - 8px);
max-width: calc(100% - @width - 8px);
min-width: 0;
// update-end--author:liaozhiyang---date:20260804---for:【LHZP-602】岗位层级过深时右侧文字完整、左侧省略;多选换行展开
}
.right {
flex: 0 0 @width;
width: @width;
}
.full {
width: 100%;
max-width: 100%;
}
:deep(.ant-select-search__field) {
display: none !important;
}
// update-begin--author:liaozhiyang---date:20260804---for:【LHZP-602】岗位层级过深时右侧文字完整、左侧省略;多选换行展开
:deep(.ant-select) {
max-width: 100%;
}
:deep(.ant-select-selector) {
max-width: 100%;
height: auto !important;
min-height: 32px;
overflow: hidden;
}
/* 多选:tag 换行展开,不挤在一行 */
:deep(.j-select-biz-wrap .ant-select-selection-overflow) {
flex-wrap: wrap;
overflow: visible;
max-width: 100%;
}
:deep(.ant-select-selection-overflow-item) {
max-width: 100%;
min-width: 0;
}
// update-begin--author:liaozhiyang---date:20260811---for:【LHZP-513】online一对多部门的渲染样式有重叠
:deep(.j-select-biz-has-omitted-tags .ant-select-selection-overflow) {
flex-wrap: nowrap;
}
:deep(.j-select-biz-has-omitted-tags .ant-select-selection-overflow-item) {
max-width: calc(100% - 48px);
}
:deep(.j-select-biz-has-omitted-tags .ant-select-selection-overflow-item-rest),
:deep(.j-select-biz-has-omitted-tags .ant-select-selection-overflow-item-suffix) {
max-width: none;
}
// update-end--author:liaozhiyang---date:20260811---for:【LHZP-513】online一对多部门的渲染样式有重叠
:deep(.j-select-biz-tag) {
display: inline-flex !important;
align-items: center;
max-width: 100%;
margin-inline-end: 4px;
margin-bottom: 2px;
overflow: hidden;
box-sizing: border-box;
}
:deep(.j-select-biz-tag-content) {
flex: 1 1 0 !important;
min-width: 0 !important;
display: block !important;
font-size: 14px;
overflow: hidden;
white-space: nowrap !important;
text-overflow: clip !important;
}
:deep(.j-select-biz-tag .ant-select-selection-item-remove) {
flex-shrink: 0;
}
// update-end--author:liaozhiyang---date:20260804---for:【LHZP-602】岗位层级过深时右侧文字完整、左侧省略;多选换行展开
}
.detailStr {
margin: 0;

View File

@ -282,7 +282,7 @@
// 代码逻辑说明: 【issues/3656】popupdict回显
watchEffect(() => {
if (props.selected && props.rowkey && unref(visible)) {
const selected = props.multi ? props.selected : [props.selected];
const selected = Array.isArray(props.selected) ? props.selected : [props.selected];
checkedKeys!.value = selected.map((item) => item[props.rowkey]);
selectRows!.value = selected;
}

View File

@ -313,9 +313,17 @@
breadcrumb.value = [];
}
};
// 同步当前部门下所有子部门的勾选状态
const setDepartChildrenChecked = (depart, checked: boolean) => {
depart.children?.forEach((child) => {
child.checked = checked;
setDepartChildrenChecked(child, checked);
});
};
// 点击部门树复选框触发
const handleDepartTreeCheck = (e, item) => {
const { target } = e;
setDepartChildrenChecked(item, target.checked);
if (target.checked) {
// 选中
getUsersByDeptId(item['id']).then((users) => {
@ -678,25 +686,39 @@
ol {
flex-wrap: nowrap;
overflow-x: auto;
/* 美化滚动条 */
/* 预留滚动条空间,避免 hover 变粗时布局跳动 */
padding-bottom: 6px;
/* 美化滚动条:默认细,悬停变粗方便拖动 */
// update-begin--author:liaozhiyang---date:20260804---for:【LHZP-869】部门组件面包屑有滚动条时悬停加粗便于拖动
&::-webkit-scrollbar {
width: 4px;
height: 3px;
background: #f5f5f5;
border-radius: 2px;
height: 4px;
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #bfbfbf;
border-radius: 3px;
border-radius: 4px;
transition: background-color 0.2s ease;
&:hover {
background: #999;
}
}
&::-webkit-scrollbar-track {
background: #f5f5f5;
border-radius: 3px;
background: transparent;
border-radius: 4px;
}
&:hover {
&::-webkit-scrollbar {
height: 10px;
}
&::-webkit-scrollbar-thumb {
background: #999;
}
&::-webkit-scrollbar-track {
background: #f0f0f0;
}
}
// update-end--author:liaozhiyang---date:20260804---for:【LHZP-869】部门组件面包屑有滚动条时悬停加粗便于拖动
}
li {
flex: none;

View File

@ -83,6 +83,7 @@
component: 'Input',
},
],
autoSubmitOnEnter: true,
};
//定义表格列
const columns = [

View File

@ -114,6 +114,7 @@
size: 'small',
formConfig: {
//labelWidth: 200,
autoSubmitOnEnter: true,
baseColProps: {
xs: 24,
sm: 8,

View File

@ -134,14 +134,23 @@
size: 'small',
};
const getBindValue = Object.assign({}, unref(props), unref(attrs), config);
const [{ rowSelection, visibleChange, selectValues, indexColumnProps, getSelectResult, handleDeleteSelected, selectRows }] = useSelectBiz(
const [{ rowSelection, visibleChange, selectValues, indexColumnProps, getSelectResult, handleDeleteSelected, selectRows, checkedKeys }] = useSelectBiz(
getUserList,
getBindValue,
emit
);
const searchInfo = ref(props.params);
// 【修复】params 是 props 一次性快照场景改为 watch 跟随 props.params 同步,
// 避免父组件切换部门后 searchInfo 仍指向首次捕获的旧引用
const searchInfo = ref<Recordable>(props.params ?? {});
watch(
() => props.params,
(newParams) => {
searchInfo.value = newParams ?? {};
},
{ deep: true, immediate: true }
);
// 代码逻辑说明: 【issues/657】右侧选中列表删除无效
watch(rowSelection.selectedRowKeys, (newVal) => {
watch(checkedKeys, (newVal) => {
// 代码逻辑说明: null指针异常导致控制台报错页面不显示------------
if(tableRef.value){
tableRef.value.setSelectedRowKeys(newVal);

View File

@ -1,44 +1,56 @@
<template>
<BasicModal
@register="register"
:getContainer="getContainer"
:canFullscreen="false"
:title="title"
:width="500"
destroyOnClose
@ok="handleOk"
wrapClassName="j-user-select-modal2" >
<BasicModal
@register="register"
:getContainer="getContainer"
:canFullscreen="false"
:title="title"
width="min(500px, 92vw)"
destroyOnClose
@ok="handleOk"
wrapClassName="j-user-select-modal2"
>
<!--update-begin---author:wangshuai---date:20260807---for:【LHZP-141】【表单设计器】筛选 角色选择的弹框效果不太好-->
<div class="role-modal-body">
<!-- 搜索框 -->
<div class="role-search-bar">
<a-input v-model:value="searchText" class="role-search" allowClear placeholder="搜索角色名称">
<template #prefix>
<SearchOutlined class="search-icon" />
</template>
</a-input>
<span class="selected-count">已选 {{ selectedList.length }}</span>
</div>
<div style="position: relative; min-height: 350px">
<div style="width: 100%">
<a-input v-model:value="searchText" allowClear style="width: 100%" placeholder="搜索">
<template #prefix>
<SearchOutlined style="color: #c0c0c0" />
</template>
</a-input>
</div>
<div class="list-caption">
<span>角色列表</span>
<span>共 {{ showDataList.length }} 项</span>
</div>
<!-- tabs -->
<div class="modal-select-list-container">
<div class="scroll">
<div class="content" style="right: -10px">
<label class="item" v-for="item in showDataList" @click="(e)=>onSelect(e, item)">
<a-checkbox v-model:checked="item.checked">
<span class="text">{{ item.name }}</span>
</a-checkbox>
</label>
</div>
</div>
</div>
<!-- 角色列表 -->
<div class="modal-select-list-container">
<template v-if="showDataList.length > 0">
<label class="item" :class="{ checked: item.checked }" v-for="item in showDataList" :key="item.id" @click="(e) => onSelect(e, item)">
<span class="role-mark">{{ item.name?.slice(0, 1) || '角' }}</span>
<span class="role-info">
<span class="role-name">{{ item.name }}</span>
<span v-if="item.code" class="role-code">{{ item.code }}</span>
</span>
<a-checkbox v-model:checked="item.checked" class="role-checkbox" />
</label>
</template>
<a-empty v-else description="暂无角色" :image-style="{ height: '60px' }" style="margin-top: 70px" />
</div>
<!-- 选中用户 -->
<div class="selected-users" style="width: 100%; overflow-x: hidden">
<SelectedUserItem v-for="item in selectedList" :info="item" @unSelect="unSelect" />
</div>
<!-- 已选角色 -->
<div v-if="selectedList.length > 0" class="selected-users-wrapper">
<div class="selected-title">已选角色</div>
<div class="selected-users">
<SelectedUserItem v-for="item in selectedList" :key="item.id" :info="item" @unSelect="unSelect" />
</div>
</BasicModal>
</div>
</div>
<!--update-end---author:wangshuai---date:20260807---for:【LHZP-141】【表单设计器】筛选 角色选择的弹框效果不太好-->
</BasicModal>
</template>
<script lang="ts">
@ -104,6 +116,9 @@
if(!list || list.length ==0 ){
}else{
let selectedIdList = data.list || [];
//update-begin---author:wangshuai---date:20260807---for:【LHZP-141】【表单设计器】筛选 角色选择的弹框效果不太好
selectedKeys.value = [...selectedIdList];
//update-end---author:wangshuai---date:20260807---for:【LHZP-141】【表单设计器】筛选 角色选择的弹框效果不太好
for(let item of list){
if(selectedIdList.indexOf(item.id)>=0){
item.checked = true;
@ -236,83 +251,223 @@
};
</script>
<style scoped lang="less">
.modal-select-list-container{
height: 352px;
margin-top: 12px;
overflow: auto;
.scroll{
height: 100%;
position: relative;
width: 100%;
overflow: hidden;
.content{
bottom: 0;
left: 0;
overflow: scroll;
overflow-x: hidden;
position: absolute;
right: 0;
top: 0;
.item{
padding: 7px 5px;
cursor: pointer;
display: block;
&:hover{
background-color: #f5f5f5;
}
}
}
//update-begin---author:wangshuai---date:20260807---for:【LHZP-141】【表单设计器】筛选 角色选择的弹框效果不太好
.role-modal-body {
position: relative;
}
.role-search-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: linear-gradient(135deg, fade(@primary-color, 8%), fade(@primary-color, 2%));
border: 1px solid fade(@primary-color, 12%);
border-radius: 10px;
}
.role-search {
flex: 1;
height: 36px;
border-radius: 7px;
background-color: #fff;
border-color: #e5e7eb;
box-shadow: none;
transition:
border-color 0.2s,
box-shadow 0.2s;
&:hover {
border-color: fade(@primary-color, 55%);
}
&:focus,
&.ant-input-affix-wrapper-focused {
background-color: #fff;
border-color: @primary-color;
box-shadow: 0 0 0 2px fade(@primary-color, 12%);
}
:deep(.ant-input) {
background-color: transparent;
}
}
.search-icon {
color: #9ca3af;
}
.selected-count {
flex: none;
min-width: 58px;
color: @primary-color;
font-size: 12px;
font-weight: 500;
text-align: right;
white-space: nowrap;
}
.list-caption {
display: flex;
align-items: center;
justify-content: space-between;
margin: 14px 2px 8px;
color: #1f2937;
font-size: 13px;
font-weight: 500;
span:last-child {
color: #9ca3af;
font-size: 12px;
font-weight: 400;
}
}
.modal-select-list-container {
height: 360px;
overflow-y: auto;
border: 1px solid #e8eaed;
border-radius: 10px;
padding: 6px;
scrollbar-width: thin;
scrollbar-color: #d8dce3 transparent;
.item {
position: relative;
display: flex;
align-items: center;
min-height: 54px;
padding: 7px 12px 7px 10px;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
transition:
background-color 0.2s,
border-color 0.2s;
& + .item {
margin-top: 4px;
}
&:hover {
background-color: #f7f9fc;
border-color: #edf0f4;
}
&.checked {
background-color: fade(@primary-color, 7%);
border-color: fade(@primary-color, 22%);
&::before {
position: absolute;
top: 12px;
bottom: 12px;
left: -1px;
width: 3px;
background: @primary-color;
border-radius: 0 3px 3px 0;
content: '';
}
.role-mark {
color: #fff;
background: @primary-color;
}
}
}
}
.role-mark {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
margin-right: 11px;
color: @primary-color;
font-size: 14px;
font-weight: 600;
background: fade(@primary-color, 10%);
border-radius: 9px;
transition:
color 0.2s,
background-color 0.2s;
}
.role-info {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
}
.role-name,
.role-code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.role-name {
color: #20242a;
font-size: 14px;
line-height: 20px;
}
.role-code {
margin-top: 1px;
color: #9aa1ab;
font-size: 11px;
line-height: 16px;
}
.role-checkbox {
flex: none;
margin-left: 12px;
}
.selected-users-wrapper {
margin-top: 12px;
padding: 10px 12px 8px;
background: #f8f9fb;
border: 1px solid #eef0f3;
border-radius: 8px;
.selected-title {
margin-bottom: 6px;
color: #6b7280;
font-size: 12px;
}
.selected-users {
display: flex;
flex-wrap: wrap;
flex-direction: row;
align-items: center;
gap: 4px;
padding-top: 0;
// 弹窗内压缩已选标签行高(默认 30px 偏高)
:deep(.user-selected-item) {
height: 26px;
line-height: 26px;
margin-bottom: 2px;
}
}
}
//update-end---author:wangshuai---date:20260807---for:【LHZP-141】【表单设计器】筛选 角色选择的弹框效果不太好
</style>
<style lang="less">
.j-user-select-modal2 {
.depart-select {
.ant-select-selector {
color: #fff !important;
background-color: #409eff !important;
border-radius: 5px !important;
}
.ant-select-selection-item,
.ant-select-arrow {
color: #fff !important;
}
}
.my-search {
position: absolute;
top: 14px;
z-index: 1;
&.all-width {
width: 100%;
}
.j-user-select-modal2 {
.scroll-container {
padding-bottom: 0 !important;
.anticon {
cursor: pointer;
&:hover {
color: #0a8fe9 !important;
}
}
.hidden {
display: none;
}
}
.my-tabs {
}
.selected-users {
display: flex;
flex-wrap: wrap;
flex-direction: row;
padding-top: 15px;
}
.scroll-container {
padding-bottom: 0 !important;
}
// 去掉内容底部多余的 18px 外边距,弹窗底部不再空一大块
.scrollbar__wrap {
margin-bottom: 0 !important;
}
}
}
</style>

View File

@ -126,11 +126,15 @@
}
const maxHeight = ref(300);
maxHeight.value = window.innerHeight - 300;
//update-begin---wangshuai---date:20260810 for:【LHZP-1106】审批角色 按角色添加人员 高度太大了------------
maxHeight.value = window.innerHeight - 500;
//update-end---wangshuai---date:20260810 for:【LHZP-1106】审批角色 按角色添加人员 高度太大了------------
const containerStyle = computed(() => {
return {
'overflow-y': 'auto',
'max-height': maxHeight.value + 'px',
//update-begin---wangshuai---date:20260810 for:【LHZP-1106】审批角色 按角色添加人员 高度太大了------------
'max-height': (maxHeight.value >= 300 ? maxHeight.value : 300) + 'px',
//update-end---author:wangshuai ---date:20260810 for:【LHZP-1106】审批角色 按角色添加人员 高度太大了------------
};
});

View File

@ -162,6 +162,7 @@
if (props.izExcludeMy) {
excludeUserIdList.value.push(userStore.getUserInfo.id);
}
resetFilterState();
//加载用户列表
loadUserList();
});
@ -180,6 +181,7 @@
selectedDepart.value = '';
}
function onDepartChange() {
pageNo.value = 1;
loadUserList();
}
/*--------------部门下拉框,用于筛选用户---------------*/
@ -187,6 +189,17 @@
/*--------------第一页 搜索框---------------*/
const searchInputStatus = ref(false);
const searchText = ref('');
/**
* 清空数据
*/
function resetFilterState() {
selectedDepart.value = '';
searchText.value = '';
searchInputStatus.value = false;
pageNo.value = 1;
}
function showSearchInput(e) {
e && prevent(e);
searchInputStatus.value = true;

View File

@ -1,4 +1,4 @@
import { inject, reactive, ref, watch, unref, Ref } from 'vue';
import { inject, reactive, ref, watch, unref, Ref, computed } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { isEmpty } from '@/utils/is';
@ -61,17 +61,16 @@ export function useSelectBiz(getList, props, emit?) {
}
/**
* 选择列配置
* 选择列配置(响应式:当 isRadioSelection prop 变化时,type 自动切换 radio/checkbox)
*/
const rowSelection = {
// 代码逻辑说明: 动态设置rowSelection的type值,默认是'checkbox' ---
const rowSelection = computed(() => ({
type: props.isRadioSelection ? 'radio' : 'checkbox',
columnWidth: 20,
selectedRowKeys: checkedKeys,
onChange: onSelectChange,
//table4.4.0新增属性选中之后是否清空上一页下一页的数据,默认false
preserveSelectedRowKeys:true,
};
}));
/**
* 序号列配置

View File

@ -219,6 +219,8 @@ export interface HelpComponentProps {
text: any;
// colour
color: string;
// Tooltip background colour
backgroundColor?: string;
// font size
fontSize: string;
icon: string;

View File

@ -154,6 +154,7 @@ export type ComponentType =
| 'LinkTableSelect'
| 'LinkTableForQuery'
| 'CascaderPcaForQuery'
| 'JCategoryLinkage'
| 'CascaderPcaInFilter'
| 'UserSelect'
| 'RoleSelect'
@ -163,5 +164,5 @@ export type ComponentType =
| 'RangeTime'
| 'JRangeNumber'
| 'JLinkTableCard'
| 'JInputSelect';
| 'JInputSelect'
| 'JAuthRuleValue';

View File

@ -9,6 +9,10 @@
import { isString } from '/@/utils/is';
import { propTypes } from '/@/utils/propTypes';
const SVG_END_WITH_FLAG = '|svg';
// update-begin--author:liaozhiyang---date:20260713---for:【LHZP-143】online新增字段图标会闪烁
// 缓存Iconify实例,避免每次动态import造成图标插入延迟(闪烁)
let iconifyInstance: any = null;
// update-end--author:liaozhiyang---date:20260713---for:【LHZP-143】online新增字段图标会闪烁
export default defineComponent({
name: 'Icon',
components: { SvgIcon },
@ -44,17 +48,21 @@
// update-begin--author:liaozhiyang---date:20260304---for:【QQYUN-14802】新增unplugin-icons插件,及icon支持online和local两种模式
try {
let svg: SVGElement | null = null;
if (import.meta.env.VITE_GLOB_ICONIFY_USE_TYPE === 'local') {
// update-begin--author:liaozhiyang---date:20260713---for:【LHZP-143】online新增字段图标会闪烁
if (iconifyInstance) {
svg = iconifyInstance.renderSVG(icon, {});
} else if (import.meta.env.VITE_GLOB_ICONIFY_USE_TYPE === 'local') {
// 使用本地 purge-icons 图标(离线,图标已打包进产物)
const iconifyModule = await import('@purge-icons/generated');
const Iconify = iconifyModule.default;
svg = Iconify.renderSVG(icon, {});
iconifyInstance = iconifyModule.default;
svg = iconifyInstance.renderSVG(icon, {});
} else {
// 使用 @iconify/iconify 在线按需加载
const iconifyModule = await import('@iconify/iconify');
const Iconify = iconifyModule.default;
svg = Iconify.renderSVG(icon, {});
iconifyInstance = iconifyModule.default;
svg = iconifyInstance.renderSVG(icon, {});
}
// update-end--author:liaozhiyang---date:20260713---for:【LHZP-143】online新增字段图标会闪烁
if (svg) {
el.textContent = '';
el.appendChild(svg);

View File

@ -41,10 +41,16 @@ export default defineComponent({
</script>
<script lang="ts" setup>
import {ref, watch, computed, nextTick, useAttrs, defineProps} from 'vue'
import {ref, watch, computed, nextTick, useAttrs} from 'vue'
import {DatePicker} from 'ant-design-vue'
import {useDesign} from '/@/hooks/web/useDesign';
import { Form } from 'ant-design-vue';
import dayjs from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import quarterOfYear from 'dayjs/plugin/quarterOfYear';
dayjs.extend(isoWeek);
dayjs.extend(quarterOfYear);
const formItemContext = Form.useInjectFormItemContext();
// 日期范围选项
@ -98,12 +104,30 @@ watch(innerValue, (val) => {
val = ''
openDatePicker()
}
emit('change', val)
emit('update:value', val)
// update-begin--author:wangshuai---date:20260819---for:【LHZP-322】【表单设计器】online周、季度没转化成功
const normalizedValue = normalizePickerValue(val)
emit('change', normalizedValue)
emit('update:value', normalizedValue)
// update-end--author:wangshuai---date:20260819---for:【LHZP-322】【表单设计器】online周、季度没转化成功
// 代码逻辑说明: 【QQYUN-9227】日期校验没清空
formItemContext?.onFieldChange();
})
/** 季度取季度第一天,周取 ISO 周的周一。 */
function normalizePickerValue(value) {
if (!value || RANGE_OPTION_KEYS.includes(value)) {
return value
}
const picker = attrs.picker
if (picker === 'quarter') {
return dayjs(value).startOf('quarter').format('YYYY-MM-DD')
}
if (picker === 'week') {
return dayjs(value).startOf('isoWeek').format('YYYY-MM-DD')
}
return value
}
watch(() => props.allowSelectRange, (allow) => {
// 如果不允许选择范围,且当前值为范围选项,则清空值
if (!allow && isRange.value) {

View File

@ -10,6 +10,10 @@ export async function registerJVxeCustom() {
// 注册【Popup】(普通封装方式)
await registerAsyncComponent(JVxeTypes.popup, import('./src/components/JVxePopupCell.vue'));
// update-begin--author:liaozhiyang---date:20260507---for:【issues/7617】vxetable子表支持popup字典
// 注册【Popup字典】
await registerAsyncComponent(JVxeTypes.popupDict, import('./src/components/JVxePopupDictCell.vue'));
// update-end--author:liaozhiyang---date:20260507---for:【issues/7617】vxetable子表支持popup字典
// 注册【字典搜索下拉】组件(高级封装方式)
registerComponent(JVxeTypes.selectDictSearch, DictSearchInputCell, DictSearchSpanCell);

View File

@ -1,57 +1,70 @@
<template>
<div>
<template v-if="hasFile" v-for="(file, fileKey) of [innerFile || {}]" :key="fileKey">
<div style="position: relative">
<template v-if="hasFile">
<div v-for="(file, fileKey) of [innerFile || {}]" :key="fileKey" class="j-vxe-file-item">
<a-tooltip v-if="file.status === 'uploading'" :title="`上传中(${Math.floor(file.percent)}%)`">
<LoadingOutlined />
<span style="margin-left: 5px">上传中…</span>
<span class="j-vxe-file-content">
<LoadingOutlined />
<span class="j-vxe-file-name">上传中…</span>
</span>
</a-tooltip>
<a-tooltip v-else-if="file.status === 'done'" :title="file.name">
<Icon icon="ant-design:paper-clip" />
<span style="margin-left: 5px">{{ ellipsisFileName }}</span>
<a-tooltip v-else-if="file.status === 'done'" :title="file.name" :mouseEnterDelay="0.4">
<button
type="button"
class="j-vxe-file-content j-vxe-file-content--action"
title=""
:disabled="cellProps.disabled"
:aria-label="`管理附件,共 ${uploadCount} 个`"
@click="handleMoreOperation"
>
<Icon icon="ant-design:paper-clip" />
<span class="j-vxe-file-name">{{ shortFileName }}</span>
<span v-if="uploadCount > 1" class="j-vxe-file-count">{{ uploadCount }}</span>
</button>
</a-tooltip>
<a-tooltip v-else :title="file.message || '上传失败'">
<Icon icon="ant-design:exclamation-circle" style="color: red" />
<span style="margin-left: 5px">{{ ellipsisFileName }}</span>
<span class="j-vxe-file-content">
<Icon icon="ant-design:exclamation-circle" style="color: red" />
<span class="j-vxe-file-name">{{ shortFileName }}</span>
</span>
</a-tooltip>
<Dropdown :trigger="['click']" placement="bottomRight" style="margin-left: 10px">
<Dropdown v-if="file.status !== 'uploading'" :trigger="['click']" placement="bottomRight">
<a-tooltip title="操作">
<Icon v-if="file.status !== 'uploading'" icon="ant-design:setting" style="cursor: pointer" />
<button type="button" class="j-vxe-file-action" aria-label="文件操作">
<Icon icon="ant-design:ellipsis-outlined" />
</button>
</a-tooltip>
<template #overlay>
<a-menu>
<a-menu-item :disabled="cellProps.disabled" @click="handleMoreOperation">
<span><Icon icon="ant-design:bars" />&nbsp;管理</span>
</a-menu-item>
<a-menu-item v-if="originColumn.allowDownload !== false" @click="handleClickDownloadFile">
<span><Icon icon="ant-design:download" />&nbsp;下载</span>
</a-menu-item>
<a-menu-item :disabled="cellProps.disabled" v-if="originColumn.allowRemove !== false" @click="handleClickDeleteFile">
<span><Icon icon="ant-design:delete" />&nbsp;删除</span>
</a-menu-item>
<a-menu-item :disabled="cellProps.disabled" @click="handleMoreOperation">
<span><Icon icon="ant-design:bars" />&nbsp;更多</span>
</a-menu-item>
</a-menu>
</template>
</Dropdown>
</div>
</template>
<a-upload
<a-button
v-if="!cellProps.disabledTable"
v-show="!hasFile"
name="file"
:data="{ isup: 1 }"
:multiple="false"
:action="uploadAction"
:headers="uploadHeaders"
:showUploadList="false"
v-bind="cellProps"
@change="handleChangeUpload"
class="j-vxe-upload-trigger"
size="small"
preIcon="ant-design:upload"
:disabled="cellProps.disabled"
@click="handleMoreOperation"
>
<a-button preIcon="ant-design:upload">{{ originColumn.btnText || '点击上传' }}</a-button>
</a-upload>
{{ originColumn.btnText || '附件' }}
</a-button>
<JUploadModal :value="modalValue" @register="registerModel" @change="onModalChange" />
</div>
</template>
@ -75,4 +88,94 @@
});
</script>
<style scoped lang="less"></style>
<style scoped lang="less">
.j-vxe-file-item {
display: inline-flex;
align-items: center;
max-width: 100%;
height: 28px;
overflow: hidden;
color: var(--vxe-ui-font-color);
vertical-align: middle;
background-color: var(--vxe-ui-layout-background-color);
border: 1px solid var(--vxe-ui-table-border-color);
border-radius: 4px;
}
.j-vxe-file-content {
display: inline-flex;
align-items: center;
min-width: 0;
height: 100%;
padding: 0 8px;
color: inherit;
background: transparent;
border: 0;
column-gap: 6px;
}
.j-vxe-file-content--action:not(:disabled) {
cursor: pointer;
&:hover,
&:focus-visible {
color: var(--vxe-ui-font-primary-color);
background-color: rgba(64, 158, 255, 0.08);
}
&:focus-visible {
outline: 2px solid var(--vxe-ui-font-primary-color) !important;
outline-offset: -2px;
}
}
.j-vxe-file-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.j-vxe-file-count {
flex: none;
min-width: 16px;
height: 16px;
padding: 0 4px;
color: #fff;
font-size: 10px;
line-height: 16px;
text-align: center;
background-color: var(--vxe-ui-font-primary-color);
border-radius: 8px;
}
.j-vxe-file-action {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: stretch;
flex: 0 0 28px;
width: 28px;
padding: 0;
color: var(--vxe-ui-font-color);
cursor: pointer;
background: transparent;
border: 0;
border-left: 1px solid var(--vxe-ui-table-border-color);
transition:
color 0.2s,
background-color 0.2s;
&:hover,
&:focus-visible {
color: var(--vxe-ui-font-primary-color);
background-color: rgba(64, 158, 255, 0.08);
}
&:focus-visible {
outline: 2px solid var(--vxe-ui-font-primary-color) !important;
outline-offset: -2px;
}
}
</style>

View File

@ -21,7 +21,7 @@
<a-upload
:accept="acceptFileType"
name="file"
:data="{ isup: 1 }"
:data="uploadData"
:multiple="false"
:action="uploadAction"
:headers="uploadHeaders"
@ -29,7 +29,7 @@
v-bind="cellProps"
@change="handleChangeUpload"
>
<a-button v-if="!hasFile" preIcon="ant-design:upload">{{ originColumn.btnText || '上传图片' }}</a-button>
<a-button v-if="!hasFile" class="j-vxe-upload-trigger" size="small" preIcon="ant-design:upload">{{ originColumn.btnText || '图片' }}</a-button>
<div v-if="hasFile && imgList.length < maxCount" class="j-vxe-plus" @click="">
<Icon icon="ant-design:plus" />
</div>

View File

@ -8,98 +8,124 @@
:linkFields="originColumn.linkFields || []"
:imageField="originColumn.imageField || ''"
:editBtnShow="!originColumn.editBtnShow"
:queryMode="originColumn.queryMode || 'online'"
v-bind="cellProps"
@change="handleChange"
/>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { JVxeComponent } from '/@/components/jeecg/JVxeTable/types';
import { useJVxeComponent, useJVxeCompProps } from '/@/components/jeecg/JVxeTable/hooks';
import { vModel } from '/@/components/jeecg/JVxeTable/utils';
import { isEmpty } from '/@/utils/is';
import { defHttp } from '/@/utils/http/axios';
import { defineComponent } from 'vue';
import { JVxeComponent } from '/@/components/jeecg/JVxeTable/types';
import { useJVxeComponent, useJVxeCompProps } from '/@/components/jeecg/JVxeTable/hooks';
import { vModel } from '/@/components/jeecg/JVxeTable/utils';
import LinkTableInput from '../../../../views/super/online/cgform/extend/linkTable/LinkTableInput.vue';
import { getLinkTableColumns, transLinkTableRecord } from '../../../../views/super/online/cgform/extend/linkTable/useLinkTable';
import { isEmpty } from '/@/utils/is';
import { defHttp } from '/@/utils/http/axios';
export default defineComponent({
name: 'JVxeLinkTableCell',
props: useJVxeCompProps(),
setup(props: JVxeComponent.Props) {
const { innerValue, cellProps, originColumn, scrolling, handleChangeCommon, row } = useJVxeComponent(props);
function handleChange(value, otherValues) {
if (!isEmpty(value)) {
Object.keys(otherValues).forEach((key) => {
let currentValue = otherValues[key];
vModel(currentValue, row, key);
});
handleChangeCommon(value);
}
export default defineComponent({
name: 'JVxeLinkTableCell',
components: { LinkTableInput },
props: useJVxeCompProps(),
setup(props: JVxeComponent.Props) {
const { innerValue, cellProps, originColumn, scrolling, handleChangeCommon, row } = useJVxeComponent(props);
function handleChange(value, otherValues) {
if (!isEmpty(value)) {
Object.keys(otherValues).forEach((key) => {
let currentValue = otherValues[key];
vModel(currentValue, row, key);
});
handleChangeCommon(value);
}
return {
innerValue,
cellProps,
originColumn,
scrolling,
handleChange,
row,
};
}
return {
innerValue,
cellProps,
originColumn,
scrolling,
handleChange,
row,
};
},
enhanced: {
aopEvents: {
},
enhanced: {
aopEvents: {
interceptor: {
'event.clearActived.className'(className) {
if (
className.includes('jeecg-online-pop-list-modal') ||
className.includes('jeecg-online-pop-modal') ||
className.includes('jeecg-all-table-pop-list-modal')
) {
return false;
}
},
interceptor: {
'event.clearActived.className'(className) {
if (className.includes('jeecg-online-pop-list-modal') || className.includes('jeecg-online-pop-modal')) {
return false;
}
},
},
translate: {
enabled: true,
async handler(value, ctx) {
if (!value) return '';
if (!ctx) return value;
const { originColumn, row } = ctx.context;
const col = originColumn.value;
const tableName = col.tableName;
const textField = col.textField || '';
const valueField = col.valueField || 'id';
// 关联记录相关的他表字段
const linkFields: string[] = col.linkFields || [];
if (!tableName) return value;
// 将他表字段对应的关联表字段也一并查询出来
},
translate: {
enabled: true,
async handler(value, ctx) {
if (!value) return '';
if (!ctx) return value;
const { originColumn, row } = ctx.context;
const col = originColumn.value;
const tableName = col.tableName;
const textField = col.textField || '';
const valueField = col.valueField || 'id';
// 关联记录相关的他表字段
const linkFields: string[] = col.linkFields || [];
if (!tableName) return value;
const vals = (value + '').split(',');
const textKey = textField.split(',')[0];
let records: any[] = [];
if (col.queryMode === 'table') {
const extraTableFields = linkFields.map((lf) => lf.split(',')[1]).filter(Boolean);
const fieldSet = new Set([valueField, ...textField.split(',').filter(Boolean), ...extraTableFields]);
const showFields = Array.from(fieldSet).join(',');
const data = await defHttp.get({
url: '/sys/dict/queryTableDataForLinkRecord',
params: { tableName, showFields, valueField, keyValues: value, pageNo: 1, pageSize: vals.length },
});
records = data?.records || [];
} else {
const extraTableFields = linkFields.map((lf) => lf.split(',')[1]).filter(Boolean);
const allSelectFields = [valueField, textField, ...extraTableFields].filter(Boolean);
const selectFields = [...new Set(allSelectFields)].join(',');
const params = {
linkTableSelectFields: selectFields,
pageSize: value.split(',').length,
pageNo: 1,
superQueryMatchType: 'and',
superQueryParams: encodeURI(JSON.stringify([{ field: valueField, rule: 'in', val: value }])),
};
const data = await defHttp.get({ url: '/online/cgform/api/getData/' + tableName, params });
const records = data?.records || [];
const textKey = textField.split(',')[0];
const vals = (value + '').split(',');
const labels = vals.map((v) => {
const record = records.find((r) => String(r[valueField]) === String(v));
return record ? record[textKey] : v;
});
// 将他表字段的值同步写入行数据,使对应列能正确显示
if (linkFields.length > 0 && row) {
linkFields.forEach((lf) => {
const [formField, tableField] = lf.split(',');
const fieldValues = vals.map((v) => {
const record = records.find((r) => String(r[valueField]) === String(v));
return record ? (record[tableField] ?? '') : '';
});
vModel(fieldValues.join(','), row, formField);
const [data, columns] = await Promise.all([
defHttp.get({url: '/online/cgform/api/getData/' + tableName,
params: {
linkTableSelectFields: selectFields,
pageSize: vals.length,
pageNo: 1,
superQueryMatchType: 'and',
superQueryParams: encodeURI(JSON.stringify([{ field: valueField, rule: 'in', val: value }])),
},
}),
getLinkTableColumns(tableName, selectFields),
]);
records = data?.records || [];
records.forEach((record) => transLinkTableRecord(record, columns?.columns, columns?.dictOptions));
}
const labels = vals.map((v) => {
const record = records.find((r) => String(r[valueField]) === String(v));
return record ? record[textKey] : v;
});
// 将他表字段的值同步写入行数据,使对应列能正确显示
if (linkFields.length > 0 && row) {
linkFields.forEach((lf) => {
const [formField, tableField] = lf.split(',');
const fieldValues = vals.map((v) => {
const record = records.find((r) => String(r[valueField]) === String(v));
return record ? (record[tableField] ?? '') : '';
});
}
return labels.join(',');
},
vModel(fieldValues.join(','), row, formField);
});
}
return labels.join(',');
},
} as JVxeComponent.EnhancedPartial,
});
},
} as JVxeComponent.EnhancedPartial,
});
</script>

View File

@ -0,0 +1,138 @@
<template>
<JPopupDict v-bind="popupProps" @change="onChange" @options-change="onOptionsChange" />
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue';
import JPopupDict from '/@/components/Form/src/jeecg/components/JPopupDict.vue';
import { JVxeComponent } from '/@/components/jeecg/JVxeTable/types';
import { useJVxeComponent, useJVxeCompProps } from '/@/components/jeecg/JVxeTable/hooks';
import { dispatchEvent } from '/@/components/jeecg/JVxeTable/utils';
import { defHttp } from '/@/utils/http/axios';
// 解析 dictCode("code,labelField,valueField")
function parseDictCode(dictCode: string | undefined) {
const arr = (dictCode || '').split(',');
return { code: arr[0] || '', labelField: arr[1] || '', valueField: arr[2] || '' };
}
let resultOptions = [];
const CG_RP_CONFIG_ID_SESSION_PREFIX = 'jeecg:popupDict:cgRpConfigId:';
const cgRpConfigIdCache = new Map<string, string>();
function safeSessionGet(key: string) {
return sessionStorage.getItem(key) || '';
}
function safeSessionSet(key: string, value: string) {
sessionStorage.setItem(key, value);
}
// 拿 cgRpConfigId(会话缓存 + 内存缓存)
async function getCgRpConfigId(code: string) {
if (!code) return '';
const sessionKey = `${CG_RP_CONFIG_ID_SESSION_PREFIX}${code}`;
const cached = cgRpConfigIdCache.get(code) || safeSessionGet(sessionKey);
if (cached) {
if (!cgRpConfigIdCache.has(code)) cgRpConfigIdCache.set(code, cached);
return cached;
}
const res = await defHttp.get({ url: `/online/cgreport/api/getRpColumns/${code}` }, { isTransformResponse: false, successMessageMode: 'none' });
const id = (res.success && res.result?.cgRpConfigId ? String(res.result.cgRpConfigId) : '') || '';
if (id) {
cgRpConfigIdCache.set(code, id);
safeSessionSet(sessionKey, id);
}
return id;
}
export default defineComponent({
name: 'JVxePopupDictCell',
components: { JPopupDict },
props: useJVxeCompProps(),
setup(props: JVxeComponent.Props) {
const { innerValue, originColumn, cellProps, handleChangeCommon } = useJVxeComponent(props);
// 仅支持 fieldConfig:cgreport报表code,显示字段(label),存储字段(value)
const computedFieldConfig = computed(() => {
const col = originColumn.value;
return (col as any).fieldConfig || '';
});
const popupProps = computed(() => ({
...cellProps.value,
value: innerValue.value ?? '',
dictCode: computedFieldConfig.value,
multi: originColumn.value.multi ?? originColumn.value.popupMulti ?? false,
param: originColumn.value.params ?? originColumn.value.param ?? {},
sorter: originColumn.value.sorter,
useInput: true,
}));
function onChange(val) {
setTimeout(() => {
handleChangeCommon(val ?? '');
}, 0);
}
function onOptionsChange(options) {
resultOptions = options;
}
return { popupProps, onChange, onOptionsChange };
},
// 【组件增强】非编辑模式下把 value(username) 翻译成 label(realname)
enhanced: {
switches: { editRender: true, visible: false },
aopEvents: {
editActived({ $event }) {
dispatchEvent({
$event,
props: this.props,
className: '.ant-select',
isClick: true,
});
},
},
translate: {
enabled: true,
async handler(value, ctx) {
if (value == null || value === '') return '';
const col = ctx?.context.originColumn.value;
const fieldConfig = (col as any)?.fieldConfig || '';
const { code, labelField, valueField } = parseDictCode(fieldConfig);
if (!code || !labelField || !valueField) return value;
if (resultOptions?.length) {
// 刚点击完弹窗,直接返回结果options
const result = resultOptions.map((item: any) => item.text);
resultOptions = [];
return result.join(',');
}
const ids = String(value).split(',');
try {
const cgRpConfigId = await getCgRpConfigId(code);
if (!cgRpConfigId) return value;
const params = {
[`force_${valueField}`]: value,
};
const res = await defHttp.get(
{ url: `/online/cgreport/api/getData/${cgRpConfigId}`, params },
{ isTransformResponse: false, successMessageMode: 'none' }
);
const records = res?.result?.records;
if (records?.length) {
const labels = ids.map((id) => {
const found = records.find((r) => String(r[valueField]) === id);
return found?.[labelField] ?? id;
});
return labels.join(',');
}
} catch {}
return value;
},
},
} as JVxeComponent.EnhancedPartial,
});
</script>
<style lang="less" scoped>
.JPopupDict {
:deep(.ant-select) {
width: 100%;
}
}
</style>

View File

@ -7,6 +7,7 @@ import { JVxeComponent } from '/@/components/jeecg/JVxeTable/src/types/JVxeCompo
import { Icon } from '/@/components/Icon';
import { Dropdown } from 'ant-design-vue';
import { LoadingOutlined } from '@ant-design/icons-vue';
import { split } from '/@/utils';
export function useFileCell(props, fileType: UploadTypeEnum, options?) {
const setup = useJVxeUploadCell(props, { token: true, action: uploadUrl, ...options });
@ -15,14 +16,14 @@ export function useFileCell(props, fileType: UploadTypeEnum, options?) {
const [registerModel, { openModal }] = useModal();
// 截取文件名
const ellipsisFileName = computed(() => {
let length = 5;
let file = innerFile.value;
const shortFileName = computed(() => {
const length = 5;
const file = innerFile.value;
if (!file || !file.name) {
return '';
}
if (file.name.length > length) {
return file.name.substr(0, length) + '…';
return file.name.substr(0, length);
}
return file.name;
});
@ -38,14 +39,19 @@ export function useFileCell(props, fileType: UploadTypeEnum, options?) {
return '';
});
const uploadCount = computed(() => {
const value = modalValue.value;
return value ? split(value).filter(Boolean).length : 0;
});
const maxCount = computed(() => {
let maxCount = originColumn.value.maxCount;
// online 扩展JSON
if (originColumn.value && originColumn.value.fieldExtendJson) {
let json = JSON.parse(originColumn.value.fieldExtendJson);
const json = JSON.parse(originColumn.value.fieldExtendJson);
maxCount = json.uploadnum ? json.uploadnum : 0;
}
return maxCount ?? 0;
return Number(maxCount ?? 0);
});
// 点击更多按钮
@ -56,19 +62,26 @@ export function useFileCell(props, fileType: UploadTypeEnum, options?) {
download: true,
...originColumn.value.props,
maxCount: maxCount.value,
multiple: originColumn.value.props?.multiple ?? maxCount.value !== 1,
fileType: fileType,
action: originColumn.value.action ?? void 0,
// update-begin--author:liaozhiyang---date:20250526---for:【issues/9657】jvxetable组件的图片上传编辑时再次上传报错
action: originColumn.value.action ?? setup.uploadAction,
// update-end--author:liaozhiyang---date:20250526---for:【issues/9657】jvxetable组件的图片上传编辑时再次上传报错
// update-begin--author:liaozhiyang---date:20250526---for:【issues/9652】vxetable上传、上传文件、上传图片加上自定义路径
bizPath: originColumn.value.bizPath ?? 'temp',
// update-end--author:liaozhiyang---date:20250526---for:【issues/9652】vxetable上传、上传文件、上传图片加上自定义路径
});
}
// 更多上传回调
function onModalChange(path) {
if (path) {
// 代码逻辑说明: 【TV360X-235】富文本禁用状态下图片上传按钮文字看不清
if (innerFile.value === null) {
innerFile.value = {};
// 从弹窗首次上传时同步补齐文件名和完成状态
innerFile.value = fileSetValue(path);
} else {
innerFile.value.path = path;
}
innerFile.value.path = path;
handleChangeCommon(innerFile.value);
} else {
// 代码逻辑说明: [issues/530]JVxeTable 的JVxeTypes.image类型,无法全部删除上传图片
@ -79,8 +92,9 @@ export function useFileCell(props, fileType: UploadTypeEnum, options?) {
return {
...setup,
modalValue,
uploadCount,
maxCount,
ellipsisFileName,
shortFileName,
registerModel,
onModalChange,
handleMoreOperation,

View File

@ -11,6 +11,7 @@
import 'vditor/dist/index.css';
import { useRootSetting } from '/@/hooks/setting/useRootSetting';
import { ThemeEnum } from '/@/enums/appEnum';
import { sanitizeRichText } from '/@/utils/htmlSanitizer';
const converter = new showdown.Converter();
converter.setOption('tables', true);
@ -19,7 +20,7 @@
value: { type: String },
class: { type: String },
});
const getHtmlData = computed(() => converter.makeHtml(props.value || ''));
const getHtmlData = computed(() => sanitizeRichText(converter.makeHtml(props.value || '')));
// 代码逻辑说明: 【issues/918】MarkdownViewer加上暗黑主题
const isDarkTheme = ref(false);

View File

@ -29,7 +29,7 @@
:maxHeight="getProps.maxHeight"
:height="getWrapperHeight"
:visible="visibleRef"
:modalHeaderHeight="getProps.modalHeaderHeight"
:modalHeaderHeight="isNoTitle ? 0 : getProps.modalHeaderHeight"
:modalFooterHeight="footer !== undefined && !footer ? 0 : getProps.modalFooterHeight"
v-bind="omit(getProps.wrapperProps, 'visible', 'height', 'modalFooterHeight')"
@ext-height="handleExtHeight"
@ -266,6 +266,13 @@
emit('fullScreen',val);
});
// update-begin--author:liaozhiyang---date:20260805---for:【LHZP-865】basicModal无标题时不预留 header 高度,有标题时重新计算内容区高度
watch(isNoTitle, () => {
nextTick(() => {
modalMethods.redoModalHeight();
});
});
// update-end--author:liaozhiyang---date:20260805---for:【LHZP-865】basicModal无标题时不预留 header 高度,有标题时重新计算内容区高度
return {
handleCancel,
getBindValue,

View File

@ -38,6 +38,7 @@
const spinRef = ref<ElRef>(null);
const realHeightRef = ref(0);
const minRealHeightRef = ref(0);
const availableHeightRef = ref(0);
let realHeight = 0;
@ -116,7 +117,7 @@
return {
minHeight: `${props.minHeight === null ? defaultMiniHeight : props.minHeight}px`,
// 代码逻辑说明: 【QQYUN-7641】basicModal组件添加MaxHeight属性
maxHeight: `${props.maxHeight ? props.maxHeight : unref(realHeightRef)}px`,
maxHeight: `${props.maxHeight ? Math.min(props.maxHeight, unref(availableHeightRef) || props.maxHeight) : unref(realHeightRef)}px`,
};
}
}
@ -172,21 +173,24 @@
await nextTick();
try {
const modalDom = bodyDom.parentElement && bodyDom.parentElement.parentElement;
const modalDom = bodyDom.closest('.ant-modal');
if (!modalDom) return;
//update-begin---author:scott ---date:20260814 for:修复弹窗内容高度自适应及底部操作按钮遮挡问题-----------
const spinEl = unref(spinRef);
if (!spinEl) return;
const modalRect = getComputedStyle(modalDom as Element).top;
const modalTop = Number.parseInt(modalRect);
let maxHeight = window.innerHeight - modalTop * 2 + (props.footerOffset! || 0) - props.modalFooterHeight - props.modalHeaderHeight;
// 使用 CSS 布局位置计算可用高度,避免打开动画导致位置读取不稳定
const modalTop = Number.parseFloat(getComputedStyle(modalDom).top) || 0;
const modalExtHeight = (modalDom as HTMLElement).offsetHeight - spinEl.clientHeight;
let maxHeight = window.innerHeight - modalTop + (props.footerOffset! || 0) - modalExtHeight;
//update-end---author:scott ---date:20260814 for:修复弹窗内容高度自适应及底部操作按钮遮挡问题-----------
// 距离顶部过进会出现滚动条
if (modalTop < 40) {
maxHeight -= 26;
}
availableHeightRef.value = maxHeight;
await nextTick();
const spinEl = unref(spinRef);
if (!spinEl) return;
await nextTick();
// if (!realHeight) {
realHeight = spinEl.scrollHeight;

View File

@ -145,7 +145,7 @@
width: 0;
height: 0;
cursor: pointer;
background-color: rgba(144, 147, 153, 0.3);
background-color: rgba(144, 147, 153, 0.6);
border-radius: inherit;
transition: 0.3s background-color;

View File

@ -18,7 +18,9 @@
<script lang="ts">
import type { RawEditorOptions } from 'tinymce';
import tinymce from 'tinymce/tinymce';
// update-begin--author:copilot---date:20260711---for:【依赖升级排查】修复 Rolldown 严格 CJS 互操作导致 tinymce 全局未挂载
import tinymce from '/@/utils/tinymceGlobalShim';
// update-end--author:copilot---date:20260711---for:【依赖升级排查】修复 Rolldown 严格 CJS 互操作导致 tinymce 全局未挂载
import Editor from '@tinymce/tinymce-vue'
import 'tinymce/themes/silver';
import 'tinymce/icons/default/icons';
@ -31,6 +33,7 @@
import 'tinymce/plugins/lists';
import 'tinymce/plugins/preview';
import 'tinymce/plugins/image';
import 'tinymce/plugins/media';
import { defineComponent, computed, nextTick, ref, unref, watch, onDeactivated, onBeforeUnmount, onMounted } from 'vue';
import ImgUpload from './ImgUpload.vue';
import ProcessMask from './ProcessMask.vue';
@ -205,6 +208,38 @@
};
uploadFile(params, uploadSuccess);
}),
// update-begin--author:liaozhiyang---date:20260617---for:【JHHB-1433】新闻内容需要支持插入视频,现在的插件没有插入视频操作
media_live_embeds: true,
media_alt_source: false,
media_poster: false,
media_dimensions: true,
file_picker_types: 'media',
file_picker_callback: (callback, value, meta) => {
if (meta.filetype !== 'media') return;
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'video/*');
input.onchange = () => {
const file = (input.files && input.files[0]) as File | undefined;
if (!file) return;
const params = {
file,
filename: file.name,
data: { biz: 'jeditor', jeditor: '1' },
};
const uploadSuccess = (res: any) => {
if (res && res.success) {
const videoUrl = res.message === 'local'
? URL.createObjectURL(file)
: getFileAccessHttpUrl(res.message);
callback(videoUrl, { title: file.name });
}
};
uploadFile(params, uploadSuccess);
};
input.click();
},
// update-end--author:liaozhiyang---date:20260617---for:【JHHB-1433】新闻内容需要支持插入视频,现在的插件没有插入视频操作
content_css: publicPath + 'resource/tinymce/skins/ui/' + skinName.value + '/content.min.css',
...options,
setup: (editor: any) => {

View File

@ -4,16 +4,16 @@
// colorpicker/contextmenu/textcolor plugin is now built in to the core editor, please remove it from your editor configuration
export const plugins = [
'advlist anchor autolink autosave code codesample directionality fullscreen hr insertdatetime link lists media nonbreaking noneditable pagebreak paste preview print save searchreplace tabfocus template textpattern visualblocks visualchars wordcount image',
'advlist anchor autolink autosave code codesample directionality fullscreen hr insertdatetime link lists media nonbreaking noneditable pagebreak preview print save searchreplace tabfocus template textpattern visualblocks visualchars wordcount image',
];
export const toolbar =
'fullscreen code preview | undo redo | bold italic underline strikethrough | fontselect fontsizeselect formatselect | alignleft aligncenter alignright alignjustify | outdent indent lineheight|subscript superscript blockquote| numlist bullist checklist | forecolor backcolor casechange permanentpen formatpainter removeformat | pagebreak | charmap emoticons | insertfile image media pageembed link anchor codesample insertdatetime hr| a11ycheck ltr rtl';
export const simplePlugins = 'lists image link fullscreen paste';
export const simplePlugins = 'lists image link fullscreen media';
export const simpleToolbar = [
'undo redo styles forecolor fontsize bold italic alignleft aligncenter alignright alignjustify bullist numlist outdent indent lists image link fullscreen',
'undo redo styles forecolor fontsize bold italic alignleft aligncenter alignright alignjustify bullist numlist outdent indent lists image media link fullscreen',
];
export const menubar = 'file edit insert view format table';

View File

@ -168,7 +168,7 @@
}
try {
item.status = UploadResultStatus.UPLOADING;
const { data } = await props.api?.(
const res = await props.api?.(
{
data: {
...(props.uploadParams || {}),
@ -182,8 +182,16 @@
item.percent = complete;
}
);
// update-begin--author:liaozhiyang---date:20260804---for:【LHZP-1385】修复上传示例报错
const data = res?.data ?? res;
const isSuccess = data && (data.success === true || data.code === 0);
const url = data?.url || data?.result || data?.message;
if (!isSuccess || !url) {
throw new Error(data?.message || 'upload failed');
}
item.status = UploadResultStatus.SUCCESS;
item.responseData = data;
item.responseData = { ...data, url };
// update-end--author:liaozhiyang---date:20260804---for:【LHZP-1385】修复上传示例报错
return {
success: true,
error: null,

View File

@ -434,6 +434,7 @@
async function queryUserList(params) {
params['column'] = 'sort';
params['order'] = 'ASC';
params['status'] = '1';
if(departInfo.selectedKeys.length == 0 && positionInfo.selectedKeys.length == 0 && userGroupInfo.selectedKeys.length == 0 && params.realname){
params['realname'] = `*${params.realname.trim()}*`;
}

View File

@ -8,7 +8,7 @@
:treeData="treeData"
:checkStrictly="true"
@select="onSelectDepart"
style="height: calc(100vh - 390px); min-height: 150px; overflow: auto"
class="post-rank-tree"
></BasicTree>
</template>
<a-empty v-else description="无岗位信息" />
@ -73,7 +73,38 @@
</script>
<style lang="less" scoped>
.depart-rule-tree :deep(.scrollbar__bar) {
pointer-events: none;
// update-begin--author:liaozhiyang---date:20260811---for:【LHZP-1694】选中用户组件切换到岗位时滚动条拖动不了
.post-rank-tree {
width: 100%;
min-width: 0;
height: calc(100vh - 390px);
min-height: 150px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
}
.post-rank-tree :deep(.scrollbar__bar) {
pointer-events: auto;
}
.post-rank-tree :deep(.scrollbar__view),
.post-rank-tree :deep(.ant-tree),
.post-rank-tree :deep(.ant-tree-list-holder-inner) {
width: 100%;
min-width: 0;
}
.post-rank-tree :deep(.ant-tree-treenode) {
box-sizing: border-box;
width: 100%;
min-width: 0;
overflow: hidden;
}
.post-rank-tree :deep(.ant-tree-node-content-wrapper) {
min-width: 0;
overflow: hidden;
}
// update-end--author:liaozhiyang---date:20260811---for:【LHZP-1694】选中用户组件切换到岗位时滚动条拖动不了
</style>

View File

@ -20,7 +20,7 @@ export const getDepartTreeData = (params?) => defHttp.get({ url: Api.departList,
/**
* 获取用户列表
*/
export const getUserList = (params?) => defHttp.get({ url: Api.userList, params });
export const getUserList = (params?) => defHttp.get({ url: Api.userList, params: { ...params, status: '1' } });
/**
* 获取指定部门用户列表

View File

@ -8,6 +8,7 @@
<a-button v-if="addBtnCfg.enabled && showAdd" type="primary" :preIcon="addBtnCfg.buttonIcon" :disabled="disabled" :loading="deleting" @click="trigger('add')">
<span>{{ addBtnCfg.buttonName }}</span>
</a-button>
<slot name="toolbarAfterAdd" :size="btnSize" :selectedRowIds="selectedRowIds" />
<a-button v-if="showSave" preIcon="ant-design:save-outlined" :disabled="disabled" @click="trigger('save')">
<span>保存</span>
</a-button>
@ -21,7 +22,7 @@
<a-button preIcon="ant-design:delete-outlined" @click="trigger('clearSelection')">清空选择</a-button>
</template>
</template>
<slot v-if="showSuffix" name="toolbarSuffix" :size="btnSize" />
<slot v-if="showSuffix" name="toolbarSuffix" :size="btnSize" :selectedRowIds="selectedRowIds" />
<a v-if="showCollapse" style="margin-left: 4px" @click="toggleCollapse">
<span>{{ collapsed ? '展开' : '收起' }}</span>
<Icon :icon="collapsed ? 'ant-design:down-outlined' : 'ant-design:up-outlined'" />

View File

@ -54,7 +54,10 @@
function handleChange(_mom, dateStr) {
// 代码逻辑说明: 【QQYUN-9205】一对多(jVxetable组件date)支持年,年月,年度度,年周
if (picker.value) {
handleChangeCommon(_mom);
//update-begin-author:liusq---date:2026-08-06--for: LHZP-1052 生成的jvxe子表 保存的时候 年 年月 周 季度 有一个没有填写值,就会报错
const dateValue = _mom && dayjs(_mom).isValid() ? dayjs(_mom).format(dateFormat.value) : null;
handleChangeCommon(dateValue);
//update-end-author:liusq---date:2026-08-06--for: LHZP-1052 生成的jvxe子表 保存的时候 年 年月 周 季度 有一个没有填写值,就会报错
} else {
handleChangeCommon(dateStr);
}

View File

@ -4,10 +4,29 @@
<LoadingOutlined />
<span>&nbsp;加载中…</span>
</template>
<template v-for="option of selectOptions" :key="option.value">
<a-select-option :value="option.value" :title="option.text || option.label || option.title" :disabled="option.disabled">
<span>{{ option.text || option.label || option.title || option.value }}</span>
</a-select-option>
<!-- 分组模式:options 中含有 group 字段时,按分组渲染 -->
<template v-if="hasGroup">
<template v-for="(groupOptions, groupName) of groupedSelectOptions" :key="groupName">
<a-select-opt-group :label="groupName">
<a-select-option
v-for="option of groupOptions"
:key="option.value"
:value="option.value"
:title="option.text || option.label || option.title"
:disabled="option.disabled"
>
<span>{{ option.text || option.label || option.title || option.value }}</span>
</a-select-option>
</a-select-opt-group>
</template>
</template>
<!-- 非分组模式:原有扁平渲染 -->
<template v-else>
<template v-for="option of selectOptions" :key="option.value">
<a-select-option :value="option.value" :title="option.text || option.label || option.title" :disabled="option.disabled">
<span>{{ option.text || option.label || option.title || option.value }}</span>
</a-select-option>
</template>
</template>
</a-select>
</template>
@ -79,6 +98,24 @@
return originColumn.value.options;
});
// 是否包含分组字段
const hasGroup = computed(() => {
const opts = selectOptions.value;
return opts && opts.length > 0 && opts.some((o) => o.group);
});
// 按 group 字段分组后的选项
const groupedSelectOptions = computed(() => {
const opts = selectOptions.value || [];
const groups: Record<string, any[]> = {};
for (const opt of opts) {
const g = opt.group || '';
if (!groups[g]) groups[g] = [];
groups[g].push(opt);
}
return groups;
});
// --------- created ---------
// 多选、搜索type
@ -169,6 +206,8 @@
innerValue,
selectProps,
selectOptions,
hasGroup,
groupedSelectOptions,
handleChange,
handleBlur,
};

View File

@ -37,7 +37,7 @@
v-if="!cellProps.disabledTable"
v-show="!hasFile"
name="file"
:data="{ isup: 1 }"
:data="uploadData"
:multiple="false"
:action="originColumn.action"
:headers="uploadHeaders"

View File

@ -37,7 +37,16 @@ export function useJVxeUploadCell(props: JVxeComponent.Props, options?) {
});
const hasFile = computed(() => innerFile.value != null);
const responseName = computed(() => originColumn.value.responseName ?? 'message');
// update-begin--author:liaozhiyang---date:20250526---for:【issues/9652】vxetable上传、上传文件、上传图片加上自定义路径
const uploadData = computed(() => {
const bizPath = originColumn.value.bizPath;
const result: any = { isup: 1 };
if (bizPath) {
result.biz = bizPath;
}
return result;
});
// update-end--author:liaozhiyang---date:20250526---for:【issues/9652】vxetable上传、上传文件、上传图片加上自定义路径
watch(
innerValue,
(val) => {
@ -110,6 +119,7 @@ export function useJVxeUploadCell(props: JVxeComponent.Props, options?) {
innerFile,
uploadAction,
uploadHeaders,
uploadData,
hasFile,
responseName,
handleChangeUpload,

View File

@ -141,7 +141,9 @@ function customComponentAddStar(columns) {
].includes(type)
) {
if (validateRules.find((item) => item.required)) {
column.title = ` * ${column.title}`;
// update-begin--author:liaozhiyang---date:20260807---for:【LHZP-703】用户、部门、图片、文件、开关的必填标识显示非红色
column.headerClassName = 'jvxe-col--required';
// update-end--author:liaozhiyang---date:20260807---for:【LHZP-703】用户、部门、图片、文件、开关的必填标识显示非红色
}
}
}

View File

@ -186,6 +186,28 @@ export function useJVxeComponent(props: JVxeComponent.Props) {
{ immediate: true }
);
// update-begin--author:liaozhiyang---date:20250526---for:【QQYUN-15511】一对多子表的options通过js修改后,刷新后编辑回显的不是label
// 监听 options 变化(解决 动态更新options时需重新翻译)
watch(
() => originColumn.value?.options,
() => {
if (props.renderType === JVxeRenderType.spaner && enhanced.translate.enabled === true) {
if (isFunction(enhanced.translate.handler)) {
const newValue = value.value;
if (newValue === undefined || newValue === null) return;
let res = enhanced.translate.handler(newValue, ctx);
if (isPromise(res)) {
res.then((v) => (innerValue.value = v));
} else {
innerValue.value = res;
}
}
}
},
{ deep: true }
);
// update-end--author:liaozhiyang---date:20250526---for:【QQYUN-15511】一对多子表的options通过js修改后,刷新后编辑回显的不是label
/** 通用处理 change 事件 */
function handleChangeCommon($value, force = false) {
const newValue = enhanced.getValue($value, ctx);

View File

@ -61,6 +61,7 @@ export function useToolbar(props: JVxeTableProps, data: JVxeDataProps, methods:
},
{
toolbarPrefix: $slots.toolbarPrefix,
toolbarAfterAdd: $slots.toolbarAfterAdd,
toolbarSuffix: $slots.toolbarSuffix,
}
);

View File

@ -22,12 +22,18 @@ export function useValidateRules(args: HandleArgs) {
delete rule.pattern;
} else {
// 兼容Online表单的特殊规则
let reg: any = rule.pattern;
for (let foo of fooPatterns) {
if (foo.value === rule.pattern) {
rule.pattern = foo.pattern;
reg = foo.pattern;
break;
}
}
// update-begin--author:liaozhiyang---date:20260714---for:【LHZP-172】行编辑pattern校验空值时不应触发,仅有值时校验(非空由required规则负责)
const regExp = reg instanceof RegExp ? reg : new RegExp(reg);
delete rule.pattern;
rule.validator = emptySkipPatternValidator(regExp);
// update-end--author:liaozhiyang---date:20260714---for:【LHZP-172】行编辑pattern校验空值时不应触发,仅有值时校验(非空由required规则负责)
}
} else if (typeof rule.handler === 'function') {
// 自定义函数校验
@ -78,6 +84,19 @@ function handlerConvertToValidator(event) {
});
}
// update-begin--author:liaozhiyang---date:20260714---for:【LHZP-172】行编辑pattern校验空值时不应触发,仅有值时校验(非空由required规则负责)
/** pattern 校验器:空值不校验(非空交给 required 规则),仅当有值时做正则校验 */
function emptySkipPatternValidator(regExp: RegExp) {
return function (event) {
const { cellValue, rule } = event;
if (cellValue === '' || cellValue === null || cellValue === undefined) {
return Promise.resolve();
}
return regExp.test(String(cellValue)) ? Promise.resolve() : Promise.reject(new Error(rule.message));
};
}
// update-end--author:liaozhiyang---date:20260714---for:【LHZP-172】行编辑pattern校验空值时不应触发,仅有值时校验(非空由required规则负责)
// 兼容 online 的规则
const fooPatterns = [
{ title: '非空', value: '*', pattern: /^.+$/ },

View File

@ -70,7 +70,14 @@ function preventClosingPopUp(this: any, params) {
return false;
}
// 点击的标签是JPopup的弹出层、部门选择、用户选择
if (className.includes('j-popup-modal') || className.includes('j-depart-select-modal') || className.includes('j-user-select-modal')) {
if (
className.includes('j-popup-modal') ||
className.includes('j-depart-select-modal') ||
className.includes('j-user-select-modal') ||
className.includes('jeecg-online-pop-list-modal') ||
className.includes('jeecg-online-pop-modal') ||
className.includes('jeecg-all-table-pop-list-modal')
) {
return false;
}
// 点击的是日期选择器

View File

@ -7,6 +7,13 @@
border-color: #606266;
}
// 图片、附件上传按钮在行内保持紧凑,避免抢占表格内容的视觉层级
.j-vxe-upload-trigger.ant-btn {
height: 28px;
padding: 0 10px;
box-shadow: none;
}
.sort--active {
border-color: @primary-color;
}
@ -111,5 +118,17 @@
.vxe-header--column.col--ellipsis>.vxe-cell .vxe-cell--title{
font-size: 13px;
}
// update-begin--author:liaozhiyang---date:20260807---for:【LHZP-703】用户、部门、图片、文件、开关的必填标识显示非红色
// 不可编辑组件(cellRender)必填标识,样式与 vxe-table 原生 .vxe-cell--required-icon 保持一致
.vxe-header--column.jvxe-col--required > .vxe-cell .vxe-cell--title::before {
content: '*';
display: inline-block;
color: var(--vxe-ui-table-validate-error-color, #f5222d);
width: 0.8em;
margin-right: 2px;
font-family: Verdana, Arial, Tahoma;
vertical-align: middle;
}
// update-end--author:liaozhiyang---date:20260807---for:【LHZP-703】用户、部门、图片、文件、开关的必填标识显示非红色
}

View File

@ -40,6 +40,9 @@ export enum JVxeTypes {
// 以下为自定义组件
popup = 'popup',
// update-begin--author:liaozhiyang---date:20260507---for:【QQYUN-7961】vxetable子表支持popup字典
popupDict = 'popupDict',
// update-end--author:liaozhiyang---date:20260507---for:【QQYUN-7961】vxetable子表支持popup字典
selectDictSearch = 'selectDictSearch',
radio = 'radio',
image = 'image',

View File

@ -9,6 +9,7 @@
valueFormat="YYYY-MM-DD"
:placeholder="'请选择' + item.label"
v-model:value="queryParam[item.field]"
style="width: 100%"
></a-date-picker>
</template>
<template v-else>
@ -40,6 +41,7 @@
:show-time="true"
valueFormat="YYYY-MM-DD HH:mm:ss"
v-model:value="queryParam[item.field]"
style="width: 100%"
></a-date-picker>
</template>
<template v-else>
@ -66,7 +68,7 @@
<span :title="item.label" class="label-text">{{ item.label }}</span>
</template>
<template v-if="single_mode === item.mode">
<a-date-picker :placeholder="'请选择' + item.label" mode="time" valueFormat="HH:mm:ss" v-model:value="queryParam[item.field]"></a-date-picker>
<a-date-picker :placeholder="'请选择' + item.label" mode="time" valueFormat="HH:mm:ss" v-model:value="queryParam[item.field]" style="width: 100%"></a-date-picker>
</template>
<template v-else>
<a-date-picker
@ -115,6 +117,7 @@
:pidField="item.pidField"
:pidValue="item.pidValue"
:hasChildField="item.hasChildField"
:converIsLeafVal="item.converIsLeafVal"
load-triggle-change
>
</JTreeSelect>
@ -131,7 +134,12 @@
<template #label>
<span :title="item.label" class="label-text">{{ item.label }}</span>
</template>
<JOnlineSearchSelect v-model:value="queryParam[item.field]" :placeholder="'请选择'+item.label" :fieldId="item.fieldId"/>
<JOnlineSearchSelect
v-model:value="queryParam[item.field]"
:placeholder="'请选择' + item.label"
:fieldId="item.fieldId"
:reportId="item.reportId"
/>
</a-form-item>
<a-form-item v-else-if="item.view === CompTypeEnum.SelUser" :labelCol="labelCol" :class="'jeecg-online-search'">
@ -324,4 +332,10 @@
text-overflow: ellipsis;
overflow-wrap: break-word;
}
/* update-begin--author:liaozhiyang---date:20260804---for:【LHZP-983】查询日期/日期时间组件宽度撑满 */
.jeecg-online-search :deep(.ant-picker) {
width: 100%;
}
/* update-end--author:liaozhiyang---date:20260804---for:【LHZP-983】查询日期/日期时间组件宽度撑满 */
</style>

View File

@ -255,9 +255,17 @@ export function usePopBiz(ob, tableRef?) {
if (query) {
url = url + query
}
// update-begin--author:liaozhiyang---date:20260804---for:【LHZP-1296】地址栏有参数时首次请求即带上,避免再触发两次请求
// 首次请求前先合并地址栏参数,保证与后续查询条件一致
if (props.routeQuery && Object.keys(props.routeQuery).length > 0) {
queryParam.value = Object.assign({}, queryParam.value, props.routeQuery);
}
let params = getQueryParams();
params['onlRepUrlParamStr'] = getUrlParamString();
// update-end--author:liaozhiyang---date:20260804---for:【LHZP-1296】地址栏有参数时首次请求即带上,避免再触发两次请求
//缓存key
let groupIdKey = props.groupId ? `${props.groupId}${url}` : '';
httpGroupRequest(() => defHttp.get({ url }, { isTransformResponse: false, successMessageMode: 'none' }), groupIdKey).then((res) => {
let groupIdKey = props.groupId ? `${props.groupId}${url}${JSON.stringify(params)}` : '';
httpGroupRequest(() => defHttp.get({ url, params }, { isTransformResponse: false, successMessageMode: 'none' }), groupIdKey).then((res) => {
if (res.success) {
initDictOptionData(res.result.dictOptions);
cgRpConfigId.value = props.id;
@ -621,15 +629,14 @@ export function usePopBiz(ob, tableRef?) {
dynamicParamHandler(res.result);
queryInfo.value = res.result;
console.log('queryInfo==>', queryInfo.value);
//查询条件加载后再请求数据
// update-begin--author:liaozhiyang---date:20260804---for:【JPopupOnlReport】已有列表数据时不再二次 loadData,避免地址栏带参重复请求
// 查询条件加载后再请求数据;若 loadColumnsAndData 已带回数据,直接回填,不再重复请求
if (data) {
setDataSource(data);
//传递路由参数和动态参数,不生效,
loadData(1);
} else {
//没有传递data时查询数据
loadData(1);
}
// update-end--author:liaozhiyang---date:20260804---for:【JPopupOnlReport】已有列表数据时不再二次 loadData,避免地址栏带参重复请求
} else {
createMessage.warning(res.message);
}
@ -695,7 +702,7 @@ export function usePopBiz(ob, tableRef?) {
}
dataSource.value = data.records;
// 代码逻辑说明: issues/426 修复356时候引入的回归错误 JPopupOnlReportModal.vue 中未修改
tableRef?.value && tableRef?.value?.setPagination({
tableRef?.value && tableRef?.value?.setPagination?.({
total: Number(data.total)
})
} else {

View File

@ -17,7 +17,8 @@
<span>{{ item.toUserId_dictText }}</span>
<Tooltip class="comment-last-content" @openChange="(v)=>visibleChange(v, item)">
<template #title>
<div v-html="getHtml(lineFeed(item.commentId_dictText))"></div>
<a-spin v-if="item.commentIdLoading" size="small" tip="加载中..." class="jeecg-comment-tooltip-spin" />
<div v-else v-html="getHtml(lineFeed(item.commentId_dictText))"></div>
</template>
<message-outlined />
</Tooltip>
@ -85,7 +86,7 @@
// dayjs.extend(customParseFormat);
import { MessageOutlined } from '@ant-design/icons-vue';
import { Comment, Tooltip } from 'ant-design-vue';
import { Comment, Spin, Tooltip } from 'ant-design-vue';
import { useUserStore } from '/@/store/modules/user';
import MyComment from './MyComment.vue';
import { list, saveOne, deleteOne, useCommentWithFile, useEmojiHtml, queryById, getGloablEmojiIndex } from './useComment';
@ -115,21 +116,21 @@
setup(props) {
const { createMessage } = useMessage();
const dataList = ref([]);
const { userInfo } = useUserStore();
const userStore = useUserStore();
const dayjs = inject('$dayjs')
const listRef = ref(null);
/**
* 获取当前用户名称
*/
function getMyname() {
if (userInfo.realname) {
return userInfo.realname.substr(0, 2);
if (userStore.getUserInfo.realname) {
return userStore.getUserInfo.realname.substr(0, 2);
}
return '';
}
function getMyAvatar(){
return getFileAccessHttpUrl(userInfo.avatar);
return getFileAccessHttpUrl(userStore.getUserInfo.avatar);
}
// 获取头像
@ -201,7 +202,7 @@
async function replyComment(item, content, fileList) {
console.log(content, item);
let obj = {
fromUserId: userInfo.id,
fromUserId: userStore.getUserInfo.id,
toUserId: item.fromUserId,
commentId: item.id,
commentContent: content
@ -213,7 +214,7 @@
//评论
async function sendComment(content, fileList) {
let obj = {
fromUserId: userInfo.id,
fromUserId: userStore.getUserInfo.id,
commentContent: content
}
await saveCommentAndFiles(obj, fileList)
@ -270,6 +271,7 @@
async function visibleChange(v, item){
if(v==true){
if(!item.commentId_dictText){
item.commentIdLoading = true;
const data = await queryById(item.commentId);
if(data.success == true){
item.commentId_dictText = data.result.commentContent
@ -277,11 +279,13 @@
console.error(data.message)
item.commentId_dictText='该评论已被删除';
}
item.commentIdLoading = false;
}
}
}
// 代码逻辑说明: 【TV360X-932】评论加上换行
const lineFeed = (content) => {
if (!content) return '';
return content.replace(/\n/g, '<br>');
};
@ -365,3 +369,14 @@
}
}
</style>
<style lang="less">
.jeecg-comment-tooltip-spin {
.ant-spin-dot-item {
background-color: rgba(255, 255, 255, 0.85);
}
.ant-spin-text {
color: rgba(255, 255, 255, 0.85);
}
}
</style>

View File

@ -9,8 +9,8 @@
<edit-outlined v-else/>
</span>
<span class="log-item-content">
<a @click="handleClickPerson">@{{item.createName || item.createBy}}</a>
{{ item.dataContent }}
<span @click="handleClickPerson">@{{item.createName || item.createBy}}</span>
{{ item.dataContent }}
</span>
<div class="log-item-date">
<Tooltip :title="item.createTime">
@ -158,6 +158,9 @@
width: 100%;
word-break: break-word;
box-sizing: border-box;
span {
color: #333;
}
}
.log-item-date{
word-wrap: break-word;

View File

@ -45,7 +45,7 @@
</template>
<script lang="ts">
import {ref, watch, computed, inject} from 'vue';
import { ref, watch, computed, nextTick } from 'vue';
import { propTypes } from '/@/utils/propTypes';
import { UserAddOutlined, PaperClipOutlined, SmileOutlined } from '@ant-design/icons-vue';
import { Tooltip } from 'ant-design-vue';
@ -130,7 +130,7 @@
emit('cancel');
}
const commentRef = ref();
const commentRef = ref<HTMLTextAreaElement | null>(null);
watch(
() => props.inputFocus,
(val) => {
@ -275,9 +275,14 @@
function handleBlur() {
showHtml.value = true;
// 代码逻辑说明: 解决多行获取焦点和失去焦点时滚动位置不一致
setTimeout(() => {
commentContentRef.value!.scrollTop = commentRef.value.scrollTop;
}, 0);
nextTick(() => {
const commentContentElement = commentContentRef.value;
const commentElement = commentRef.value;
if (!commentContentElement || !commentElement) {
return;
}
commentContentElement.scrollTop = commentElement.scrollTop;
});
}
const commentActive = ref(false);

View File

@ -17,7 +17,7 @@
<div class="complex">
<div class="content">
<!-- 图片 -->
<div v-if="isImage(item)" class="content-top" style="height: 100%">
<div v-if="isImage(item) && !item.exist" class="content-top" style="height: 100%">
<div class="content-image" :style="{'height':'100%', 'backgroundImage': 'url('+getImageSrc(item)+')'}">
<!-- <img style="height: 100%;" :src="getImageSrc(item)">-->
</div>
@ -25,7 +25,7 @@
<!-- 文件 -->
<template v-else>
<div class="content-top">
<div class="content-icon" :style="{ background: 'url(' + getBackground(item) + ') no-repeat' }"></div>
<div class="content-icon" :style="{ background: 'url(' + (isImage(item) ? getImageTypeIcon() : getBackground(item)) + ') no-repeat' }"></div>
</div>
<div class="content-bottom" :title="item.name">
<span>{{ item.name }}</span>
@ -78,7 +78,7 @@
},
emits:['select'],
setup(_p, {emit}) {
const { selectFileList, beforeUpload, handleRemove, getBackground, isImage, getImageSrc, viewImage } = useFileList();
const { selectFileList, beforeUpload, handleRemove, getBackground, isImage, getImageSrc, viewImage, getImageTypeIcon } = useFileList();
const [registerModel, { openModal }] = useModal();
@ -126,6 +126,7 @@
clear,
isImage,
getImageSrc,
getImageTypeIcon,
viewImage,
registerModel,
showFileModal,

View File

@ -17,6 +17,7 @@ import { createImgPreview } from '/@/components/Preview';
import data from "emoji-mart-vue-fast/data/apple.json";
import { EmojiIndex } from "emoji-mart-vue-fast/src";
import { encryptByBase64 } from '/@/utils/cipher';
import { sanitizeCommentHtml } from '/@/utils/htmlSanitizer';
enum Api {
list = '/sys/comment/listByForm',
@ -422,7 +423,7 @@ export function useEmojiHtml(globalEmojiIndex){
if(!text){
return ''
}
return text.replace(COLONS_REGEX, function (match, p1, p2) {
const emojiHtml = text.replace(COLONS_REGEX, function (match, p1, p2) {
const before = p1 || ''
if (endsWith(before, 'alt="') || endsWith(before, 'data-text="')) {
return match
@ -433,7 +434,7 @@ export function useEmojiHtml(globalEmojiIndex){
}
return before + emoji2Html(emoji)
})
return text;
return sanitizeCommentHtml(emojiHtml);
}
function endsWith(str, temp){

View File

@ -555,7 +555,7 @@
const [registerHighlightColorModal, { openModal: openHighlightColorModal }] = useModal();
function initEditor() {
setTimeout(() => {
setTimeout(async () => {
const fontDom: any = document.querySelector('.jeecg-menu-item-font');
fontOptionDom.value = fontDom.querySelector('.options');
//字体大小
@ -594,7 +594,13 @@
searchCollapseDom.value = document.querySelector('.jeecg-menu-item-search-collapse');
setTableStyle()
createEditor(props.content);
// update-begin--author:liaozhiyang---date:20270724---for:【LHZP-1452】修复word目录需要修改下正文才显示
await createEditor(props.content);
// 初始化完成后主动渲染一次目录,避免需要点击目录按钮才显示
if (formState.showCataLogDom) {
updateCatalog();
}
// update-end--author:liaozhiyang---date:20270724---for:【LHZP-1452】修复word目录需要修改下正文才显示
// 组件挂载后添加事件监听器
document.addEventListener('click', handleClickOutside);
//监听Ctrl或Cmd键和S键事件

View File

@ -56,7 +56,8 @@ export function useThirdLogin() {
bindingPhoneModal.value = true;
let strings = token.split(',');
thirdUserUuid.value = strings[1];
} else {
} else if (token.split('.').length === 3) {
// JWT 格式校验:必须包含两个 '.',过滤飞书 SDK 等第三方 postMessage(如 [tea-sdk]ready)
doThirdLogin(token);
}
} else if (typeof token === 'object') {

View File

@ -44,6 +44,16 @@ export function useDragNotice() {
console.log('仪表盘监听点击事件参数', record);
//1.路径的话,判断外部链接还是内部路由跳转
if (action == 'url') {
if (record?.type && record?.type === 'file') {
const fileUrl = getFileAccessHttpUrl(record.fileUrl);
const fileName = record.fileUrl?.split('/')?.pop()?.split('?')?.[0] || record.title;
downloadFile(fileUrl, fileName);
return;
}
if (record?.type && record.type === 'url') {
window.open(record.linkUrl, '_blank');
return;
}
//常用下載特殊处理
if (url == 'fileUrl') {
url = record[url];
@ -145,7 +155,7 @@ export function useDragNotice() {
const iframes: any = document.getElementsByClassName('jeecg-iframe-page__main');
// 将 HTMLCollection 转换为数组
const iframeArray = Array.from(iframes);
if (currentRoute.value?.meta?.frameSrc && currentRoute.value?.meta?.frameSrc.indexOf('/drag/view?pageId=') >= 0) {
if (currentRoute.value?.meta?.frameSrc && isDragFrameSrc(currentRoute.value.meta.frameSrc)) {
const targetIframe: any = iframeArray.find((iframe: any) => iframe.src == currentRoute.value?.meta?.frameSrc);
console.log('targetIframe', targetIframe);
if (targetIframe) {
@ -153,6 +163,16 @@ export function useDragNotice() {
}
}
}
/**
* 判断 frameSrc 是否为仪表盘页面
* 支持两种 URL 形式:
* 1. /drag/view?pageId=xxx(query 形式,老版本约定)
* 2. /drag/view/xxx(path 形式,新版本约定)
*/
function isDragFrameSrc(frameSrc: string): boolean {
return /\/(drag|jimubi)\/view(\?|\/|$)/.test(frameSrc || '');
}
// update-end--author:liusq---date:20260804---for:【仪表盘】从我的消息切回仪表盘 tab 时布局变化,原判断仅匹配 /drag/view?pageId= 形式,未覆盖 /drag/view/:id path 形式,导致 iframe 内仪表盘未刷新
return {
initDragWebSocket: initWebSocket,
handleOpenType,

View File

@ -6,6 +6,17 @@ import { validateCasLogin } from '/@/api/sys/user';
import { useUserStore } from '/@/store/modules/user';
const globSetting = useGlobSetting();
const openSso = globSetting.openSso;
/**
* 获取 CAS 登录回调地址,保留当前页面路径并移除 CAS 追加的 ticket 参数
* for: 【issues/9797】开启CAS验证,回跳地址错误
*/
export function getCasServiceUrl() {
const serviceUrl = new URL(window.location.href);
serviceUrl.searchParams.delete('ticket');
return serviceUrl.href;
}
export function useSso() {
// 代码逻辑说明: 【QQYUN-7805】SSO登录强制用http #957---
let locationUrl = document.location.protocol +"//" + window.location.host + '/';
@ -18,17 +29,18 @@ export function useSso() {
let token = getToken();
let ticket = getUrlParam('ticket');
if (!token) {
const loginServiceUrl = getCasServiceUrl();
if (ticket) {
await validateCasLogin({
ticket: ticket,
service: locationUrl,
service: loginServiceUrl,
}).then((res) => {
const userStore = useUserStore();
userStore.setToken(res.token);
return userStore.afterLoginAction(true, {});
});
} else {
window.location.href = globSetting.casBaseUrl + '/login?service=' + encodeURIComponent(locationUrl);
window.location.href = globSetting.casBaseUrl + '/login?service=' + encodeURIComponent(loginServiceUrl);
}
}
}

View File

@ -26,7 +26,7 @@
import { BasicForm, useForm } from '/@/components/Form';
import { useMessage } from '/@/hooks/web/useMessage';
import { useI18n } from '/@/hooks/web/useI18n';
import { rules } from '@/utils/helper/validator';
import { rules, createPasswordValidator } from '@/utils/helper/validator';
import { defHttp } from '@/utils/http/axios';
import { useUserStore } from '@/store/modules/user';
@ -36,27 +36,8 @@
const { createMessage } = useMessage();
const confirmLoading = ref(false);
const oldPassword = ref('');
const userStore = useUserStore();
// 判断密码是否为连续的数字或字母
function lxStr (password) {
let arr = password.split('');
let flag = true;
for (let i = 1; i < arr.length - 1; i++) {
let firstIndex = arr[i - 1].charCodeAt();
let secondIndex = arr[i].charCodeAt();
let thirdIndex = arr[i + 1].charCodeAt();
thirdIndex - secondIndex == 1;
secondIndex - firstIndex == 1;
if ((thirdIndex - secondIndex === 1) && (secondIndex - firstIndex === 1)) {
flag = false;
}
}
if (!flag) {
return flag
}
return flag
}
// 表单配置
const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({
labelWidth: 100,
@ -73,60 +54,11 @@
required: true,
message: t('layout.changePassword.pleaseEnterNewPassword'),
},
//update-begin---author:wangshuai ---date:2026-06-29 for:【QQYUN-16619】根据三级等保开关动态切换校验规则-----------
{
validator:(_, value)=>{
if(!value){
return Promise.resolve();
}
//不能使用系统密码作为新密码
if(value === oldPassword.value){
return Promise.reject('不能使用系统密码作为新密码!');
}
//密码由8位数字、大小写字母和特殊符号组成
let reg = /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[~!@#$%^&*()_+`\-={}:";'<>?,./]).{8,}$/;
if(!reg.test(value)){
return Promise.reject('密码由 8 位及以上数字、大小写字母和特殊符号组成!');
}
return Promise.resolve();
}
validator: createPasswordValidator(oldPassword.value),
},
/* {
// 使用一个函数来自定义验证逻辑,客户使用这个
validator: (_, value) => {
// 如果密码为空,直接返回 resolve() 让 required 规则去处理
if (!value) {
return Promise.resolve();
}
//不能使用系统密码作为新密码
if(value === oldPassword.value){
return Promise.reject('不能使用系统密码作为新密码!');
}
// 1. 长度不低于8位
if (value.length < 8) {
return Promise.reject('密码长度不能少于8位');
}
// 2. 必须包含数字、字母大小写、特殊字符
// 使用正则表达式进行匹配
const hasNumber = /[0-9]/.test(value);
const hasLowercase = /[a-z]/.test(value);
const hasUppercase = /[A-Z]/.test(value);
const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~`]/.test(value);
if (!(hasNumber && hasLowercase && hasUppercase && hasSpecialChar)) {
return Promise.reject('密码必须包含数字、大小写字母和特殊字符');
}
if(!lxStr(value)){
return Promise.reject('密码不能出现3位及3为以上的数字或字母');
}
//不能相同字符(如111、aaa)连续3位或3位以上
const reg2 = /(\w)*(\w)\2{2}(\w)*!/g
if (reg2.test(value)) {
console.log('密码不能出现相同字符连续3位或3位以上')
return false
}
// 如果所有规则都通过,返回 resolve()
return Promise.resolve();
}
}*/
//update-end---author:wangshuai ---date:2026-06-29 for:【QQYUN-16619】根据三级等保开关动态切换校验规则-----------
],
},
{
@ -149,7 +81,6 @@
});
const getTitle = computed(() => '修改密码');
const userStore = useUserStore();
const { createMessage: $message } = useMessage();

View File

@ -108,10 +108,13 @@
* 弹窗打开前处理
*/
async function show() {
//加载部门
await loadDepartList();
//加载租户
await loadTenantList();
// update-begin--author:liaozhiyang---date:20260812---for:【LHZP-1359】部门和租户任一有数据都需要展示出来,除非都没数据时则提示
const [canSwitchDepart, canSwitchTenant] = await Promise.all([loadDepartList(), loadTenantList()]);
if (!canSwitchDepart && !canSwitchTenant) {
createMessage.info('当前没有可切换的租户或部门');
return;
}
// update-end--author:liaozhiyang---date:20260812---for:【LHZP-1359】部门和租户任一有数据都需要展示出来,除非都没数据时则提示
//标题配置
if (unref(isMultiTenant) && unref(isMultiDepart)) {
currTitle.value = '切换租户和部门';
@ -132,18 +135,20 @@
*/
async function loadDepartList() {
const result = await getUserDeparts();
if (!result.list || result.list.length == 0) {
return;
// update-begin--author:liaozhiyang---date:20260812---for:【LHZP-1359】部门和租户任一有数据都需要展示出来,除非都没数据时则提示
const userDeparts = Array.isArray(result.list) ? result.list.filter((item) => item.orgCategory == '2') : [];
if (userDeparts.length == 0) {
return false;
}
let currentDepart = result.list.filter((item) => item.orgCode == result.orgCode);
//TODO 筛选出用户的部门信息(排除公司或者岗位配置)后期回滚
const userDeparts = result.list.filter((item) => item.orgCategory == '2');
// update-end--author:liaozhiyang---date:20260812---for:【LHZP-1359】部门和租户任一有数据都需要展示出来,除非都没数据时则提示
let currentDepart = userDeparts.filter((item) => item.orgCode == result.orgCode);
departList.value = userDeparts;
// 代码逻辑说明: JHHB-790 用户部门变更,会出现这个情况(因为之前设置的这里只切换部门,过滤了公司和岗位信息)
const hasCurrentDepart = userDeparts.some(item => item.orgCode == result.orgCode);
departSelected.value = hasCurrentDepart && currentDepart && currentDepart.length > 0 ? result.orgCode : '';
currentDepartName.value = currentDepart && currentDepart.length > 0 ? currentDepart[0].departName : '';
isMultiDepart.value = true;
return true;
}
/**
*加载租户信息
@ -151,7 +156,7 @@
async function loadTenantList() {
const result = await getUserTenants();
if (!result.list || result.list.length == 0) {
return;
return false;
}
let tenantId = userStore.getTenant;
let currentTenant = result.list.filter((item) => item.id == tenantId);
@ -159,6 +164,7 @@
tenantList.value = result.list;
tenantSelected.value = tenantId;
isMultiTenant.value = true;
return true;
}
/**
@ -198,23 +204,24 @@
*/
function departResolve() {
return new Promise(async (resolve, reject) => {
if (!unref(isMultiDepart) || unref(departList).length == 0) {
const hasDepart = unref(isMultiDepart) && unref(departList).length > 0;
const hasTenant = unref(isMultiTenant);
if (!hasDepart && !hasTenant) {
resolve();
return;
}
const result = await selectDepart({
username: userStore.getUserInfo.username,
orgCode: hasDepart ? unref(departSelected) : undefined,
loginTenantId: hasTenant ? unref(tenantSelected) : undefined,
});
if (result.userInfo) {
userStore.setUserInfo(result.userInfo);
resolve();
} else {
const result = await selectDepart({
username: userStore.getUserInfo.username,
orgCode: unref(departSelected),
loginTenantId: unref(tenantSelected),
});
if (result.userInfo) {
const userInfo = result.userInfo;
userStore.setUserInfo(userInfo);
resolve();
} else {
requestFailed(result);
userStore.logout();
reject();
}
requestFailed(result);
userStore.logout();
reject();
}
});
}

View File

@ -5,7 +5,7 @@
</template>
<script lang="ts" setup>
import { ref, unref } from 'vue';
import { rules } from '/@/utils/helper/validator';
import { rules, createPasswordValidator } from '/@/utils/helper/validator';
import { defHttp } from '/@/utils/http/axios';
import { BasicModal, useModalInner } from '/@/components/Modal';
import BasicForm from '/@/components/Form/src/BasicForm.vue';
@ -43,10 +43,11 @@
required: true,
message: t('layout.changePassword.pleaseEnterNewPassword'),
},
//update-begin---author:wangshuai ---date:2026-06-29 for:【QQYUN-16619】根据三级等保开关动态切换校验规则-----------
{
pattern: /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[~!@#$%^&*()_+`\-={}:";'<>?,./]).{8,}$/,
message: '密码由 8 位及以上数字、大小写字母和特殊符号组成!',
},
validator: createPasswordValidator(),
},
//update-end---author:wangshuai ---date:2026-06-29 for:【QQYUN-16619】根据三级等保开关动态切换校验规则-----------
],
},
{

View File

@ -57,7 +57,7 @@
import { removeAuthCache, setAuthCache } from '/src/utils/auth';
import { getFileAccessHttpUrl } from '/@/utils/common/compUtils';
import { getRefPromise } from '/@/utils/index';
import { refreshDragCache } from "@/api/common/api";
import { refreshDragCache, refreshHomeCache } from "@/api/common/api";
type MenuEvent = 'logout' | 'doc' | 'lock' | 'cache' | 'depart' | 'defaultHomePage' | 'password' | 'account';
const { createMessage } = useMessage();
@ -124,6 +124,8 @@
const result = await refreshCache();
const dragRes = await refreshDragCache();
console.log('dragRes', dragRes);
await refreshHomeCache();
await userStore.getUserInfoAction();
if (result.success) {
const res = await queryAllDictItems();
removeAuthCache(DB_DICT_DATA_KEY);

View File

@ -11,6 +11,7 @@ export default {
inputText: 'Please enter',
chooseText: 'Please choose',
uploadText: 'Please upload',
redo: 'Refresh',
back: 'Back',

View File

@ -11,6 +11,7 @@ export default {
inputText: '请输入',
chooseText: '请选择',
uploadText: '请上传',
redo: '刷新',
back: '返回',

View File

@ -128,7 +128,7 @@ function dynamicImport(dynamicViewsModules: Record<string, () => Promise<Recorda
return;
}
// online/aiflow 本地未找到,尝试从懒加载包中按需加载
if (component.startsWith('/super/airag/aiflow')) {
if (component.startsWith('/super/airag/aiflow/')) {
return () => {
return loadPackageComponent(component).then((factory) => (factory ? factory() : Promise.reject(`组件 ${component} 未找到`)));
};

View File

@ -7,6 +7,21 @@ import {createRouter as createVueRouter, destroyRouter, router} from './router'
// 白名单应该包含基本静态路由
const WHITE_NAME_LIST: string[] = [];
// Online 路由在应用启动时注册,退出登录重置权限路由时需要保留,否则重新登录后会命中 404。
const ONLINE_ROUTE_NAMES = [
'onl-auto-cgform-router',
'OnlineAutoList',
'DefaultOnlineList',
'CgformErpList',
'OnlCgformInnerTableList',
'OnlCgformTabList',
'onl-auto-cgreport-router',
'OnlCgReportList',
'onl-auto-graphreport-router',
'GraphreportAutoChart',
'onl-auto-desform-router',
'AutoDesformDataList'
];
const getRouteNames = (array: any[]) =>
array.forEach((item) => {
WHITE_NAME_LIST.push(item.name);
@ -40,7 +55,7 @@ export function createRouter() {
export function resetRouter() {
router.getRoutes().forEach((route) => {
const { name } = route;
if (name && !WHITE_NAME_LIST.includes(name as string)) {
if (name && !WHITE_NAME_LIST.includes(name as string) && !ONLINE_ROUTE_NAMES.includes(name as string)) {
router.hasRoute(name) && router.removeRoute(name);
}
});

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