提交新版本
This commit is contained in:
41
packages/@core/base/design/package.json
Normal file
41
packages/@core/base/design/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@vben-core/design",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/base/design"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm vite build",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
"./bem": {
|
||||
"development": "./src/scss-bem/bem.scss",
|
||||
"default": "./dist/bem.scss"
|
||||
},
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/design.css"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
101
packages/@core/base/design/src/css/ui.css
Normal file
101
packages/@core/base/design/src/css/ui.css
Normal file
@@ -0,0 +1,101 @@
|
||||
.side-content {
|
||||
animation-duration: 0.3s;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.side-content[data-side='top'] {
|
||||
animation-name: slide-up;
|
||||
}
|
||||
|
||||
.side-content[data-side='bottom'] {
|
||||
animation-name: slide-down;
|
||||
}
|
||||
|
||||
.side-content[data-side='left'] {
|
||||
animation-name: slide-left;
|
||||
}
|
||||
|
||||
.side-content[data-side='right'] {
|
||||
animation-name: slide-right;
|
||||
}
|
||||
|
||||
.breadcrumb-transition-enter-active {
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.76, 0, 0.24, 1),
|
||||
opacity 0.4s cubic-bezier(0.76, 0, 0.24, 1);
|
||||
}
|
||||
|
||||
.breadcrumb-transition-leave-active {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.breadcrumb-transition-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px) skewX(-30deg);
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(50px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(50px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.z-popup {
|
||||
z-index: var(--popup-z-index);
|
||||
}
|
||||
|
||||
@keyframes shrink {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
2
packages/@core/base/design/src/design-tokens/index.ts
Normal file
2
packages/@core/base/design/src/design-tokens/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import './default.css';
|
||||
import './dark.css';
|
||||
6
packages/@core/base/design/src/index.ts
Normal file
6
packages/@core/base/design/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import './design-tokens';
|
||||
|
||||
import './css/global.css';
|
||||
import './css/transition.css';
|
||||
import './css/nprogress.css';
|
||||
import './css/ui.css';
|
||||
41
packages/@core/base/icons/package.json
Normal file
41
packages/@core/base/icons/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@vben-core/icons",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/base/icons"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/vue": "catalog:",
|
||||
"lucide-vue-next": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
76
packages/@core/base/icons/src/lucide.ts
Normal file
76
packages/@core/base/icons/src/lucide.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowLeftToLine,
|
||||
ArrowRightLeft,
|
||||
ArrowRightToLine,
|
||||
ArrowUp,
|
||||
ArrowUpToLine,
|
||||
Bell,
|
||||
BookOpenText,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Circle,
|
||||
CircleAlert,
|
||||
CircleCheckBig,
|
||||
CircleHelp,
|
||||
CircleX,
|
||||
CloudUpload,
|
||||
Copy,
|
||||
CornerDownLeft,
|
||||
Download,
|
||||
Ellipsis,
|
||||
Expand,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FoldHorizontal,
|
||||
Fullscreen,
|
||||
Github,
|
||||
Grip,
|
||||
GripVertical,
|
||||
History,
|
||||
Menu as IconDefault,
|
||||
Inbox,
|
||||
Info,
|
||||
InspectionPanel,
|
||||
Languages,
|
||||
LoaderCircle,
|
||||
LockKeyhole,
|
||||
LogOut,
|
||||
MailCheck,
|
||||
Maximize,
|
||||
ArrowRightFromLine as MdiMenuClose,
|
||||
ArrowLeftFromLine as MdiMenuOpen,
|
||||
Menu,
|
||||
Minimize,
|
||||
Minimize2,
|
||||
MoonStar,
|
||||
Palette,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
Search,
|
||||
SearchX,
|
||||
Settings,
|
||||
ShieldQuestion,
|
||||
Shrink,
|
||||
Square,
|
||||
SquareCheckBig,
|
||||
SquareMinus,
|
||||
Sun,
|
||||
SunMoon,
|
||||
SwatchBook,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserRoundPen,
|
||||
X,
|
||||
} from 'lucide-vue-next';
|
||||
104
packages/@core/base/shared/package.json
Normal file
104
packages/@core/base/shared/package.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"name": "@vben-core/shared",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/base/shared"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"stub": "pnpm unbuild --stub"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
"./constants": {
|
||||
"types": "./src/constants/index.ts",
|
||||
"development": "./src/constants/index.ts",
|
||||
"default": "./dist/constants/index.mjs"
|
||||
},
|
||||
"./utils": {
|
||||
"types": "./src/utils/index.ts",
|
||||
"development": "./src/utils/index.ts",
|
||||
"default": "./dist/utils/index.mjs"
|
||||
},
|
||||
"./color": {
|
||||
"types": "./src/color/index.ts",
|
||||
"development": "./src/color/index.ts",
|
||||
"default": "./dist/color/index.mjs"
|
||||
},
|
||||
"./cache": {
|
||||
"types": "./src/cache/index.ts",
|
||||
"development": "./src/cache/index.ts",
|
||||
"default": "./dist/cache/index.mjs"
|
||||
},
|
||||
"./store": {
|
||||
"types": "./src/store.ts",
|
||||
"development": "./src/store.ts",
|
||||
"default": "./dist/store.mjs"
|
||||
},
|
||||
"./global-state": {
|
||||
"types": "./src/global-state.ts",
|
||||
"development": "./src/global-state.ts",
|
||||
"default": "./dist/global-state.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
"./constants": {
|
||||
"types": "./dist/constants/index.d.ts",
|
||||
"default": "./dist/constants/index.mjs"
|
||||
},
|
||||
"./utils": {
|
||||
"types": "./dist/utils/index.d.ts",
|
||||
"default": "./dist/utils/index.mjs"
|
||||
},
|
||||
"./color": {
|
||||
"types": "./dist/color/index.d.ts",
|
||||
"default": "./dist/color/index.mjs"
|
||||
},
|
||||
"./cache": {
|
||||
"types": "./dist/cache/index.d.ts",
|
||||
"default": "./dist/cache/index.mjs"
|
||||
},
|
||||
"./store": {
|
||||
"types": "./dist/store.d.ts",
|
||||
"default": "./dist/store.mjs"
|
||||
},
|
||||
"./global-state": {
|
||||
"types": "./dist/global-state.d.ts",
|
||||
"default": "./dist/global-state.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@ctrl/tinycolor": "catalog:",
|
||||
"@tanstack/vue-store": "catalog:",
|
||||
"@vue/shared": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"crypto-js": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"defu": "catalog:",
|
||||
"es-toolkit": "catalog:",
|
||||
"jsencrypt": "catalog:",
|
||||
"lodash.clonedeep": "catalog:",
|
||||
"nprogress": "catalog:",
|
||||
"tailwind-merge": "catalog:",
|
||||
"theme-colors": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/crypto-js": "catalog:",
|
||||
"@types/lodash.clonedeep": "catalog:",
|
||||
"@types/lodash.get": "catalog:",
|
||||
"@types/lodash.isequal": "catalog:",
|
||||
"@types/lodash.set": "catalog:",
|
||||
"@types/nprogress": "catalog:"
|
||||
}
|
||||
}
|
||||
31
packages/@core/base/shared/src/constants/vben.ts
Normal file
31
packages/@core/base/shared/src/constants/vben.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @zh_CN GITHUB 仓库地址
|
||||
*/
|
||||
// export const VBEN_GITHUB_URL = 'https://github.com/vbenjs/vue-vben-admin';
|
||||
export const VBEN_GITHUB_URL =
|
||||
'https://github.com/yudaocode/yudao-ui-admin-vben';
|
||||
|
||||
/**
|
||||
* @zh_CN 文档地址
|
||||
*/
|
||||
// export const VBEN_DOC_URL = 'https://doc.vben.pro';
|
||||
export const VBEN_DOC_URL = 'https://doc.iocoder.cn/';
|
||||
|
||||
/**
|
||||
* @zh_CN Vben Logo
|
||||
*/
|
||||
export const VBEN_LOGO_URL =
|
||||
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp';
|
||||
|
||||
/**
|
||||
* @zh_CN Vben Admin 首页地址
|
||||
*/
|
||||
export const VBEN_PREVIEW_URL = 'https://www.vben.pro';
|
||||
|
||||
export const VBEN_ELE_PREVIEW_URL = 'https://ele.vben.pro';
|
||||
|
||||
export const VBEN_NAIVE_PREVIEW_URL = 'https://naive.vben.pro';
|
||||
|
||||
export const VBEN_ANT_PREVIEW_URL = 'https://ant.vben.pro';
|
||||
|
||||
export const VBEN_TD_PREVIEW_URL = 'https://tdesign.vben.pro';
|
||||
143
packages/@core/base/shared/src/utils/__tests__/date.test.ts
Normal file
143
packages/@core/base/shared/src/utils/__tests__/date.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import dayjs from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
getCurrentTimezone,
|
||||
getSystemTimezone,
|
||||
isDate,
|
||||
isDayjsObject,
|
||||
setCurrentTimezone,
|
||||
} from '../date';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
describe('dateUtils', () => {
|
||||
const sampleISO = '2024-10-30T12:34:56Z';
|
||||
const sampleTimestamp = Date.parse(sampleISO);
|
||||
|
||||
beforeEach(() => {
|
||||
// 重置时区
|
||||
dayjs.tz.setDefault();
|
||||
setCurrentTimezone(); // 重置为系统默认
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// formatDate
|
||||
// ===============================
|
||||
describe('formatDate', () => {
|
||||
it('should format a valid ISO date string', () => {
|
||||
const formatted = formatDate(sampleISO, 'YYYY/MM/DD');
|
||||
expect(formatted).toMatch(/2024\/10\/30/);
|
||||
});
|
||||
|
||||
it('should format a timestamp correctly', () => {
|
||||
const formatted = formatDate(sampleTimestamp);
|
||||
expect(formatted).toMatch(/2024-10-30/);
|
||||
});
|
||||
|
||||
it('should format a Date object', () => {
|
||||
const formatted = formatDate(new Date(sampleISO));
|
||||
expect(formatted).toMatch(/2024-10-30/);
|
||||
});
|
||||
|
||||
it('should format a dayjs object', () => {
|
||||
const formatted = formatDate(dayjs(sampleISO));
|
||||
expect(formatted).toMatch(/2024-10-30/);
|
||||
});
|
||||
|
||||
it('should return original input if date is invalid', () => {
|
||||
const invalid = 'not-a-date';
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const formatted = formatDate(invalid);
|
||||
expect(formatted).toBe(invalid);
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should apply given format', () => {
|
||||
const formatted = formatDate(sampleISO, 'YYYY-MM-DD HH:mm');
|
||||
expect(formatted).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/);
|
||||
});
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// formatDateTime
|
||||
// ===============================
|
||||
describe('formatDateTime', () => {
|
||||
it('should format date into full datetime', () => {
|
||||
const result = formatDateTime(sampleISO);
|
||||
expect(result).toMatch(/2024-10-30 \d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// isDate
|
||||
// ===============================
|
||||
describe('isDate', () => {
|
||||
it('should return true for Date instances', () => {
|
||||
expect(isDate(new Date())).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-Date values', () => {
|
||||
expect(isDate('2024-10-30')).toBe(false);
|
||||
expect(isDate(null)).toBe(false);
|
||||
expect(isDate(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// isDayjsObject
|
||||
// ===============================
|
||||
describe('isDayjsObject', () => {
|
||||
it('should return true for dayjs objects', () => {
|
||||
expect(isDayjsObject(dayjs())).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other values', () => {
|
||||
expect(isDayjsObject(new Date())).toBe(false);
|
||||
expect(isDayjsObject('string')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// getSystemTimezone
|
||||
// ===============================
|
||||
describe('getSystemTimezone', () => {
|
||||
it('should return a valid IANA timezone string', () => {
|
||||
const tz = getSystemTimezone();
|
||||
expect(typeof tz).toBe('string');
|
||||
expect(tz).toMatch(/^[A-Z]+\/[A-Z_]+/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// setCurrentTimezone / getCurrentTimezone
|
||||
// ===============================
|
||||
describe('setCurrentTimezone & getCurrentTimezone', () => {
|
||||
it('should set and retrieve the current timezone', () => {
|
||||
setCurrentTimezone('Asia/Shanghai');
|
||||
expect(getCurrentTimezone()).toBe('Asia/Shanghai');
|
||||
});
|
||||
|
||||
it('should reset to system timezone when called with no args', () => {
|
||||
const guessed = getSystemTimezone();
|
||||
setCurrentTimezone();
|
||||
expect(getCurrentTimezone()).toBe(guessed);
|
||||
});
|
||||
|
||||
it('should update dayjs default timezone', () => {
|
||||
setCurrentTimezone('America/New_York');
|
||||
const d = dayjs('2024-01-01T00:00:00Z');
|
||||
// 校验时区转换生效(小时变化)
|
||||
expect(d.tz().format('HH')).not.toBe('00');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { loadScript } from '../resources';
|
||||
|
||||
const testJsPath =
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js';
|
||||
|
||||
describe('loadScript', () => {
|
||||
beforeEach(() => {
|
||||
// 每个测试前清空 head,保证环境干净
|
||||
document.head.innerHTML = '';
|
||||
});
|
||||
|
||||
it('should resolve when the script loads successfully', async () => {
|
||||
const promise = loadScript(testJsPath);
|
||||
|
||||
// 此时脚本元素已被创建并插入
|
||||
const script = document.querySelector(
|
||||
`script[src="${testJsPath}"]`,
|
||||
) as HTMLScriptElement;
|
||||
expect(script).toBeTruthy();
|
||||
|
||||
// 模拟加载成功
|
||||
script.dispatchEvent(new Event('load'));
|
||||
|
||||
// 等待 promise resolve
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not insert duplicate script and resolve immediately if already loaded', async () => {
|
||||
// 先手动插入一个相同 src 的 script
|
||||
const existing = document.createElement('script');
|
||||
existing.src = 'bar.js';
|
||||
document.head.append(existing);
|
||||
|
||||
// 再次调用
|
||||
const promise = loadScript('bar.js');
|
||||
|
||||
// 立即 resolve
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
|
||||
// head 中只保留一个
|
||||
const scripts = document.head.querySelectorAll('script[src="bar.js"]');
|
||||
expect(scripts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should reject when the script fails to load', async () => {
|
||||
const promise = loadScript('error.js');
|
||||
|
||||
const script = document.querySelector(
|
||||
'script[src="error.js"]',
|
||||
) as HTMLScriptElement;
|
||||
expect(script).toBeTruthy();
|
||||
|
||||
// 模拟加载失败
|
||||
script.dispatchEvent(new Event('error'));
|
||||
|
||||
await expect(promise).rejects.toThrow('Failed to load script: error.js');
|
||||
});
|
||||
|
||||
it('should handle multiple concurrent calls and only insert one script tag', async () => {
|
||||
const p1 = loadScript(testJsPath);
|
||||
const p2 = loadScript(testJsPath);
|
||||
|
||||
const script = document.querySelector(
|
||||
`script[src="${testJsPath}"]`,
|
||||
) as HTMLScriptElement;
|
||||
expect(script).toBeTruthy();
|
||||
|
||||
// 触发一次 load,两个 promise 都应该 resolve
|
||||
script.dispatchEvent(new Event('load'));
|
||||
|
||||
await expect(p1).resolves.toBeUndefined();
|
||||
await expect(p2).resolves.toBeUndefined();
|
||||
|
||||
// 只插入一次
|
||||
const scripts = document.head.querySelectorAll(
|
||||
`script[src="${testJsPath}"]`,
|
||||
);
|
||||
expect(scripts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
99
packages/@core/base/shared/src/utils/date.ts
Normal file
99
packages/@core/base/shared/src/utils/date.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import dayjs from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
type FormatDate = Date | dayjs.Dayjs | number | string;
|
||||
|
||||
type Format =
|
||||
| 'HH'
|
||||
| 'HH:mm'
|
||||
| 'HH:mm:ss'
|
||||
| 'YYYY'
|
||||
| 'YYYY-MM'
|
||||
| 'YYYY-MM-DD'
|
||||
| 'YYYY-MM-DD HH'
|
||||
| 'YYYY-MM-DD HH:mm'
|
||||
| 'YYYY-MM-DD HH:mm:ss'
|
||||
| (string & {});
|
||||
|
||||
export function formatDate(time?: FormatDate, format: Format = 'YYYY-MM-DD') {
|
||||
// 日期不存在,则返回空
|
||||
if (!time) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const date = dayjs.isDayjs(time) ? time : dayjs(time);
|
||||
if (!date.isValid()) {
|
||||
throw new Error('Invalid date');
|
||||
}
|
||||
return date.tz().format(format);
|
||||
} catch (error) {
|
||||
console.error(`Error formatting date: ${error}`);
|
||||
return String(time ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateTime(time?: FormatDate) {
|
||||
return formatDate(time, 'YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
export function formatDate2(date: Date, format?: string): string {
|
||||
// 日期不存在,则返回空
|
||||
if (!date) {
|
||||
return '';
|
||||
}
|
||||
// 日期存在,则进行格式化
|
||||
return date ? dayjs(date).format(format ?? 'YYYY-MM-DD HH:mm:ss') : '';
|
||||
}
|
||||
|
||||
export function isDate(value: any): value is Date {
|
||||
return value instanceof Date;
|
||||
}
|
||||
|
||||
export function isDayjsObject(value: any): value is dayjs.Dayjs {
|
||||
return dayjs.isDayjs(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* element plus 的时间 Formatter 实现,使用 YYYY-MM-DD HH:mm:ss 格式
|
||||
*
|
||||
* @param _row
|
||||
* @param _column
|
||||
* @param cellValue 字段值
|
||||
*/
|
||||
export function dateFormatter(_row: any, _column: any, cellValue: any): string {
|
||||
return cellValue ? formatDate(cellValue)?.toString() || '' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时区
|
||||
* @returns 当前时区
|
||||
*/
|
||||
export const getSystemTimezone = () => {
|
||||
return dayjs.tz.guess();
|
||||
};
|
||||
|
||||
/**
|
||||
* 自定义设置的时区
|
||||
*/
|
||||
let currentTimezone = getSystemTimezone();
|
||||
|
||||
/**
|
||||
* 设置默认时区
|
||||
* @param timezone
|
||||
*/
|
||||
export const setCurrentTimezone = (timezone?: string) => {
|
||||
currentTimezone = timezone || getSystemTimezone();
|
||||
dayjs.tz.setDefault(currentTimezone);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取设置的时区
|
||||
* @returns 设置的时区
|
||||
*/
|
||||
export const getCurrentTimezone = () => {
|
||||
return currentTimezone;
|
||||
};
|
||||
267
packages/@core/base/shared/src/utils/encrypt.ts
Normal file
267
packages/@core/base/shared/src/utils/encrypt.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import CryptoJS from 'crypto-js';
|
||||
import { JSEncrypt } from 'jsencrypt';
|
||||
|
||||
/**
|
||||
* API 加解密工具类
|
||||
* 支持 AES 和 RSA 加密算法
|
||||
*/
|
||||
|
||||
/**
|
||||
* AES 加密工具类
|
||||
*/
|
||||
export const AES = {
|
||||
/**
|
||||
* AES 加密
|
||||
* @param data 要加密的数据
|
||||
* @param key 加密密钥
|
||||
* @returns 加密后的字符串
|
||||
*/
|
||||
encrypt(data: string, key: string): string {
|
||||
try {
|
||||
if (!key) {
|
||||
throw new Error('AES 加密密钥不能为空');
|
||||
}
|
||||
if (key.length !== 32 && key.length !== 16) {
|
||||
throw new Error(
|
||||
`AES 加密密钥长度必须为 32 位或 16 位,当前长度: ${key.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const keyUtf8 = CryptoJS.enc.Utf8.parse(key);
|
||||
const encrypted = CryptoJS.AES.encrypt(data, keyUtf8, {
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7,
|
||||
});
|
||||
return encrypted.toString();
|
||||
} catch (error) {
|
||||
console.error('AES 加密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* AES 解密
|
||||
* @param encryptedData 加密的数据
|
||||
* @param key 解密密钥
|
||||
* @returns 解密后的字符串
|
||||
*/
|
||||
decrypt(encryptedData: string, key: string): string {
|
||||
try {
|
||||
if (!key) {
|
||||
throw new Error('AES 解密密钥不能为空');
|
||||
}
|
||||
if (key.length !== 32) {
|
||||
throw new Error(
|
||||
`AES 解密密钥长度必须为 32 位,当前长度: ${key.length}`,
|
||||
);
|
||||
}
|
||||
if (!encryptedData) {
|
||||
throw new Error('AES 解密数据不能为空');
|
||||
}
|
||||
|
||||
const keyUtf8 = CryptoJS.enc.Utf8.parse(key);
|
||||
const decrypted = CryptoJS.AES.decrypt(encryptedData, keyUtf8, {
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7,
|
||||
});
|
||||
const result = decrypted.toString(CryptoJS.enc.Utf8);
|
||||
if (!result) {
|
||||
throw new Error('AES 解密结果为空,可能是密钥错误或数据损坏');
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('AES 解密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* RSA 加密工具类
|
||||
*/
|
||||
export const RSA = {
|
||||
/**
|
||||
* RSA 加密
|
||||
* @param data 要加密的数据
|
||||
* @param publicKey 公钥(必需)
|
||||
* @returns 加密后的字符串
|
||||
*/
|
||||
encrypt(data: string, publicKey: string): false | string {
|
||||
try {
|
||||
if (!publicKey) {
|
||||
throw new Error('RSA 公钥不能为空');
|
||||
}
|
||||
|
||||
const encryptor = new JSEncrypt();
|
||||
encryptor.setPublicKey(publicKey);
|
||||
const result = encryptor.encrypt(data);
|
||||
if (result === false) {
|
||||
throw new Error('RSA 加密失败,可能是公钥格式错误或数据过长');
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('RSA 加密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* RSA 解密
|
||||
* @param encryptedData 加密的数据
|
||||
* @param privateKey 私钥(必需)
|
||||
* @returns 解密后的字符串
|
||||
*/
|
||||
decrypt(encryptedData: string, privateKey: string): false | string {
|
||||
try {
|
||||
if (!privateKey) {
|
||||
throw new Error('RSA 私钥不能为空');
|
||||
}
|
||||
if (!encryptedData) {
|
||||
throw new Error('RSA 解密数据不能为空');
|
||||
}
|
||||
|
||||
const encryptor = new JSEncrypt();
|
||||
encryptor.setPrivateKey(privateKey);
|
||||
const result = encryptor.decrypt(encryptedData);
|
||||
if (result === false) {
|
||||
throw new Error('RSA 解密失败,可能是私钥错误或数据损坏');
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('RSA 解密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* API 加解密配置接口
|
||||
*/
|
||||
export interface ApiEncryptConfig {
|
||||
/** 加密算法 */
|
||||
algorithm: 'AES' | 'RSA';
|
||||
/** 是否启用加解密 */
|
||||
enable: boolean;
|
||||
/** 加密头名称 */
|
||||
header: string;
|
||||
/** 请求加密密钥(AES密钥或RSA公钥) */
|
||||
requestKey: string;
|
||||
/** 响应解密密钥(AES密钥或RSA私钥) */
|
||||
responseKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 加解密主类
|
||||
*/
|
||||
export class ApiEncrypt {
|
||||
private config: ApiEncryptConfig;
|
||||
|
||||
constructor(config: ApiEncryptConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密响应数据
|
||||
* @param encryptedData 加密的响应数据
|
||||
* @returns 解密后的数据
|
||||
*/
|
||||
decryptResponse(encryptedData: string): any {
|
||||
if (!this.config.enable) {
|
||||
return encryptedData;
|
||||
}
|
||||
|
||||
try {
|
||||
let decryptedData: false | string = '';
|
||||
if (this.config.algorithm.toUpperCase() === 'AES') {
|
||||
if (!this.config.responseKey) {
|
||||
throw new Error('AES 响应解密密钥未配置');
|
||||
}
|
||||
decryptedData = AES.decrypt(encryptedData, this.config.responseKey);
|
||||
} else if (this.config.algorithm.toUpperCase() === 'RSA') {
|
||||
if (!this.config.responseKey) {
|
||||
throw new Error('RSA 私钥未配置');
|
||||
}
|
||||
decryptedData = RSA.decrypt(encryptedData, this.config.responseKey);
|
||||
if (decryptedData === false) {
|
||||
throw new Error('RSA 解密失败');
|
||||
}
|
||||
} else {
|
||||
throw new Error(`不支持的解密算法: ${this.config.algorithm}`);
|
||||
}
|
||||
|
||||
if (!decryptedData) {
|
||||
throw new Error('解密结果为空');
|
||||
}
|
||||
|
||||
// 尝试解析为 JSON,如果失败则返回原字符串
|
||||
try {
|
||||
return JSON.parse(decryptedData);
|
||||
} catch {
|
||||
return decryptedData;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('响应数据解密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密请求数据
|
||||
* @param data 要加密的数据
|
||||
* @returns 加密后的数据
|
||||
*/
|
||||
encryptRequest(data: any): string {
|
||||
if (!this.config.enable) {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonData = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
|
||||
if (this.config.algorithm.toUpperCase() === 'AES') {
|
||||
if (!this.config.requestKey) {
|
||||
throw new Error('AES 请求加密密钥未配置');
|
||||
}
|
||||
return AES.encrypt(jsonData, this.config.requestKey);
|
||||
} else if (this.config.algorithm.toUpperCase() === 'RSA') {
|
||||
if (!this.config.requestKey) {
|
||||
throw new Error('RSA 公钥未配置');
|
||||
}
|
||||
const result = RSA.encrypt(jsonData, this.config.requestKey);
|
||||
if (result === false) {
|
||||
throw new Error('RSA 加密失败');
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
throw new Error(`不支持的加密算法: ${this.config.algorithm}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('请求数据加密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取加密头名称
|
||||
*/
|
||||
getEncryptHeader(): string {
|
||||
return this.config.header;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建基于环境变量的 API 加解密实例
|
||||
* @param env 环境变量对象
|
||||
* @returns ApiEncrypt 实例
|
||||
*/
|
||||
export function createApiEncrypt(env: Record<string, any>): ApiEncrypt {
|
||||
const config: ApiEncryptConfig = {
|
||||
enable: env.VITE_APP_API_ENCRYPT_ENABLE === 'true',
|
||||
header: env.VITE_APP_API_ENCRYPT_HEADER || 'X-Api-Encrypt',
|
||||
algorithm: env.VITE_APP_API_ENCRYPT_ALGORITHM || 'AES',
|
||||
requestKey: env.VITE_APP_API_ENCRYPT_REQUEST_KEY || '',
|
||||
responseKey: env.VITE_APP_API_ENCRYPT_RESPONSE_KEY || '',
|
||||
};
|
||||
|
||||
return new ApiEncrypt(config);
|
||||
}
|
||||
199
packages/@core/base/shared/src/utils/formatNumber.ts
Normal file
199
packages/@core/base/shared/src/utils/formatNumber.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { isEmpty, isString, isUndefined } from './inference';
|
||||
|
||||
/**
|
||||
* 将一个整数转换为分数保留传入的小数
|
||||
* @param num
|
||||
* @param digit
|
||||
*/
|
||||
export function formatToFractionDigit(
|
||||
num: number | string | undefined,
|
||||
digit: number = 2,
|
||||
): string {
|
||||
if (isUndefined(num)) return '0.00';
|
||||
const parsedNumber = isString(num) ? Number.parseFloat(num) : num;
|
||||
return (parsedNumber / 100).toFixed(digit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一个整数转换为分数保留两位小数
|
||||
* @param num
|
||||
*/
|
||||
export function formatToFraction(num: number | string | undefined): string {
|
||||
return formatToFractionDigit(num, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一个数转换为 1.00 这样
|
||||
* 数据呈现的时候使用
|
||||
*
|
||||
* @param num 整数
|
||||
*/
|
||||
export function floatToFixed2(num: number | string | undefined): string {
|
||||
let str = '0.00';
|
||||
if (isUndefined(num)) return str;
|
||||
const f = formatToFraction(num);
|
||||
const decimalPart = f.toString().split('.')[1];
|
||||
const len = decimalPart ? decimalPart.length : 0;
|
||||
switch (len) {
|
||||
case 0: {
|
||||
str = `${f.toString()}.00`;
|
||||
break;
|
||||
}
|
||||
case 1: {
|
||||
str = `${f.toString()}0`;
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
str = f.toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一个分数转换为整数
|
||||
* @param num
|
||||
*/
|
||||
export function convertToInteger(num: number | string | undefined): number {
|
||||
if (isUndefined(num)) return 0;
|
||||
const parsedNumber = isString(num) ? Number.parseFloat(num) : num;
|
||||
return Math.round(parsedNumber * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 元转分
|
||||
*/
|
||||
export function yuanToFen(amount: number | string): number {
|
||||
return convertToInteger(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分转元
|
||||
*/
|
||||
export function fenToYuan(price: number | string): string {
|
||||
return formatToFraction(price);
|
||||
}
|
||||
|
||||
// 格式化金额【分转元】
|
||||
export const fenToYuanFormat = (_: any, __: any, cellValue: any, ___: any) => {
|
||||
return `¥${floatToFixed2(cellValue)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算环比
|
||||
*
|
||||
* @param value 当前数值
|
||||
* @param reference 对比数值
|
||||
*/
|
||||
export function calculateRelativeRate(
|
||||
value?: number,
|
||||
reference?: number,
|
||||
): number {
|
||||
// 防止除0
|
||||
if (!reference || reference === 0) return 0;
|
||||
|
||||
return Number.parseFloat(
|
||||
((100 * ((value || 0) - reference)) / reference).toFixed(0),
|
||||
);
|
||||
}
|
||||
|
||||
// ========== ERP 专属方法 ==========
|
||||
|
||||
const ERP_COUNT_DIGIT = 3;
|
||||
const ERP_PRICE_DIGIT = 2;
|
||||
|
||||
/**
|
||||
* 【ERP】格式化 Input 数字
|
||||
*
|
||||
* 例如说:库存数量
|
||||
*
|
||||
* @param num 数量
|
||||
* @package
|
||||
* @return 格式化后的数量
|
||||
*/
|
||||
export function erpNumberFormatter(
|
||||
num: number | string | undefined,
|
||||
digit: number,
|
||||
) {
|
||||
if (num === null || num === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof num === 'string') {
|
||||
num = Number.parseFloat(num);
|
||||
}
|
||||
// 如果非 number,则直接返回空串
|
||||
if (Number.isNaN(num)) {
|
||||
return '';
|
||||
}
|
||||
return num.toFixed(digit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 【ERP】格式化数量,保留三位小数
|
||||
*
|
||||
* 例如说:库存数量
|
||||
*
|
||||
* @param num 数量
|
||||
* @return 格式化后的数量
|
||||
*/
|
||||
export function erpCountInputFormatter(num: number | string | undefined) {
|
||||
return erpNumberFormatter(num, ERP_COUNT_DIGIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 【ERP】格式化数量,保留三位小数
|
||||
*
|
||||
* @param cellValue 数量
|
||||
* @return 格式化后的数量
|
||||
*/
|
||||
export function erpCountTableColumnFormatter(cellValue: any) {
|
||||
return erpNumberFormatter(cellValue, ERP_COUNT_DIGIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 【ERP】格式化金额,保留二位小数
|
||||
*
|
||||
* 例如说:库存数量
|
||||
*
|
||||
* @param num 数量
|
||||
* @return 格式化后的数量
|
||||
*/
|
||||
export function erpPriceInputFormatter(num: number | string | undefined) {
|
||||
return erpNumberFormatter(num, ERP_PRICE_DIGIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 【ERP】格式化金额,保留二位小数
|
||||
*
|
||||
* @param cellValue 数量
|
||||
* @return 格式化后的数量
|
||||
*/
|
||||
export function erpPriceTableColumnFormatter(cellValue: any) {
|
||||
return erpNumberFormatter(cellValue, ERP_PRICE_DIGIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 【ERP】价格计算,四舍五入保留两位小数
|
||||
*
|
||||
* @param price 价格
|
||||
* @param count 数量
|
||||
* @return 总价格。如果有任一为空,则返回 undefined
|
||||
*/
|
||||
export function erpPriceMultiply(price: number, count: number) {
|
||||
if (isEmpty(price) || isEmpty(count)) return undefined;
|
||||
return Number.parseFloat((price * count).toFixed(ERP_PRICE_DIGIT));
|
||||
}
|
||||
|
||||
/**
|
||||
* 【ERP】百分比计算,四舍五入保留两位小数
|
||||
*
|
||||
* 如果 total 为 0,则返回 0
|
||||
*
|
||||
* @param value 当前值
|
||||
* @param total 总值
|
||||
*/
|
||||
export function erpCalculatePercentage(value: number, total: number) {
|
||||
if (total === 0) return 0;
|
||||
return ((value / total) * 100).toFixed(2);
|
||||
}
|
||||
25
packages/@core/base/shared/src/utils/index.ts
Normal file
25
packages/@core/base/shared/src/utils/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export * from './cn';
|
||||
export * from './date';
|
||||
export * from './diff';
|
||||
export * from './dom';
|
||||
export * from './download';
|
||||
export * from './encrypt';
|
||||
export * from './formatNumber';
|
||||
export * from './inference';
|
||||
export * from './letter';
|
||||
export * from './merge';
|
||||
export * from './nprogress';
|
||||
export * from './resources';
|
||||
export * from './state-handler';
|
||||
export * from './time';
|
||||
export * from './to';
|
||||
export * from './tree';
|
||||
export * from './unique';
|
||||
export * from './update-css-variables';
|
||||
export * from './upload';
|
||||
export * from './util';
|
||||
export * from './uuid'; // add by 芋艿:从 vben2.0 复制
|
||||
export * from './window';
|
||||
export { get, isEqual, set } from 'es-toolkit/compat';
|
||||
// export { cloneDeep } from 'es-toolkit/object';
|
||||
export { default as cloneDeep } from 'lodash.clonedeep';
|
||||
21
packages/@core/base/shared/src/utils/resources.ts
Normal file
21
packages/@core/base/shared/src/utils/resources.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 加载js文件
|
||||
* @param src js文件地址
|
||||
*/
|
||||
function loadScript(src: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (document.querySelector(`script[src="${src}"]`)) {
|
||||
// 如果已经加载过,直接 resolve
|
||||
return resolve();
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.addEventListener('load', () => resolve());
|
||||
script.addEventListener('error', () =>
|
||||
reject(new Error(`Failed to load script: ${src}`)),
|
||||
);
|
||||
document.head.append(script);
|
||||
});
|
||||
}
|
||||
|
||||
export { loadScript };
|
||||
224
packages/@core/base/shared/src/utils/upload.ts
Normal file
224
packages/@core/base/shared/src/utils/upload.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 根据支持的文件类型生成 accept 属性值
|
||||
*
|
||||
* @param supportedFileTypes 支持的文件类型数组,如 ['PDF', 'DOC', 'DOCX']
|
||||
* @returns 用于文件上传组件 accept 属性的字符串
|
||||
*/
|
||||
export function generateAcceptedFileTypes(
|
||||
supportedFileTypes: string[],
|
||||
): string {
|
||||
const allowedExtensions = supportedFileTypes.map((ext) => ext.toLowerCase());
|
||||
const mimeTypes: string[] = [];
|
||||
|
||||
// 添加常见的 MIME 类型映射
|
||||
if (allowedExtensions.includes('txt')) {
|
||||
mimeTypes.push('text/plain');
|
||||
}
|
||||
if (allowedExtensions.includes('pdf')) {
|
||||
mimeTypes.push('application/pdf');
|
||||
}
|
||||
if (allowedExtensions.includes('html') || allowedExtensions.includes('htm')) {
|
||||
mimeTypes.push('text/html');
|
||||
}
|
||||
if (allowedExtensions.includes('csv')) {
|
||||
mimeTypes.push('text/csv');
|
||||
}
|
||||
if (allowedExtensions.includes('xlsx') || allowedExtensions.includes('xls')) {
|
||||
mimeTypes.push(
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
}
|
||||
if (allowedExtensions.includes('docx') || allowedExtensions.includes('doc')) {
|
||||
mimeTypes.push(
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
);
|
||||
}
|
||||
if (allowedExtensions.includes('pptx') || allowedExtensions.includes('ppt')) {
|
||||
mimeTypes.push(
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
);
|
||||
}
|
||||
if (allowedExtensions.includes('xml')) {
|
||||
mimeTypes.push('application/xml', 'text/xml');
|
||||
}
|
||||
if (
|
||||
allowedExtensions.includes('md') ||
|
||||
allowedExtensions.includes('markdown')
|
||||
) {
|
||||
mimeTypes.push('text/markdown');
|
||||
}
|
||||
if (allowedExtensions.includes('epub')) {
|
||||
mimeTypes.push('application/epub+zip');
|
||||
}
|
||||
if (allowedExtensions.includes('eml')) {
|
||||
mimeTypes.push('message/rfc822');
|
||||
}
|
||||
if (allowedExtensions.includes('msg')) {
|
||||
mimeTypes.push('application/vnd.ms-outlook');
|
||||
}
|
||||
|
||||
// 添加文件扩展名
|
||||
const extensions = allowedExtensions.map((ext) => `.${ext}`);
|
||||
|
||||
return [...mimeTypes, ...extensions].join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 中提取文件名
|
||||
*
|
||||
* @param url 文件 URL
|
||||
* @returns 文件名,如果无法提取则返回 'unknown'
|
||||
*/
|
||||
export function getFileNameFromUrl(url: null | string | undefined): string {
|
||||
// 处理空值
|
||||
if (!url) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
const fileName = pathname.split('/').pop() || 'unknown';
|
||||
return decodeURIComponent(fileName);
|
||||
} catch {
|
||||
// 如果 URL 解析失败,尝试从字符串中提取
|
||||
const parts = url.split('/');
|
||||
return parts[parts.length - 1] || 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认图片类型
|
||||
*/
|
||||
export const defaultImageAccepts = [
|
||||
'bmp',
|
||||
'gif',
|
||||
'jpeg',
|
||||
'jpg',
|
||||
'png',
|
||||
'svg',
|
||||
'webp',
|
||||
];
|
||||
|
||||
/**
|
||||
* 判断文件是否为图片
|
||||
*
|
||||
* @param filename 文件名
|
||||
* @param accepts 支持的文件类型
|
||||
* @returns 是否为图片
|
||||
*/
|
||||
export function isImage(
|
||||
filename: null | string | undefined,
|
||||
accepts: string[] = defaultImageAccepts,
|
||||
): boolean {
|
||||
if (!filename || accepts.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
return accepts.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否为指定类型
|
||||
*
|
||||
* @param file 文件
|
||||
* @param accepts 支持的文件类型
|
||||
* @returns 是否为指定类型
|
||||
*/
|
||||
export function checkFileType(file: File, accepts: string[]) {
|
||||
if (!accepts || accepts.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const newTypes = accepts.join('|');
|
||||
const reg = new RegExp(`${String.raw`\.(` + newTypes})$`, 'i');
|
||||
return reg.test(file.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*
|
||||
* @param bytes 文件大小(字节)
|
||||
* @returns 格式化后的文件大小字符串
|
||||
*/
|
||||
export function formatFileSize(bytes: number, digits = 2): string {
|
||||
if (bytes === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const k = 1024;
|
||||
const unitArr = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
const index = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** index).toFixed(digits))} ${unitArr[index]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件图标(Lucide Icons)
|
||||
*
|
||||
* @param filename 文件名
|
||||
* @returns Lucide 图标名称
|
||||
*/
|
||||
export function getFileIcon(filename: null | string | undefined): string {
|
||||
if (!filename) {
|
||||
return 'lucide:file';
|
||||
}
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
if (isImage(ext)) {
|
||||
return 'lucide:image';
|
||||
}
|
||||
if (['pdf'].includes(ext)) {
|
||||
return 'lucide:file-text';
|
||||
}
|
||||
if (['doc', 'docx'].includes(ext)) {
|
||||
return 'lucide:file-text';
|
||||
}
|
||||
if (['xls', 'xlsx'].includes(ext)) {
|
||||
return 'lucide:file-spreadsheet';
|
||||
}
|
||||
if (['ppt', 'pptx'].includes(ext)) {
|
||||
return 'lucide:presentation';
|
||||
}
|
||||
if (['aac', 'm4a', 'mp3', 'wav'].includes(ext)) {
|
||||
return 'lucide:music';
|
||||
}
|
||||
if (['avi', 'mov', 'mp4', 'wmv'].includes(ext)) {
|
||||
return 'lucide:video';
|
||||
}
|
||||
return 'lucide:file';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件类型样式类(Tailwind CSS 渐变色)
|
||||
*
|
||||
* @param filename 文件名
|
||||
* @returns Tailwind CSS 渐变类名
|
||||
*/
|
||||
export function getFileTypeClass(filename: null | string | undefined): string {
|
||||
if (!filename) {
|
||||
return 'from-gray-500 to-gray-700';
|
||||
}
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
if (isImage(ext)) {
|
||||
return 'from-yellow-400 to-orange-500';
|
||||
}
|
||||
if (['pdf'].includes(ext)) {
|
||||
return 'from-red-500 to-red-700';
|
||||
}
|
||||
if (['doc', 'docx'].includes(ext)) {
|
||||
return 'from-blue-600 to-blue-800';
|
||||
}
|
||||
if (['xls', 'xlsx'].includes(ext)) {
|
||||
return 'from-green-600 to-green-800';
|
||||
}
|
||||
if (['ppt', 'pptx'].includes(ext)) {
|
||||
return 'from-orange-600 to-orange-800';
|
||||
}
|
||||
if (['aac', 'm4a', 'mp3', 'wav'].includes(ext)) {
|
||||
return 'from-purple-500 to-purple-700';
|
||||
}
|
||||
if (['avi', 'mov', 'mp4', 'wmv'].includes(ext)) {
|
||||
return 'from-red-500 to-red-700';
|
||||
}
|
||||
return 'from-gray-500 to-gray-700';
|
||||
}
|
||||
116
packages/@core/base/shared/src/utils/util.ts
Normal file
116
packages/@core/base/shared/src/utils/util.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
export function bindMethods<T extends object>(instance: T): void {
|
||||
const prototype = Object.getPrototypeOf(instance);
|
||||
const propertyNames = Object.getOwnPropertyNames(prototype);
|
||||
|
||||
propertyNames.forEach((propertyName) => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, propertyName);
|
||||
const propertyValue = instance[propertyName as keyof T];
|
||||
|
||||
if (
|
||||
typeof propertyValue === 'function' &&
|
||||
propertyName !== 'constructor' &&
|
||||
descriptor &&
|
||||
!descriptor.get &&
|
||||
!descriptor.set
|
||||
) {
|
||||
instance[propertyName as keyof T] = propertyValue.bind(instance);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取嵌套对象的字段值
|
||||
* @param obj - 要查找的对象
|
||||
* @param path - 用于查找字段的路径,使用小数点分隔
|
||||
* @returns 字段值,或者未找到时返回 undefined
|
||||
*/
|
||||
export function getNestedValue<T>(obj: T, path: string): any {
|
||||
if (typeof path !== 'string' || path.length === 0) {
|
||||
throw new Error('Path must be a non-empty string');
|
||||
}
|
||||
// 把路径字符串按 "." 分割成数组
|
||||
const keys = path.split('.') as (number | string)[];
|
||||
|
||||
let current: any = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (current === null || current === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[key as keyof typeof current];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取链接的参数值(值类型)
|
||||
* @param key 参数键名
|
||||
* @param urlStr 链接地址,默认为当前浏览器的地址
|
||||
*/
|
||||
export function getUrlNumberValue(
|
||||
key: string,
|
||||
urlStr: string = location.href,
|
||||
): number {
|
||||
return Number(getUrlValue(key, urlStr));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取链接的参数值
|
||||
* @param key 参数键名
|
||||
* @param urlStr 链接地址,默认为当前浏览器的地址
|
||||
*/
|
||||
export function getUrlValue(
|
||||
key: string,
|
||||
urlStr: string = location.href,
|
||||
): string {
|
||||
if (!urlStr || !key) return '';
|
||||
const url = new URL(decodeURIComponent(urlStr));
|
||||
return url.searchParams.get(key) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 将值复制到目标对象,且以目标对象属性为准,例:target: {a:1} source:{a:2,b:3} 结果为:{a:2}
|
||||
* @param target 目标对象
|
||||
* @param source 源对象
|
||||
*/
|
||||
export function copyValueToTarget(target: any, source: any) {
|
||||
const newObj = Object.assign({}, target, source);
|
||||
// 删除多余属性
|
||||
Object.keys(newObj).forEach((key) => {
|
||||
// 如果不是target中的属性则删除
|
||||
if (!Object.keys(target).includes(key)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete newObj[key];
|
||||
}
|
||||
});
|
||||
// 更新目标对象值
|
||||
Object.assign(target, newObj);
|
||||
}
|
||||
|
||||
/** 实现 groupBy 功能 */
|
||||
export function groupBy(array: any[], key: string) {
|
||||
const result: Record<string, any[]> = {};
|
||||
for (const item of array) {
|
||||
const groupKey = item[key];
|
||||
if (!result[groupKey]) {
|
||||
result[groupKey] = [];
|
||||
}
|
||||
result[groupKey].push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JSON 字符串
|
||||
*
|
||||
* @param str
|
||||
*/
|
||||
export function jsonParse(str: string) {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
console.warn(`str[${str}] 不是一个 JSON 字符串`);
|
||||
return str;
|
||||
}
|
||||
}
|
||||
37
packages/@core/base/shared/src/utils/window.ts
Normal file
37
packages/@core/base/shared/src/utils/window.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
interface OpenWindowOptions {
|
||||
noopener?: boolean;
|
||||
noreferrer?: boolean;
|
||||
target?: '_blank' | '_parent' | '_self' | '_top' | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新窗口打开URL。
|
||||
*
|
||||
* @param url - 需要打开的网址。
|
||||
* @param options - 打开窗口的选项。
|
||||
*/
|
||||
function openWindow(url: string, options: OpenWindowOptions = {}): void {
|
||||
// 解构并设置默认值
|
||||
const { noopener = true, noreferrer = true, target = '_blank' } = options;
|
||||
|
||||
// 基于选项创建特性字符串
|
||||
const features = [noopener && 'noopener=yes', noreferrer && 'noreferrer=yes']
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
|
||||
// 打开窗口
|
||||
window.open(url, target, features);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在新窗口中打开路由。
|
||||
* @param path
|
||||
*/
|
||||
function openRouteInNewWindow(path: string) {
|
||||
const { hash, origin } = location;
|
||||
const fullPath = path.startsWith('/') ? path : `/${path}`;
|
||||
const url = `${origin}${hash && !fullPath.startsWith('/#') ? '/#' : ''}${fullPath}`;
|
||||
openWindow(url, { target: '_blank' });
|
||||
}
|
||||
|
||||
export { openRouteInNewWindow, openWindow };
|
||||
44
packages/@core/base/typings/package.json
Normal file
44
packages/@core/base/typings/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@vben-core/typings",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/base/typings"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./vue-router": {
|
||||
"types": "./vue-router.d.ts"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
}
|
||||
}
|
||||
121
packages/@core/base/typings/src/app.d.ts
vendored
Normal file
121
packages/@core/base/typings/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
type LayoutType =
|
||||
| 'full-content'
|
||||
| 'header-mixed-nav'
|
||||
| 'header-nav'
|
||||
| 'header-sidebar-nav'
|
||||
| 'mixed-nav'
|
||||
| 'sidebar-mixed-nav'
|
||||
| 'sidebar-nav';
|
||||
|
||||
type ThemeModeType = 'auto' | 'dark' | 'light';
|
||||
|
||||
/**
|
||||
* 偏好设置按钮位置
|
||||
* fixed 固定在右侧
|
||||
* header 顶栏
|
||||
* auto 自动
|
||||
*/
|
||||
type PreferencesButtonPositionType = 'auto' | 'fixed' | 'header';
|
||||
|
||||
type BuiltinThemeType =
|
||||
| 'custom'
|
||||
| 'deep-blue'
|
||||
| 'deep-green'
|
||||
| 'default'
|
||||
| 'gray'
|
||||
| 'green'
|
||||
| 'neutral'
|
||||
| 'orange'
|
||||
| 'pink'
|
||||
| 'red'
|
||||
| 'rose'
|
||||
| 'sky-blue'
|
||||
| 'slate'
|
||||
| 'stone'
|
||||
| 'violet'
|
||||
| 'yellow'
|
||||
| 'zinc'
|
||||
| (Record<never, never> & string);
|
||||
|
||||
type ContentCompactType = 'compact' | 'wide';
|
||||
|
||||
type LayoutHeaderModeType = 'auto' | 'auto-scroll' | 'fixed' | 'static';
|
||||
type LayoutHeaderMenuAlignType = 'center' | 'end' | 'start';
|
||||
|
||||
/**
|
||||
* 登录过期模式
|
||||
* modal 弹窗模式
|
||||
* page 页面模式
|
||||
*/
|
||||
type LoginExpiredModeType = 'modal' | 'page';
|
||||
|
||||
/**
|
||||
* 面包屑样式
|
||||
* background 背景
|
||||
* normal 默认
|
||||
*/
|
||||
type BreadcrumbStyleType = 'background' | 'normal';
|
||||
|
||||
/**
|
||||
* 权限模式
|
||||
* backend 后端权限模式
|
||||
* frontend 前端权限模式
|
||||
* mixed 混合权限模式
|
||||
*/
|
||||
type AccessModeType = 'backend' | 'frontend' | 'mixed';
|
||||
|
||||
/**
|
||||
* 导航风格
|
||||
* plain 朴素
|
||||
* rounded 圆润
|
||||
*/
|
||||
type NavigationStyleType = 'plain' | 'rounded';
|
||||
|
||||
/**
|
||||
* 标签栏风格
|
||||
* brisk 轻快
|
||||
* card 卡片
|
||||
* chrome 谷歌
|
||||
* plain 朴素
|
||||
*/
|
||||
type TabsStyleType = 'brisk' | 'card' | 'chrome' | 'plain';
|
||||
|
||||
/**
|
||||
* 页面切换动画
|
||||
*/
|
||||
type PageTransitionType = 'fade' | 'fade-down' | 'fade-slide' | 'fade-up';
|
||||
|
||||
/**
|
||||
* 页面切换动画
|
||||
* panel-center 居中布局
|
||||
* panel-left 居左布局
|
||||
* panel-right 居右布局
|
||||
*/
|
||||
type AuthPageLayoutType = 'panel-center' | 'panel-left' | 'panel-right';
|
||||
|
||||
/**
|
||||
* 时区选项
|
||||
*/
|
||||
interface TimezoneOption {
|
||||
label: string;
|
||||
offset: number;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export type {
|
||||
AccessModeType,
|
||||
AuthPageLayoutType,
|
||||
BreadcrumbStyleType,
|
||||
BuiltinThemeType,
|
||||
ContentCompactType,
|
||||
LayoutHeaderMenuAlignType,
|
||||
LayoutHeaderModeType,
|
||||
LayoutType,
|
||||
LoginExpiredModeType,
|
||||
NavigationStyleType,
|
||||
PageTransitionType,
|
||||
PreferencesButtonPositionType,
|
||||
TabsStyleType,
|
||||
ThemeModeType,
|
||||
TimezoneOption,
|
||||
};
|
||||
47
packages/@core/composables/package.json
Normal file
47
packages/@core/composables/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@vben-core/composables",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@core/composables"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/shared": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"sortablejs": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sortablejs": "catalog:"
|
||||
}
|
||||
}
|
||||
13
packages/@core/composables/src/index.ts
Normal file
13
packages/@core/composables/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export * from './use-is-mobile';
|
||||
export * from './use-layout-style';
|
||||
export * from './use-namespace';
|
||||
export * from './use-priority-value';
|
||||
export * from './use-scroll-lock';
|
||||
export * from './use-simple-locale';
|
||||
export * from './use-sortable';
|
||||
export {
|
||||
useEmitAsProps,
|
||||
useForwardExpose,
|
||||
useForwardProps,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui';
|
||||
@@ -0,0 +1,139 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`defaultPreferences immutability test > should not modify the config object 1`] = `
|
||||
{
|
||||
"app": {
|
||||
"accessMode": "frontend",
|
||||
"authPageLayout": "panel-right",
|
||||
"checkUpdatesInterval": 1,
|
||||
"colorGrayMode": false,
|
||||
"colorWeakMode": false,
|
||||
"compact": false,
|
||||
"contentCompact": "wide",
|
||||
"contentCompactWidth": 1200,
|
||||
"contentPadding": 0,
|
||||
"contentPaddingBottom": 0,
|
||||
"contentPaddingLeft": 0,
|
||||
"contentPaddingRight": 0,
|
||||
"contentPaddingTop": 0,
|
||||
"defaultAvatar": "https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp",
|
||||
"defaultHomePath": "/analytics",
|
||||
"dynamicTitle": true,
|
||||
"enableCheckUpdates": true,
|
||||
"enablePreferences": true,
|
||||
"enableRefreshToken": false,
|
||||
"enableStickyPreferencesNavigationBar": true,
|
||||
"isMobile": false,
|
||||
"layout": "sidebar-nav",
|
||||
"locale": "zh-CN",
|
||||
"loginExpiredMode": "page",
|
||||
"name": "Vben Admin",
|
||||
"preferencesButtonPosition": "auto",
|
||||
"watermark": false,
|
||||
"watermarkContent": "",
|
||||
"zIndex": 200,
|
||||
},
|
||||
"breadcrumb": {
|
||||
"enable": true,
|
||||
"hideOnlyOne": false,
|
||||
"showHome": false,
|
||||
"showIcon": true,
|
||||
"styleType": "normal",
|
||||
},
|
||||
"copyright": {
|
||||
"companyName": "Vben",
|
||||
"companySiteLink": "https://www.vben.pro",
|
||||
"date": "2024",
|
||||
"enable": true,
|
||||
"icp": "",
|
||||
"icpLink": "",
|
||||
"settingShow": true,
|
||||
},
|
||||
"footer": {
|
||||
"enable": false,
|
||||
"fixed": false,
|
||||
"height": 32,
|
||||
},
|
||||
"header": {
|
||||
"enable": true,
|
||||
"height": 50,
|
||||
"hidden": false,
|
||||
"menuAlign": "start",
|
||||
"mode": "fixed",
|
||||
},
|
||||
"logo": {
|
||||
"enable": true,
|
||||
"fit": "contain",
|
||||
"source": "https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp",
|
||||
},
|
||||
"navigation": {
|
||||
"accordion": true,
|
||||
"split": true,
|
||||
"styleType": "rounded",
|
||||
},
|
||||
"shortcutKeys": {
|
||||
"enable": true,
|
||||
"globalLockScreen": true,
|
||||
"globalLogout": true,
|
||||
"globalPreferences": true,
|
||||
"globalSearch": true,
|
||||
},
|
||||
"sidebar": {
|
||||
"autoActivateChild": false,
|
||||
"collapseWidth": 60,
|
||||
"collapsed": false,
|
||||
"collapsedButton": true,
|
||||
"collapsedShowTitle": false,
|
||||
"enable": true,
|
||||
"expandOnHover": true,
|
||||
"extraCollapse": false,
|
||||
"extraCollapsedWidth": 60,
|
||||
"fixedButton": true,
|
||||
"hidden": false,
|
||||
"mixedWidth": 80,
|
||||
"width": 224,
|
||||
},
|
||||
"tabbar": {
|
||||
"draggable": true,
|
||||
"enable": true,
|
||||
"height": 38,
|
||||
"keepAlive": true,
|
||||
"maxCount": 0,
|
||||
"middleClickToClose": false,
|
||||
"persist": true,
|
||||
"showIcon": true,
|
||||
"showMaximize": true,
|
||||
"showMore": true,
|
||||
"styleType": "chrome",
|
||||
"wheelable": true,
|
||||
},
|
||||
"theme": {
|
||||
"builtinType": "default",
|
||||
"colorDestructive": "hsl(348 100% 61%)",
|
||||
"colorPrimary": "hsl(212 100% 45%)",
|
||||
"colorSuccess": "hsl(144 57% 58%)",
|
||||
"colorWarning": "hsl(42 84% 61%)",
|
||||
"mode": "dark",
|
||||
"radius": "0.5",
|
||||
"semiDarkHeader": false,
|
||||
"semiDarkSidebar": false,
|
||||
},
|
||||
"transition": {
|
||||
"enable": true,
|
||||
"loading": true,
|
||||
"name": "fade-slide",
|
||||
"progress": true,
|
||||
},
|
||||
"widget": {
|
||||
"fullscreen": true,
|
||||
"globalSearch": true,
|
||||
"languageToggle": true,
|
||||
"lockScreen": true,
|
||||
"notification": true,
|
||||
"refresh": true,
|
||||
"sidebarToggle": true,
|
||||
"themeToggle": true,
|
||||
"timezone": true,
|
||||
},
|
||||
}
|
||||
`;
|
||||
37
packages/@core/preferences/package.json
Normal file
37
packages/@core/preferences/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@vben-core/preferences",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@core/preferences"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"#build": "pnpm unbuild"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./src/index.ts",
|
||||
"#default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/shared": "workspace:*",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
141
packages/@core/preferences/src/config.ts
Normal file
141
packages/@core/preferences/src/config.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { Preferences } from './types';
|
||||
|
||||
const defaultPreferences: Preferences = {
|
||||
app: {
|
||||
accessMode: 'frontend',
|
||||
authPageLayout: 'panel-right',
|
||||
checkUpdatesInterval: 1,
|
||||
colorGrayMode: false,
|
||||
colorWeakMode: false,
|
||||
compact: false,
|
||||
contentCompact: 'wide',
|
||||
contentCompactWidth: 1200,
|
||||
contentPadding: 0,
|
||||
contentPaddingBottom: 0,
|
||||
contentPaddingLeft: 0,
|
||||
contentPaddingRight: 0,
|
||||
contentPaddingTop: 0,
|
||||
defaultAvatar:
|
||||
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp',
|
||||
defaultHomePath: '/analytics',
|
||||
dynamicTitle: true,
|
||||
enableCheckUpdates: true,
|
||||
enablePreferences: true,
|
||||
enableRefreshToken: false,
|
||||
enableStickyPreferencesNavigationBar: true,
|
||||
isMobile: false,
|
||||
layout: 'sidebar-nav',
|
||||
locale: 'zh-CN',
|
||||
loginExpiredMode: 'page',
|
||||
name: 'Vben Admin',
|
||||
preferencesButtonPosition: 'auto',
|
||||
watermark: false,
|
||||
watermarkContent: '',
|
||||
zIndex: 200,
|
||||
},
|
||||
breadcrumb: {
|
||||
enable: true,
|
||||
hideOnlyOne: false,
|
||||
showHome: false,
|
||||
showIcon: true,
|
||||
styleType: 'normal',
|
||||
},
|
||||
copyright: {
|
||||
companyName: 'Vben',
|
||||
companySiteLink: 'https://www.vben.pro',
|
||||
date: '2024',
|
||||
enable: true,
|
||||
icp: '',
|
||||
icpLink: '',
|
||||
settingShow: true,
|
||||
},
|
||||
footer: {
|
||||
enable: false,
|
||||
fixed: false,
|
||||
height: 32,
|
||||
},
|
||||
header: {
|
||||
enable: true,
|
||||
height: 50,
|
||||
hidden: false,
|
||||
menuAlign: 'start',
|
||||
mode: 'fixed',
|
||||
},
|
||||
|
||||
logo: {
|
||||
enable: true,
|
||||
fit: 'contain',
|
||||
source: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
|
||||
},
|
||||
navigation: {
|
||||
accordion: true,
|
||||
split: true,
|
||||
styleType: 'rounded',
|
||||
},
|
||||
shortcutKeys: {
|
||||
enable: true,
|
||||
globalLockScreen: true,
|
||||
globalLogout: true,
|
||||
globalPreferences: true,
|
||||
globalSearch: true,
|
||||
},
|
||||
sidebar: {
|
||||
autoActivateChild: false,
|
||||
collapsed: false,
|
||||
collapsedButton: true,
|
||||
collapsedShowTitle: false,
|
||||
collapseWidth: 60,
|
||||
enable: true,
|
||||
expandOnHover: true,
|
||||
extraCollapse: false,
|
||||
extraCollapsedWidth: 60,
|
||||
fixedButton: true,
|
||||
hidden: false,
|
||||
mixedWidth: 80,
|
||||
width: 224,
|
||||
},
|
||||
tabbar: {
|
||||
draggable: true,
|
||||
enable: true,
|
||||
height: 38,
|
||||
keepAlive: true,
|
||||
maxCount: 0,
|
||||
middleClickToClose: false,
|
||||
persist: true,
|
||||
showIcon: true,
|
||||
showMaximize: true,
|
||||
showMore: true,
|
||||
styleType: 'chrome',
|
||||
wheelable: true,
|
||||
},
|
||||
theme: {
|
||||
builtinType: 'default',
|
||||
colorDestructive: 'hsl(348 100% 61%)',
|
||||
colorPrimary: 'hsl(212 100% 45%)',
|
||||
colorSuccess: 'hsl(144 57% 58%)',
|
||||
colorWarning: 'hsl(42 84% 61%)',
|
||||
mode: 'dark',
|
||||
radius: '0.5',
|
||||
semiDarkHeader: false,
|
||||
semiDarkSidebar: false,
|
||||
},
|
||||
transition: {
|
||||
enable: true,
|
||||
loading: true,
|
||||
name: 'fade-slide',
|
||||
progress: true,
|
||||
},
|
||||
widget: {
|
||||
fullscreen: true,
|
||||
globalSearch: true,
|
||||
languageToggle: true,
|
||||
lockScreen: true,
|
||||
notification: true,
|
||||
refresh: true,
|
||||
sidebarToggle: true,
|
||||
themeToggle: true,
|
||||
timezone: true,
|
||||
},
|
||||
};
|
||||
|
||||
export { defaultPreferences };
|
||||
119
packages/@core/preferences/src/constants.ts
Normal file
119
packages/@core/preferences/src/constants.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { BuiltinThemeType, TimezoneOption } from '@vben-core/typings';
|
||||
|
||||
interface BuiltinThemePreset {
|
||||
color: string;
|
||||
darkPrimaryColor?: string;
|
||||
primaryColor?: string;
|
||||
type: BuiltinThemeType;
|
||||
}
|
||||
|
||||
const BUILT_IN_THEME_PRESETS: BuiltinThemePreset[] = [
|
||||
{
|
||||
color: 'hsl(212 100% 45%)',
|
||||
type: 'default',
|
||||
},
|
||||
{
|
||||
color: 'hsl(245 82% 67%)',
|
||||
type: 'violet',
|
||||
},
|
||||
{
|
||||
color: 'hsl(347 77% 60%)',
|
||||
type: 'pink',
|
||||
},
|
||||
{
|
||||
color: 'hsl(42 84% 61%)',
|
||||
type: 'yellow',
|
||||
},
|
||||
{
|
||||
color: 'hsl(231 98% 65%)',
|
||||
type: 'sky-blue',
|
||||
},
|
||||
{
|
||||
color: 'hsl(161 90% 43%)',
|
||||
type: 'green',
|
||||
},
|
||||
{
|
||||
color: 'hsl(240 5% 26%)',
|
||||
darkPrimaryColor: 'hsl(0 0% 98%)',
|
||||
primaryColor: 'hsl(240 5.9% 10%)',
|
||||
type: 'zinc',
|
||||
},
|
||||
|
||||
{
|
||||
color: 'hsl(181 84% 32%)',
|
||||
type: 'deep-green',
|
||||
},
|
||||
|
||||
{
|
||||
color: 'hsl(211 91% 39%)',
|
||||
type: 'deep-blue',
|
||||
},
|
||||
{
|
||||
color: 'hsl(18 89% 40%)',
|
||||
type: 'orange',
|
||||
},
|
||||
{
|
||||
color: 'hsl(0 75% 42%)',
|
||||
type: 'rose',
|
||||
},
|
||||
|
||||
{
|
||||
color: 'hsl(0 0% 25%)',
|
||||
darkPrimaryColor: 'hsl(0 0% 98%)',
|
||||
primaryColor: 'hsl(240 5.9% 10%)',
|
||||
type: 'neutral',
|
||||
},
|
||||
{
|
||||
color: 'hsl(215 25% 27%)',
|
||||
darkPrimaryColor: 'hsl(0 0% 98%)',
|
||||
primaryColor: 'hsl(240 5.9% 10%)',
|
||||
type: 'slate',
|
||||
},
|
||||
{
|
||||
color: 'hsl(217 19% 27%)',
|
||||
darkPrimaryColor: 'hsl(0 0% 98%)',
|
||||
primaryColor: 'hsl(240 5.9% 10%)',
|
||||
type: 'gray',
|
||||
},
|
||||
{
|
||||
color: '',
|
||||
type: 'custom',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 时区选项
|
||||
*/
|
||||
const DEFAULT_TIME_ZONE_OPTIONS: TimezoneOption[] = [
|
||||
{
|
||||
offset: -5,
|
||||
timezone: 'America/New_York',
|
||||
label: 'America/New_York(GMT-5)',
|
||||
},
|
||||
{
|
||||
offset: 0,
|
||||
timezone: 'Europe/London',
|
||||
label: 'Europe/London(GMT0)',
|
||||
},
|
||||
{
|
||||
offset: 8,
|
||||
timezone: 'Asia/Shanghai',
|
||||
label: 'Asia/Shanghai(GMT+8)',
|
||||
},
|
||||
{
|
||||
offset: 9,
|
||||
timezone: 'Asia/Tokyo',
|
||||
label: 'Asia/Tokyo(GMT+9)',
|
||||
},
|
||||
{
|
||||
offset: 9,
|
||||
timezone: 'Asia/Seoul',
|
||||
label: 'Asia/Seoul(GMT+9)',
|
||||
},
|
||||
];
|
||||
|
||||
export const COLOR_PRESETS = [...BUILT_IN_THEME_PRESETS].slice(0, 7);
|
||||
|
||||
export { BUILT_IN_THEME_PRESETS, DEFAULT_TIME_ZONE_OPTIONS };
|
||||
|
||||
export type { BuiltinThemePreset };
|
||||
231
packages/@core/preferences/src/preferences.ts
Normal file
231
packages/@core/preferences/src/preferences.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { DeepPartial } from '@vben-core/typings';
|
||||
|
||||
import type { InitialOptions, Preferences } from './types';
|
||||
|
||||
import { markRaw, reactive, readonly, watch } from 'vue';
|
||||
|
||||
import { StorageManager } from '@vben-core/shared/cache';
|
||||
import { isMacOs, merge } from '@vben-core/shared/utils';
|
||||
|
||||
import {
|
||||
breakpointsTailwind,
|
||||
useBreakpoints,
|
||||
useDebounceFn,
|
||||
} from '@vueuse/core';
|
||||
|
||||
import { defaultPreferences } from './config';
|
||||
import { updateCSSVariables } from './update-css-variables';
|
||||
|
||||
const STORAGE_KEY = 'preferences';
|
||||
const STORAGE_KEY_LOCALE = `${STORAGE_KEY}-locale`;
|
||||
const STORAGE_KEY_THEME = `${STORAGE_KEY}-theme`;
|
||||
|
||||
class PreferenceManager {
|
||||
private cache: null | StorageManager = null;
|
||||
// private flattenedState: Flatten<Preferences>;
|
||||
private initialPreferences: Preferences = defaultPreferences;
|
||||
private isInitialized: boolean = false;
|
||||
private savePreferences: (preference: Preferences) => void;
|
||||
private state: Preferences = reactive<Preferences>({
|
||||
...this.loadPreferences(),
|
||||
});
|
||||
constructor() {
|
||||
this.cache = new StorageManager();
|
||||
|
||||
// 避免频繁的操作缓存
|
||||
this.savePreferences = useDebounceFn(
|
||||
(preference: Preferences) => this._savePreferences(preference),
|
||||
150,
|
||||
);
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
[STORAGE_KEY, STORAGE_KEY_LOCALE, STORAGE_KEY_THEME].forEach((key) => {
|
||||
this.cache?.removeItem(key);
|
||||
});
|
||||
}
|
||||
|
||||
public getInitialPreferences() {
|
||||
return this.initialPreferences;
|
||||
}
|
||||
|
||||
public getPreferences() {
|
||||
return readonly(this.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 覆盖偏好设置
|
||||
* overrides 要覆盖的偏好设置
|
||||
* namespace 命名空间
|
||||
*/
|
||||
public async initPreferences({ namespace, overrides }: InitialOptions) {
|
||||
// 是否初始化过
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
// 初始化存储管理器
|
||||
this.cache = new StorageManager({ prefix: namespace });
|
||||
// 合并初始偏好设置
|
||||
this.initialPreferences = merge({}, overrides, defaultPreferences);
|
||||
|
||||
// 加载并合并当前存储的偏好设置
|
||||
const mergedPreference = merge(
|
||||
{},
|
||||
// overrides,
|
||||
this.loadCachedPreferences() || {},
|
||||
this.initialPreferences,
|
||||
);
|
||||
|
||||
// 更新偏好设置
|
||||
this.updatePreferences(mergedPreference);
|
||||
|
||||
this.setupWatcher();
|
||||
|
||||
this.initPlatform();
|
||||
// 标记为已初始化
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置偏好设置
|
||||
* 偏好设置将被重置为初始值,并从 localStorage 中移除。
|
||||
*
|
||||
* @example
|
||||
* 假设 initialPreferences 为 { theme: 'light', language: 'en' }
|
||||
* 当前 state 为 { theme: 'dark', language: 'fr' }
|
||||
* this.resetPreferences();
|
||||
* 调用后,state 将被重置为 { theme: 'light', language: 'en' }
|
||||
* 并且 localStorage 中的对应项将被移除
|
||||
*/
|
||||
resetPreferences() {
|
||||
// 将状态重置为初始偏好设置
|
||||
Object.assign(this.state, this.initialPreferences);
|
||||
// 保存重置后的偏好设置
|
||||
this.savePreferences(this.state);
|
||||
// 从存储中移除偏好设置项
|
||||
[STORAGE_KEY, STORAGE_KEY_THEME, STORAGE_KEY_LOCALE].forEach((key) => {
|
||||
this.cache?.removeItem(key);
|
||||
});
|
||||
this.updatePreferences(this.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新偏好设置
|
||||
* @param updates - 要更新的偏好设置
|
||||
*/
|
||||
public updatePreferences(updates: DeepPartial<Preferences>) {
|
||||
const mergedState = merge({}, updates, markRaw(this.state));
|
||||
|
||||
Object.assign(this.state, mergedState);
|
||||
|
||||
// 根据更新的键值执行相应的操作
|
||||
this.handleUpdates(updates);
|
||||
this.savePreferences(this.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存偏好设置
|
||||
* @param {Preferences} preference - 需要保存的偏好设置
|
||||
*/
|
||||
private _savePreferences(preference: Preferences) {
|
||||
this.cache?.setItem(STORAGE_KEY, preference);
|
||||
this.cache?.setItem(STORAGE_KEY_LOCALE, preference.app.locale);
|
||||
this.cache?.setItem(STORAGE_KEY_THEME, preference.theme.mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理更新的键值
|
||||
* 根据更新的键值执行相应的操作。
|
||||
* @param {DeepPartial<Preferences>} updates - 部分更新的偏好设置
|
||||
*/
|
||||
private handleUpdates(updates: DeepPartial<Preferences>) {
|
||||
const themeUpdates = updates.theme || {};
|
||||
const appUpdates = updates.app || {};
|
||||
if (themeUpdates && Object.keys(themeUpdates).length > 0) {
|
||||
updateCSSVariables(this.state);
|
||||
}
|
||||
|
||||
if (
|
||||
Reflect.has(appUpdates, 'colorGrayMode') ||
|
||||
Reflect.has(appUpdates, 'colorWeakMode')
|
||||
) {
|
||||
this.updateColorMode(this.state);
|
||||
}
|
||||
}
|
||||
|
||||
private initPlatform() {
|
||||
const dom = document.documentElement;
|
||||
dom.dataset.platform = isMacOs() ? 'macOs' : 'window';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存中加载偏好设置。如果缓存中没有找到对应的偏好设置,则返回默认偏好设置。
|
||||
*/
|
||||
private loadCachedPreferences() {
|
||||
return this.cache?.getItem<Preferences>(STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载偏好设置
|
||||
* @returns {Preferences} 加载的偏好设置
|
||||
*/
|
||||
private loadPreferences(): Preferences {
|
||||
return this.loadCachedPreferences() || { ...defaultPreferences };
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听状态和系统偏好设置的变化。
|
||||
*/
|
||||
private setupWatcher() {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 监听断点,判断是否移动端
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind);
|
||||
const isMobile = breakpoints.smaller('md');
|
||||
watch(
|
||||
() => isMobile.value,
|
||||
(val) => {
|
||||
this.updatePreferences({
|
||||
app: { isMobile: val },
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 监听系统主题偏好设置变化
|
||||
window
|
||||
.matchMedia('(prefers-color-scheme: dark)')
|
||||
.addEventListener('change', ({ matches: isDark }) => {
|
||||
// 如果偏好设置中主题模式为auto,则跟随系统更新
|
||||
if (this.state.theme.mode === 'auto') {
|
||||
this.updatePreferences({
|
||||
theme: { mode: isDark ? 'dark' : 'light' },
|
||||
});
|
||||
// 恢复为auto模式
|
||||
this.updatePreferences({
|
||||
theme: { mode: 'auto' },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新页面颜色模式(灰色、色弱)
|
||||
* @param preference
|
||||
*/
|
||||
private updateColorMode(preference: Preferences) {
|
||||
if (preference.app) {
|
||||
const { colorGrayMode, colorWeakMode } = preference.app;
|
||||
const dom = document.documentElement;
|
||||
const COLOR_WEAK = 'invert-mode';
|
||||
const COLOR_GRAY = 'grayscale-mode';
|
||||
dom.classList.toggle(COLOR_WEAK, colorWeakMode);
|
||||
dom.classList.toggle(COLOR_GRAY, colorGrayMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const preferencesManager = new PreferenceManager();
|
||||
export { PreferenceManager, preferencesManager };
|
||||
336
packages/@core/preferences/src/types.ts
Normal file
336
packages/@core/preferences/src/types.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import type {
|
||||
AccessModeType,
|
||||
AuthPageLayoutType,
|
||||
BreadcrumbStyleType,
|
||||
BuiltinThemeType,
|
||||
ContentCompactType,
|
||||
DeepPartial,
|
||||
LayoutHeaderMenuAlignType,
|
||||
LayoutHeaderModeType,
|
||||
LayoutType,
|
||||
LoginExpiredModeType,
|
||||
NavigationStyleType,
|
||||
PageTransitionType,
|
||||
PreferencesButtonPositionType,
|
||||
TabsStyleType,
|
||||
ThemeModeType,
|
||||
} from '@vben-core/typings';
|
||||
|
||||
type SupportedLanguagesType = 'en-US' | 'zh-CN';
|
||||
|
||||
interface AppPreferences {
|
||||
/** 权限模式 */
|
||||
accessMode: AccessModeType;
|
||||
/** 登录注册页面布局 */
|
||||
authPageLayout: AuthPageLayoutType;
|
||||
/** 检查更新轮询时间 */
|
||||
checkUpdatesInterval: number;
|
||||
/** 是否开启灰色模式 */
|
||||
colorGrayMode: boolean;
|
||||
/** 是否开启色弱模式 */
|
||||
colorWeakMode: boolean;
|
||||
/** 是否开启紧凑模式 */
|
||||
compact: boolean;
|
||||
/** 是否开启内容紧凑模式 */
|
||||
contentCompact: ContentCompactType;
|
||||
/** 内容紧凑宽度 */
|
||||
contentCompactWidth: number;
|
||||
/** 内容内边距 */
|
||||
contentPadding: number;
|
||||
/** 内容底部内边距 */
|
||||
contentPaddingBottom: number;
|
||||
/** 内容左侧内边距 */
|
||||
contentPaddingLeft: number;
|
||||
/** 内容右侧内边距 */
|
||||
contentPaddingRight: number;
|
||||
/** 内容顶部内边距 */
|
||||
contentPaddingTop: number;
|
||||
// /** 应用默认头像 */
|
||||
defaultAvatar: string;
|
||||
/** 默认首页地址 */
|
||||
defaultHomePath: string;
|
||||
// /** 开启动态标题 */
|
||||
dynamicTitle: boolean;
|
||||
/** 是否开启检查更新 */
|
||||
enableCheckUpdates: boolean;
|
||||
/** 是否显示偏好设置 */
|
||||
enablePreferences: boolean;
|
||||
/**
|
||||
* @zh_CN 是否开启refreshToken
|
||||
*/
|
||||
enableRefreshToken: boolean;
|
||||
/**
|
||||
* @zh_CN 是否开启首选项导航栏吸顶效果
|
||||
*/
|
||||
enableStickyPreferencesNavigationBar: boolean;
|
||||
/** 是否移动端 */
|
||||
isMobile: boolean;
|
||||
/** 布局方式 */
|
||||
layout: LayoutType;
|
||||
/** 支持的语言 */
|
||||
locale: SupportedLanguagesType;
|
||||
/** 登录过期模式 */
|
||||
loginExpiredMode: LoginExpiredModeType;
|
||||
/** 应用名 */
|
||||
name: string;
|
||||
/** 偏好设置按钮位置 */
|
||||
preferencesButtonPosition: PreferencesButtonPositionType;
|
||||
/**
|
||||
* @zh_CN 是否开启水印
|
||||
*/
|
||||
watermark: boolean;
|
||||
/**
|
||||
* @zh_CN 水印文案
|
||||
*/
|
||||
watermarkContent: string;
|
||||
/** z-index */
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
interface BreadcrumbPreferences {
|
||||
/** 面包屑是否启用 */
|
||||
enable: boolean;
|
||||
/** 面包屑是否只有一个时隐藏 */
|
||||
hideOnlyOne: boolean;
|
||||
/** 面包屑首页图标是否可见 */
|
||||
showHome: boolean;
|
||||
/** 面包屑图标是否可见 */
|
||||
showIcon: boolean;
|
||||
/** 面包屑风格 */
|
||||
styleType: BreadcrumbStyleType;
|
||||
}
|
||||
|
||||
interface CopyrightPreferences {
|
||||
/** 版权公司名 */
|
||||
companyName: string;
|
||||
/** 版权公司名链接 */
|
||||
companySiteLink: string;
|
||||
/** 版权日期 */
|
||||
date: string;
|
||||
/** 版权是否可见 */
|
||||
enable: boolean;
|
||||
/** 备案号 */
|
||||
icp: string;
|
||||
/** 备案号链接 */
|
||||
icpLink: string;
|
||||
/** 设置面板是否显示*/
|
||||
settingShow?: boolean;
|
||||
}
|
||||
|
||||
interface FooterPreferences {
|
||||
/** 底栏是否可见 */
|
||||
enable: boolean;
|
||||
/** 底栏是否固定 */
|
||||
fixed: boolean;
|
||||
/** 底栏高度 */
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface HeaderPreferences {
|
||||
/** 顶栏是否启用 */
|
||||
enable: boolean;
|
||||
/** 顶栏高度 */
|
||||
height: number;
|
||||
/** 顶栏是否隐藏,css-隐藏 */
|
||||
hidden: boolean;
|
||||
/** 顶栏菜单位置 */
|
||||
menuAlign: LayoutHeaderMenuAlignType;
|
||||
/** header显示模式 */
|
||||
mode: LayoutHeaderModeType;
|
||||
}
|
||||
|
||||
interface LogoPreferences {
|
||||
/** logo是否可见 */
|
||||
enable: boolean;
|
||||
/** logo图片适应方式 */
|
||||
fit: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
|
||||
/** logo地址 */
|
||||
source: string;
|
||||
/** 暗色主题logo地址 (可选,若不设置则使用 source) */
|
||||
sourceDark?: string;
|
||||
}
|
||||
|
||||
interface NavigationPreferences {
|
||||
/** 导航菜单手风琴模式 */
|
||||
accordion: boolean;
|
||||
/** 导航菜单是否切割,只在 layout=mixed-nav 生效 */
|
||||
split: boolean;
|
||||
/** 导航菜单风格 */
|
||||
styleType: NavigationStyleType;
|
||||
}
|
||||
|
||||
interface SidebarPreferences {
|
||||
/** 点击目录时自动激活子菜单 */
|
||||
autoActivateChild: boolean;
|
||||
/** 侧边栏是否折叠 */
|
||||
collapsed: boolean;
|
||||
/** 侧边栏折叠按钮是否可见 */
|
||||
collapsedButton: boolean;
|
||||
/** 侧边栏折叠时,是否显示title */
|
||||
collapsedShowTitle: boolean;
|
||||
/** 侧边栏折叠宽度 */
|
||||
collapseWidth: number;
|
||||
/** 侧边栏是否可见 */
|
||||
enable: boolean;
|
||||
/** 菜单自动展开状态 */
|
||||
expandOnHover: boolean;
|
||||
/** 侧边栏扩展区域是否折叠 */
|
||||
extraCollapse: boolean;
|
||||
/** 侧边栏扩展区域折叠宽度 */
|
||||
extraCollapsedWidth: number;
|
||||
/** 侧边栏固定按钮是否可见 */
|
||||
fixedButton: boolean;
|
||||
/** 侧边栏是否隐藏 - css */
|
||||
hidden: boolean;
|
||||
/** 混合侧边栏宽度 */
|
||||
mixedWidth: number;
|
||||
/** 侧边栏宽度 */
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface ShortcutKeyPreferences {
|
||||
/** 是否启用快捷键-全局 */
|
||||
enable: boolean;
|
||||
/** 是否启用全局锁屏快捷键 */
|
||||
globalLockScreen: boolean;
|
||||
/** 是否启用全局注销快捷键 */
|
||||
globalLogout: boolean;
|
||||
/** 是否启用全局偏好设置快捷键 */
|
||||
globalPreferences: boolean;
|
||||
/** 是否启用全局搜索快捷键 */
|
||||
globalSearch: boolean;
|
||||
}
|
||||
|
||||
interface TabbarPreferences {
|
||||
/** 是否开启多标签页拖拽 */
|
||||
draggable: boolean;
|
||||
/** 是否开启多标签页 */
|
||||
enable: boolean;
|
||||
/** 标签页高度 */
|
||||
height: number;
|
||||
/** 开启标签页缓存功能 */
|
||||
keepAlive: boolean;
|
||||
/** 限制最大数量 */
|
||||
maxCount: number;
|
||||
/** 是否点击中键时关闭标签 */
|
||||
middleClickToClose: boolean;
|
||||
/** 是否持久化标签 */
|
||||
persist: boolean;
|
||||
/** 是否开启多标签页图标 */
|
||||
showIcon: boolean;
|
||||
/** 显示最大化按钮 */
|
||||
showMaximize: boolean;
|
||||
/** 显示更多按钮 */
|
||||
showMore: boolean;
|
||||
/** 标签页风格 */
|
||||
styleType: TabsStyleType;
|
||||
/** 是否开启鼠标滚轮响应 */
|
||||
wheelable: boolean;
|
||||
}
|
||||
|
||||
interface ThemePreferences {
|
||||
/** 内置主题名 */
|
||||
builtinType: BuiltinThemeType;
|
||||
/** 错误色 */
|
||||
colorDestructive: string;
|
||||
/** 主题色 */
|
||||
colorPrimary: string;
|
||||
/** 成功色 */
|
||||
colorSuccess: string;
|
||||
/** 警告色 */
|
||||
colorWarning: string;
|
||||
/** 当前主题 */
|
||||
mode: ThemeModeType;
|
||||
/** 圆角 */
|
||||
radius: string;
|
||||
/** 是否开启半深色header(只在theme='light'时生效) */
|
||||
semiDarkHeader: boolean;
|
||||
/** 是否开启半深色菜单(只在theme='light'时生效) */
|
||||
semiDarkSidebar: boolean;
|
||||
}
|
||||
|
||||
interface TransitionPreferences {
|
||||
/** 页面切换动画是否启用 */
|
||||
enable: boolean;
|
||||
// /** 是否开启页面加载loading */
|
||||
loading: boolean;
|
||||
/** 页面切换动画 */
|
||||
name: PageTransitionType | string;
|
||||
/** 是否开启页面加载进度动画 */
|
||||
progress: boolean;
|
||||
}
|
||||
|
||||
interface WidgetPreferences {
|
||||
/** 是否启用全屏部件 */
|
||||
fullscreen: boolean;
|
||||
/** 是否启用全局搜索部件 */
|
||||
globalSearch: boolean;
|
||||
/** 是否启用语言切换部件 */
|
||||
languageToggle: boolean;
|
||||
/** 是否开启锁屏功能 */
|
||||
lockScreen: boolean;
|
||||
/** 是否显示通知部件 */
|
||||
notification: boolean;
|
||||
/** 显示刷新按钮 */
|
||||
refresh: boolean;
|
||||
/** 是否显示侧边栏显示/隐藏部件 */
|
||||
sidebarToggle: boolean;
|
||||
/** 是否显示主题切换部件 */
|
||||
themeToggle: boolean;
|
||||
/** 是否显示时区部件 */
|
||||
timezone: boolean;
|
||||
}
|
||||
|
||||
interface Preferences {
|
||||
/** 全局配置 */
|
||||
app: AppPreferences;
|
||||
/** 顶栏配置 */
|
||||
breadcrumb: BreadcrumbPreferences;
|
||||
/** 版权配置 */
|
||||
copyright: CopyrightPreferences;
|
||||
/** 底栏配置 */
|
||||
footer: FooterPreferences;
|
||||
/** 面包屑配置 */
|
||||
header: HeaderPreferences;
|
||||
/** logo配置 */
|
||||
logo: LogoPreferences;
|
||||
/** 导航配置 */
|
||||
navigation: NavigationPreferences;
|
||||
/** 快捷键配置 */
|
||||
shortcutKeys: ShortcutKeyPreferences;
|
||||
/** 侧边栏配置 */
|
||||
sidebar: SidebarPreferences;
|
||||
/** 标签页配置 */
|
||||
tabbar: TabbarPreferences;
|
||||
/** 主题配置 */
|
||||
theme: ThemePreferences;
|
||||
/** 动画配置 */
|
||||
transition: TransitionPreferences;
|
||||
/** 功能配置 */
|
||||
widget: WidgetPreferences;
|
||||
}
|
||||
|
||||
type PreferencesKeys = keyof Preferences;
|
||||
|
||||
interface InitialOptions {
|
||||
namespace: string;
|
||||
overrides?: DeepPartial<Preferences>;
|
||||
}
|
||||
export type {
|
||||
AppPreferences,
|
||||
BreadcrumbPreferences,
|
||||
FooterPreferences,
|
||||
HeaderPreferences,
|
||||
InitialOptions,
|
||||
LogoPreferences,
|
||||
NavigationPreferences,
|
||||
Preferences,
|
||||
PreferencesKeys,
|
||||
ShortcutKeyPreferences,
|
||||
SidebarPreferences,
|
||||
SupportedLanguagesType,
|
||||
TabbarPreferences,
|
||||
ThemePreferences,
|
||||
TransitionPreferences,
|
||||
WidgetPreferences,
|
||||
};
|
||||
52
packages/@core/ui-kit/form-ui/package.json
Normal file
52
packages/@core/ui-kit/form-ui/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@vben-core/form-ui",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/uikit/form-ui"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/composables": "workspace:*",
|
||||
"@vben-core/icons": "workspace:*",
|
||||
"@vben-core/shadcn-ui": "workspace:*",
|
||||
"@vben-core/shared": "workspace:*",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vee-validate/zod": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vee-validate": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-defaults": "catalog:"
|
||||
}
|
||||
}
|
||||
188
packages/@core/ui-kit/form-ui/src/components/form-actions.vue
Normal file
188
packages/@core/ui-kit/form-ui/src/components/form-actions.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRaw, unref, watch } from 'vue';
|
||||
|
||||
import { useSimpleLocale } from '@vben-core/composables';
|
||||
import { VbenExpandableArrow } from '@vben-core/shadcn-ui';
|
||||
import { cn, isFunction, triggerWindowResize } from '@vben-core/shared/utils';
|
||||
|
||||
import { COMPONENT_MAP } from '../config';
|
||||
import { injectFormProps } from '../use-form-context';
|
||||
|
||||
const { $t } = useSimpleLocale();
|
||||
|
||||
const [rootProps, form] = injectFormProps();
|
||||
|
||||
const collapsed = defineModel({ default: false });
|
||||
|
||||
const resetButtonOptions = computed(() => {
|
||||
return {
|
||||
content: `${$t.value('reset')}`,
|
||||
show: true,
|
||||
...unref(rootProps).resetButtonOptions,
|
||||
};
|
||||
});
|
||||
|
||||
const submitButtonOptions = computed(() => {
|
||||
return {
|
||||
content: `${$t.value('submit')}`,
|
||||
show: true,
|
||||
...unref(rootProps).submitButtonOptions,
|
||||
};
|
||||
});
|
||||
|
||||
// const isQueryForm = computed(() => {
|
||||
// return !!unref(rootProps).showCollapseButton;
|
||||
// });
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
const props = unref(rootProps);
|
||||
if (!props.formApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { valid } = await props.formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = toRaw(await props.formApi.getValues()) ?? {};
|
||||
await props.handleSubmit?.(values);
|
||||
}
|
||||
|
||||
async function handleReset(e: Event) {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
const props = unref(rootProps);
|
||||
|
||||
const values = toRaw(await props.formApi?.getValues()) ?? {};
|
||||
|
||||
if (isFunction(props.handleReset)) {
|
||||
await props.handleReset?.(values);
|
||||
} else {
|
||||
form.resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => collapsed.value,
|
||||
() => {
|
||||
const props = unref(rootProps);
|
||||
if (props.collapseTriggerResize) {
|
||||
triggerWindowResize();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const actionWrapperClass = computed(() => {
|
||||
const props = unref(rootProps);
|
||||
const actionLayout = props.actionLayout || 'rowEnd';
|
||||
const actionPosition = props.actionPosition || 'right';
|
||||
|
||||
const cls = [
|
||||
'flex',
|
||||
'items-center',
|
||||
'gap-3',
|
||||
props.compact ? 'pb-2' : 'pb-4',
|
||||
props.layout === 'vertical' ? 'self-end' : 'self-center',
|
||||
props.layout === 'inline' ? '' : 'w-full',
|
||||
props.actionWrapperClass,
|
||||
];
|
||||
|
||||
switch (actionLayout) {
|
||||
case 'newLine': {
|
||||
cls.push('col-span-full');
|
||||
break;
|
||||
}
|
||||
case 'rowEnd': {
|
||||
cls.push('col-[-2/-1]');
|
||||
break;
|
||||
}
|
||||
// 'inline' 不需要额外类名,保持默认
|
||||
}
|
||||
|
||||
switch (actionPosition) {
|
||||
case 'center': {
|
||||
cls.push('justify-center');
|
||||
break;
|
||||
}
|
||||
case 'left': {
|
||||
cls.push('justify-start');
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// case 'right': 默认右对齐
|
||||
cls.push('justify-end');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return cls.join(' ');
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
handleReset,
|
||||
handleSubmit,
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div :class="cn(actionWrapperClass)">
|
||||
<template v-if="rootProps.actionButtonsReverse">
|
||||
<!-- 提交按钮前 -->
|
||||
<slot name="submit-before"></slot>
|
||||
|
||||
<component
|
||||
:is="COMPONENT_MAP.PrimaryButton"
|
||||
v-if="submitButtonOptions.show"
|
||||
type="button"
|
||||
@click="handleSubmit"
|
||||
v-bind="submitButtonOptions"
|
||||
>
|
||||
{{ submitButtonOptions.content }}
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<!-- 重置按钮前 -->
|
||||
<slot name="reset-before"></slot>
|
||||
|
||||
<component
|
||||
:is="COMPONENT_MAP.DefaultButton"
|
||||
v-if="resetButtonOptions.show"
|
||||
type="button"
|
||||
@click="handleReset"
|
||||
v-bind="resetButtonOptions"
|
||||
>
|
||||
{{ resetButtonOptions.content }}
|
||||
</component>
|
||||
|
||||
<template v-if="!rootProps.actionButtonsReverse">
|
||||
<!-- 提交按钮前 -->
|
||||
<slot name="submit-before"></slot>
|
||||
|
||||
<component
|
||||
:is="COMPONENT_MAP.PrimaryButton"
|
||||
v-if="submitButtonOptions.show"
|
||||
type="button"
|
||||
@click="handleSubmit"
|
||||
v-bind="submitButtonOptions"
|
||||
>
|
||||
{{ submitButtonOptions.content }}
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<!-- 展开按钮前 -->
|
||||
<slot name="expand-before"></slot>
|
||||
|
||||
<VbenExpandableArrow
|
||||
class="ml-[-0.3em]"
|
||||
v-if="rootProps.showCollapseButton"
|
||||
v-model:model-value="collapsed"
|
||||
>
|
||||
<span>{{ collapsed ? $t('expand') : $t('collapse') }}</span>
|
||||
</VbenExpandableArrow>
|
||||
|
||||
<!-- 展开按钮后 -->
|
||||
<slot name="expand-after"></slot>
|
||||
</div>
|
||||
</template>
|
||||
621
packages/@core/ui-kit/form-ui/src/form-api.ts
Normal file
621
packages/@core/ui-kit/form-ui/src/form-api.ts
Normal file
@@ -0,0 +1,621 @@
|
||||
import type {
|
||||
FormState,
|
||||
GenericObject,
|
||||
ResetFormOpts,
|
||||
ValidationOptions,
|
||||
} from 'vee-validate';
|
||||
|
||||
import type { ComponentPublicInstance } from 'vue';
|
||||
|
||||
import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import type { FormActions, FormSchema, VbenFormProps } from './types';
|
||||
|
||||
import { isRef, toRaw } from 'vue';
|
||||
|
||||
import { Store } from '@vben-core/shared/store';
|
||||
import {
|
||||
bindMethods,
|
||||
createMerge,
|
||||
formatDate,
|
||||
isDate,
|
||||
isDayjsObject,
|
||||
isFunction,
|
||||
isObject,
|
||||
mergeWithArrayOverride,
|
||||
StateHandler,
|
||||
} from '@vben-core/shared/utils';
|
||||
|
||||
function getDefaultState(): VbenFormProps {
|
||||
return {
|
||||
actionWrapperClass: '',
|
||||
collapsed: false,
|
||||
collapsedRows: 1,
|
||||
collapseTriggerResize: false,
|
||||
commonConfig: {},
|
||||
handleReset: undefined,
|
||||
handleSubmit: undefined,
|
||||
handleValuesChange: undefined,
|
||||
handleCollapsedChange: undefined,
|
||||
layout: 'horizontal',
|
||||
resetButtonOptions: {},
|
||||
schema: [],
|
||||
scrollToFirstError: false,
|
||||
showCollapseButton: false,
|
||||
showDefaultActions: true,
|
||||
submitButtonOptions: {},
|
||||
submitOnChange: false,
|
||||
submitOnEnter: false,
|
||||
wrapperClass: 'grid-cols-1',
|
||||
};
|
||||
}
|
||||
|
||||
export class FormApi {
|
||||
// private api: Pick<VbenFormProps, 'handleReset' | 'handleSubmit'>;
|
||||
public form = {} as FormActions;
|
||||
isMounted = false;
|
||||
|
||||
public state: null | VbenFormProps = null;
|
||||
stateHandler: StateHandler;
|
||||
|
||||
public store: Store<VbenFormProps>;
|
||||
|
||||
/**
|
||||
* 组件实例映射
|
||||
*/
|
||||
private componentRefMap: Map<string, unknown> = new Map();
|
||||
|
||||
// 最后一次点击提交时的表单值
|
||||
private latestSubmissionValues: null | Recordable<any> = null;
|
||||
|
||||
private prevState: null | VbenFormProps = null;
|
||||
|
||||
constructor(options: VbenFormProps = {}) {
|
||||
const { ...storeState } = options;
|
||||
|
||||
const defaultState = getDefaultState();
|
||||
|
||||
this.store = new Store<VbenFormProps>(
|
||||
{
|
||||
...defaultState,
|
||||
...storeState,
|
||||
},
|
||||
{
|
||||
onUpdate: () => {
|
||||
this.prevState = this.state;
|
||||
this.state = this.store.state;
|
||||
this.updateState();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.state = this.store.state;
|
||||
this.stateHandler = new StateHandler();
|
||||
bindMethods(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段组件实例
|
||||
* @param fieldName 字段名
|
||||
* @returns 组件实例
|
||||
*/
|
||||
getFieldComponentRef<T = ComponentPublicInstance>(
|
||||
fieldName: string,
|
||||
): T | undefined {
|
||||
let target = this.componentRefMap.has(fieldName)
|
||||
? (this.componentRefMap.get(fieldName) as ComponentPublicInstance)
|
||||
: undefined;
|
||||
if (
|
||||
target &&
|
||||
target.$.type.name === 'AsyncComponentWrapper' &&
|
||||
target.$.subTree.ref
|
||||
) {
|
||||
if (Array.isArray(target.$.subTree.ref)) {
|
||||
if (
|
||||
target.$.subTree.ref.length > 0 &&
|
||||
isRef(target.$.subTree.ref[0]?.r)
|
||||
) {
|
||||
target = target.$.subTree.ref[0]?.r.value as ComponentPublicInstance;
|
||||
}
|
||||
} else if (isRef(target.$.subTree.ref.r)) {
|
||||
target = target.$.subTree.ref.r.value as ComponentPublicInstance;
|
||||
}
|
||||
}
|
||||
return target as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前聚焦的字段,如果没有聚焦的字段则返回undefined
|
||||
*/
|
||||
getFocusedField() {
|
||||
for (const fieldName of this.componentRefMap.keys()) {
|
||||
const ref = this.getFieldComponentRef(fieldName);
|
||||
if (ref) {
|
||||
let el: HTMLElement | null = null;
|
||||
if (ref instanceof HTMLElement) {
|
||||
el = ref;
|
||||
} else if (ref.$el instanceof HTMLElement) {
|
||||
el = ref.$el;
|
||||
}
|
||||
if (!el) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
el === document.activeElement ||
|
||||
el.contains(document.activeElement)
|
||||
) {
|
||||
return fieldName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getLatestSubmissionValues() {
|
||||
return this.latestSubmissionValues || {};
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
async getValues<T = Recordable<any>>() {
|
||||
const form = await this.getForm();
|
||||
return (form.values ? this.handleRangeTimeValue(form.values) : {}) as T;
|
||||
}
|
||||
|
||||
async isFieldValid(fieldName: string) {
|
||||
const form = await this.getForm();
|
||||
return form.isFieldValid(fieldName);
|
||||
}
|
||||
|
||||
merge(formApi: FormApi) {
|
||||
const chain = [this, formApi];
|
||||
const proxy = new Proxy(formApi, {
|
||||
get(target: any, prop: any) {
|
||||
if (prop === 'merge') {
|
||||
return (nextFormApi: FormApi) => {
|
||||
chain.push(nextFormApi);
|
||||
return proxy;
|
||||
};
|
||||
}
|
||||
if (prop === 'submitAllForm') {
|
||||
return async (needMerge: boolean = true) => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
chain.map(async (api) => {
|
||||
const validateResult = await api.validate();
|
||||
if (!validateResult.valid) {
|
||||
return;
|
||||
}
|
||||
const rawValues = toRaw((await api.getValues()) || {});
|
||||
return rawValues;
|
||||
}),
|
||||
);
|
||||
if (needMerge) {
|
||||
const mergedResults = Object.assign({}, ...results);
|
||||
return mergedResults;
|
||||
}
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
mount(formActions: FormActions, componentRefMap: Map<string, unknown>) {
|
||||
if (!this.isMounted) {
|
||||
Object.assign(this.form, formActions);
|
||||
this.stateHandler.setConditionTrue();
|
||||
this.setLatestSubmissionValues({
|
||||
...toRaw(this.handleRangeTimeValue(this.form.values)),
|
||||
});
|
||||
this.componentRefMap = componentRefMap;
|
||||
this.isMounted = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据字段名移除表单项
|
||||
* @param fields
|
||||
*/
|
||||
async removeSchemaByFields(fields: string[]) {
|
||||
const fieldSet = new Set(fields);
|
||||
const schema = this.state?.schema ?? [];
|
||||
|
||||
const filterSchema = schema.filter((item) => !fieldSet.has(item.fieldName));
|
||||
|
||||
this.setState({
|
||||
schema: filterSchema,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置表单
|
||||
*/
|
||||
async resetForm(
|
||||
state?: Partial<FormState<GenericObject>> | undefined,
|
||||
opts?: Partial<ResetFormOpts>,
|
||||
) {
|
||||
const form = await this.getForm();
|
||||
return form.resetForm(state, opts);
|
||||
}
|
||||
|
||||
async resetValidate() {
|
||||
const form = await this.getForm();
|
||||
const fields = Object.keys(form.errors.value);
|
||||
fields.forEach((field) => {
|
||||
form.setFieldError(field, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到第一个错误字段
|
||||
* @param errors 验证错误对象
|
||||
*/
|
||||
scrollToFirstError(errors: Record<string, any> | string) {
|
||||
// https://github.com/logaretm/vee-validate/discussions/3835
|
||||
const firstErrorFieldName =
|
||||
typeof errors === 'string' ? errors : Object.keys(errors)[0];
|
||||
|
||||
if (!firstErrorFieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
let el = document.querySelector(
|
||||
`[name="${firstErrorFieldName}"]`,
|
||||
) as HTMLElement;
|
||||
|
||||
// 如果通过 name 属性找不到,尝试通过组件引用查找, 正常情况下不会走到这,怕哪天 vee-validate 改了 name 属性有个兜底的
|
||||
if (!el) {
|
||||
const componentRef = this.getFieldComponentRef(firstErrorFieldName);
|
||||
if (componentRef && componentRef.$el instanceof HTMLElement) {
|
||||
el = componentRef.$el;
|
||||
}
|
||||
}
|
||||
|
||||
if (el) {
|
||||
// 滚动到错误字段,添加一些偏移量以确保字段完全可见
|
||||
el.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置表单禁用状态:用于非 Modal 中使用 Form 时,需要 Form 自己控制禁用状态
|
||||
* @author 芋道源码
|
||||
* @param disabled 是否禁用
|
||||
*/
|
||||
setDisabled(disabled: boolean) {
|
||||
this.setState((prev) => ({
|
||||
...prev,
|
||||
commonConfig: { ...prev.commonConfig, disabled },
|
||||
}));
|
||||
}
|
||||
|
||||
async setFieldValue(field: string, value: any, shouldValidate?: boolean) {
|
||||
const form = await this.getForm();
|
||||
form.setFieldValue(field, value, shouldValidate);
|
||||
}
|
||||
|
||||
setLatestSubmissionValues(values: null | Recordable<any>) {
|
||||
this.latestSubmissionValues = { ...toRaw(values) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置表单提交按钮的加载状态:用于非 Modal 中使用 Form 时,需要 Form 自己控制 loading 状态
|
||||
* @author 芋道源码
|
||||
* @param loading 是否加载中
|
||||
*/
|
||||
setLoading(loading: boolean) {
|
||||
this.setState((prev) => ({
|
||||
...prev,
|
||||
submitButtonOptions: { ...prev.submitButtonOptions, loading },
|
||||
}));
|
||||
}
|
||||
|
||||
setState(
|
||||
stateOrFn:
|
||||
| ((prev: VbenFormProps) => Partial<VbenFormProps>)
|
||||
| Partial<VbenFormProps>,
|
||||
) {
|
||||
if (isFunction(stateOrFn)) {
|
||||
this.store.setState((prev) => {
|
||||
return mergeWithArrayOverride(stateOrFn(prev), prev);
|
||||
});
|
||||
} else {
|
||||
this.store.setState((prev) => mergeWithArrayOverride(stateOrFn, prev));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置表单值
|
||||
* @param fields record
|
||||
* @param filterFields 过滤不在schema中定义的字段 默认为true
|
||||
* @param shouldValidate
|
||||
*/
|
||||
async setValues(
|
||||
fields: Record<string, any>,
|
||||
filterFields: boolean = true,
|
||||
shouldValidate: boolean = false,
|
||||
) {
|
||||
const form = await this.getForm();
|
||||
if (!filterFields) {
|
||||
form.setValues(fields, shouldValidate);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并算法有待改进,目前的算法不支持object类型的值。
|
||||
* antd的日期时间相关组件的值类型为dayjs对象
|
||||
* element-plus的日期时间相关组件的值类型可能为Date对象
|
||||
* 以上两种类型需要排除深度合并
|
||||
*/
|
||||
const fieldMergeFn = createMerge((obj, key, value) => {
|
||||
if (key in obj) {
|
||||
obj[key] =
|
||||
!Array.isArray(obj[key]) &&
|
||||
isObject(obj[key]) &&
|
||||
!isDayjsObject(obj[key]) &&
|
||||
!isDate(obj[key])
|
||||
? fieldMergeFn(value, obj[key])
|
||||
: value;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const filteredFields = fieldMergeFn(fields, form.values);
|
||||
form.setValues(filteredFields, shouldValidate);
|
||||
}
|
||||
|
||||
async submitForm(e?: Event) {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
const form = await this.getForm();
|
||||
await form.submitForm();
|
||||
const rawValues = toRaw(await this.getValues());
|
||||
await this.state?.handleSubmit?.(rawValues);
|
||||
|
||||
return rawValues;
|
||||
}
|
||||
|
||||
unmount() {
|
||||
this.form?.resetForm?.();
|
||||
// this.state = null;
|
||||
this.latestSubmissionValues = null;
|
||||
this.isMounted = false;
|
||||
this.stateHandler.reset();
|
||||
}
|
||||
|
||||
updateSchema(schema: Partial<FormSchema>[]) {
|
||||
const updated: Partial<FormSchema>[] = [...schema];
|
||||
const hasField = updated.every(
|
||||
(item) => Reflect.has(item, 'fieldName') && item.fieldName,
|
||||
);
|
||||
|
||||
if (!hasField) {
|
||||
console.error(
|
||||
'All items in the schema array must have a valid `fieldName` property to be updated',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const currentSchema = [...(this.state?.schema ?? [])];
|
||||
|
||||
const updatedMap: Record<string, any> = {};
|
||||
|
||||
updated.forEach((item) => {
|
||||
if (item.fieldName) {
|
||||
updatedMap[item.fieldName] = item;
|
||||
}
|
||||
});
|
||||
|
||||
currentSchema.forEach((schema, index) => {
|
||||
const updatedData = updatedMap[schema.fieldName];
|
||||
if (updatedData) {
|
||||
currentSchema[index] = mergeWithArrayOverride(
|
||||
updatedData,
|
||||
schema,
|
||||
) as FormSchema;
|
||||
}
|
||||
});
|
||||
this.setState({ schema: currentSchema });
|
||||
}
|
||||
|
||||
async validate(opts?: Partial<ValidationOptions>) {
|
||||
const form = await this.getForm();
|
||||
|
||||
const validateResult = await form.validate(opts);
|
||||
|
||||
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
|
||||
console.error('validate error', validateResult?.errors);
|
||||
|
||||
if (this.state?.scrollToFirstError) {
|
||||
this.scrollToFirstError(validateResult.errors);
|
||||
}
|
||||
}
|
||||
return validateResult;
|
||||
}
|
||||
|
||||
async validateAndSubmitForm() {
|
||||
const form = await this.getForm();
|
||||
const { valid, errors } = await form.validate();
|
||||
if (!valid) {
|
||||
if (this.state?.scrollToFirstError) {
|
||||
this.scrollToFirstError(errors);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return await this.submitForm();
|
||||
}
|
||||
|
||||
async validateField(fieldName: string, opts?: Partial<ValidationOptions>) {
|
||||
const form = await this.getForm();
|
||||
const validateResult = await form.validateField(fieldName, opts);
|
||||
|
||||
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
|
||||
console.error('validate error', validateResult?.errors);
|
||||
|
||||
if (this.state?.scrollToFirstError) {
|
||||
this.scrollToFirstError(fieldName);
|
||||
}
|
||||
}
|
||||
return validateResult;
|
||||
}
|
||||
|
||||
private async getForm() {
|
||||
if (!this.isMounted) {
|
||||
// 等待form挂载
|
||||
await this.stateHandler.waitForCondition();
|
||||
}
|
||||
if (!this.form?.meta) {
|
||||
throw new Error('<VbenForm /> is not mounted');
|
||||
}
|
||||
return this.form;
|
||||
}
|
||||
|
||||
private handleMultiFields = (originValues: Record<string, any>) => {
|
||||
const arrayToStringFields = this.state?.arrayToStringFields;
|
||||
if (!arrayToStringFields || !Array.isArray(arrayToStringFields)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const processFields = (fields: string[], separator: string = ',') => {
|
||||
this.processFields(fields, separator, originValues, (value, sep) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(sep);
|
||||
} else if (typeof value === 'string') {
|
||||
// 处理空字符串的情况
|
||||
if (value === '') {
|
||||
return [];
|
||||
}
|
||||
// 处理复杂分隔符的情况
|
||||
const escapedSeparator = sep.replaceAll(
|
||||
/[.*+?^${}()|[\]\\]/g,
|
||||
String.raw`\$&`,
|
||||
);
|
||||
return value.split(new RegExp(escapedSeparator));
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理简单数组格式 ['field1', 'field2', ';'] 或 ['field1', 'field2']
|
||||
if (arrayToStringFields.every((item) => typeof item === 'string')) {
|
||||
const lastItem =
|
||||
arrayToStringFields[arrayToStringFields.length - 1] || '';
|
||||
const fields =
|
||||
lastItem.length === 1
|
||||
? arrayToStringFields.slice(0, -1)
|
||||
: arrayToStringFields;
|
||||
const separator = lastItem.length === 1 ? lastItem : ',';
|
||||
processFields(fields, separator);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理嵌套数组格式 [['field1'], ';']
|
||||
arrayToStringFields.forEach((fieldConfig) => {
|
||||
if (Array.isArray(fieldConfig)) {
|
||||
const [fields, separator = ','] = fieldConfig;
|
||||
// 根据类型定义,fields 应该始终是字符串数组
|
||||
if (!Array.isArray(fields)) {
|
||||
console.warn(
|
||||
`Invalid field configuration: fields should be an array of strings, got ${typeof fields}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
processFields(fields, separator);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private handleRangeTimeValue = (originValues: Record<string, any>) => {
|
||||
const values = { ...originValues };
|
||||
const fieldMappingTime = this.state?.fieldMappingTime;
|
||||
|
||||
this.handleMultiFields(values);
|
||||
if (!fieldMappingTime || !Array.isArray(fieldMappingTime)) {
|
||||
return values;
|
||||
}
|
||||
|
||||
fieldMappingTime.forEach(
|
||||
([field, [startTimeKey, endTimeKey], format = 'YYYY-MM-DD']) => {
|
||||
if (startTimeKey && endTimeKey && values[field] === null) {
|
||||
Reflect.deleteProperty(values, startTimeKey);
|
||||
Reflect.deleteProperty(values, endTimeKey);
|
||||
// delete values[startTimeKey];
|
||||
// delete values[endTimeKey];
|
||||
}
|
||||
|
||||
if (!values[field]) {
|
||||
Reflect.deleteProperty(values, field);
|
||||
// delete values[field];
|
||||
return;
|
||||
}
|
||||
|
||||
const [startTime, endTime] = values[field];
|
||||
if (format === null) {
|
||||
values[startTimeKey] = startTime;
|
||||
values[endTimeKey] = endTime;
|
||||
} else if (isFunction(format)) {
|
||||
values[startTimeKey] = format(startTime, startTimeKey);
|
||||
values[endTimeKey] = format(endTime, endTimeKey);
|
||||
} else {
|
||||
const [startTimeFormat, endTimeFormat] = Array.isArray(format)
|
||||
? format
|
||||
: [format, format];
|
||||
|
||||
values[startTimeKey] = startTime
|
||||
? formatDate(startTime, startTimeFormat)
|
||||
: undefined;
|
||||
values[endTimeKey] = endTime
|
||||
? formatDate(endTime, endTimeFormat)
|
||||
: undefined;
|
||||
}
|
||||
// delete values[field];
|
||||
Reflect.deleteProperty(values, field);
|
||||
},
|
||||
);
|
||||
return values;
|
||||
};
|
||||
|
||||
private processFields = (
|
||||
fields: string[],
|
||||
separator: string,
|
||||
originValues: Record<string, any>,
|
||||
transformFn: (value: any, separator: string) => any,
|
||||
) => {
|
||||
fields.forEach((field) => {
|
||||
const value = originValues[field];
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
originValues[field] = transformFn(value, separator);
|
||||
});
|
||||
};
|
||||
|
||||
private updateState() {
|
||||
const currentSchema = this.state?.schema ?? [];
|
||||
const prevSchema = this.prevState?.schema ?? [];
|
||||
// 进行了删除schema操作
|
||||
if (currentSchema.length < prevSchema.length) {
|
||||
const currentFields = new Set(
|
||||
currentSchema.map((item) => item.fieldName),
|
||||
);
|
||||
const deletedSchema = prevSchema.filter(
|
||||
(item) => !currentFields.has(item.fieldName),
|
||||
);
|
||||
for (const schema of deletedSchema) {
|
||||
this.form?.setFieldValue?.(schema.fieldName, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
397
packages/@core/ui-kit/form-ui/src/form-render/form-field.vue
Normal file
397
packages/@core/ui-kit/form-ui/src/form-render/form-field.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<script setup lang="ts">
|
||||
import type { ZodType } from 'zod';
|
||||
|
||||
import type { FormSchema, MaybeComponentProps } from '../types';
|
||||
|
||||
import { computed, nextTick, onUnmounted, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import { CircleAlert } from '@vben-core/icons';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
VbenRenderContent,
|
||||
VbenTooltip,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { cn, isFunction, isObject, isString } from '@vben-core/shared/utils';
|
||||
|
||||
import { toTypedSchema } from '@vee-validate/zod';
|
||||
import { useFieldError, useFormValues } from 'vee-validate';
|
||||
|
||||
import { injectComponentRefMap } from '../use-form-context';
|
||||
import { injectRenderFormProps, useFormContext } from './context';
|
||||
import useDependencies from './dependencies';
|
||||
import FormLabel from './form-label.vue';
|
||||
import { isEventObjectLike } from './helper';
|
||||
|
||||
interface Props extends FormSchema {}
|
||||
|
||||
const {
|
||||
colon,
|
||||
commonComponentProps,
|
||||
component,
|
||||
componentProps,
|
||||
dependencies,
|
||||
description,
|
||||
disabled,
|
||||
disabledOnChangeListener,
|
||||
disabledOnInputListener,
|
||||
emptyStateValue,
|
||||
fieldName,
|
||||
formFieldProps,
|
||||
hide,
|
||||
label,
|
||||
labelClass,
|
||||
labelWidth,
|
||||
modelPropName,
|
||||
renderComponentContent,
|
||||
rules,
|
||||
} = defineProps<
|
||||
Props & {
|
||||
commonComponentProps: MaybeComponentProps;
|
||||
}
|
||||
>();
|
||||
|
||||
const { componentBindEventMap, componentMap, isVertical } = useFormContext();
|
||||
const formRenderProps = injectRenderFormProps();
|
||||
const values = useFormValues();
|
||||
const errors = useFieldError(fieldName);
|
||||
const fieldComponentRef = useTemplateRef<HTMLInputElement>('fieldComponentRef');
|
||||
const formApi = formRenderProps.form;
|
||||
const compact = computed(() => formRenderProps.compact);
|
||||
const isInValid = computed(() => errors.value?.length > 0);
|
||||
|
||||
const FieldComponent = computed(() => {
|
||||
const finalComponent = isString(component)
|
||||
? componentMap.value[component]
|
||||
: component;
|
||||
if (!finalComponent) {
|
||||
// 组件未注册
|
||||
console.warn(`Component ${component} is not registered`);
|
||||
}
|
||||
return finalComponent;
|
||||
});
|
||||
|
||||
const {
|
||||
dynamicComponentProps,
|
||||
dynamicRules,
|
||||
isDisabled,
|
||||
isIf,
|
||||
isRequired,
|
||||
isShow,
|
||||
} = useDependencies(() => dependencies);
|
||||
|
||||
const labelStyle = computed(() => {
|
||||
return labelClass?.includes('w-') || isVertical.value
|
||||
? {}
|
||||
: {
|
||||
width: `${labelWidth}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const currentRules = computed(() => {
|
||||
return dynamicRules.value || rules;
|
||||
});
|
||||
|
||||
const visible = computed(() => {
|
||||
return !hide && isIf.value && isShow.value;
|
||||
});
|
||||
|
||||
const shouldRequired = computed(() => {
|
||||
if (!visible.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentRules.value) {
|
||||
return isRequired.value;
|
||||
}
|
||||
|
||||
if (isRequired.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isString(currentRules.value)) {
|
||||
return ['mobileRequired', 'required', 'selectRequired'].includes(
|
||||
currentRules.value,
|
||||
);
|
||||
}
|
||||
|
||||
let isOptional = currentRules?.value?.isOptional?.();
|
||||
|
||||
// 如果有设置默认值,则不是必填,需要特殊处理
|
||||
const typeName = currentRules?.value?._def?.typeName;
|
||||
if (typeName === 'ZodDefault') {
|
||||
const innerType = currentRules?.value?._def.innerType;
|
||||
if (innerType) {
|
||||
isOptional = innerType.isOptional?.();
|
||||
}
|
||||
}
|
||||
|
||||
return !isOptional;
|
||||
});
|
||||
|
||||
const fieldRules = computed(() => {
|
||||
if (!visible.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rules = currentRules.value;
|
||||
if (!rules) {
|
||||
return isRequired.value ? 'required' : null;
|
||||
}
|
||||
|
||||
if (isString(rules)) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
const isOptional = !shouldRequired.value;
|
||||
if (!isOptional) {
|
||||
const unwrappedRules = (rules as any)?.unwrap?.();
|
||||
if (unwrappedRules) {
|
||||
rules = unwrappedRules;
|
||||
}
|
||||
}
|
||||
return toTypedSchema(rules as ZodType);
|
||||
});
|
||||
|
||||
const computedProps = computed(() => {
|
||||
const finalComponentProps = isFunction(componentProps)
|
||||
? componentProps(values.value, formApi!)
|
||||
: componentProps;
|
||||
|
||||
return {
|
||||
...commonComponentProps,
|
||||
...finalComponentProps,
|
||||
...dynamicComponentProps.value,
|
||||
};
|
||||
});
|
||||
|
||||
watch(
|
||||
() => computedProps.value?.autofocus,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
nextTick(() => {
|
||||
autofocus();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const shouldDisabled = computed(() => {
|
||||
return isDisabled.value || disabled || computedProps.value?.disabled;
|
||||
});
|
||||
|
||||
const customContentRender = computed(() => {
|
||||
if (!isFunction(renderComponentContent)) {
|
||||
return {};
|
||||
}
|
||||
return renderComponentContent(values.value, formApi!);
|
||||
});
|
||||
|
||||
const renderContentKey = computed(() => {
|
||||
return Object.keys(customContentRender.value);
|
||||
});
|
||||
|
||||
const fieldProps = computed(() => {
|
||||
const rules = fieldRules.value;
|
||||
return {
|
||||
keepValue: true,
|
||||
label: isString(label) ? label : '',
|
||||
...(rules ? { rules } : {}),
|
||||
...(formFieldProps as Record<string, any>),
|
||||
};
|
||||
});
|
||||
|
||||
function fieldBindEvent(slotProps: Record<string, any>) {
|
||||
const modelValue = slotProps.componentField.modelValue;
|
||||
const handler = slotProps.componentField['onUpdate:modelValue'];
|
||||
|
||||
const bindEventField =
|
||||
modelPropName ||
|
||||
(isString(component) ? componentBindEventMap.value?.[component] : null);
|
||||
|
||||
let value = modelValue;
|
||||
// antd design 的一些组件会传递一个 event 对象
|
||||
if (modelValue && isObject(modelValue) && bindEventField) {
|
||||
value = isEventObjectLike(modelValue)
|
||||
? modelValue?.target?.[bindEventField]
|
||||
: (modelValue?.[bindEventField] ?? modelValue);
|
||||
}
|
||||
|
||||
if (bindEventField) {
|
||||
return {
|
||||
[`onUpdate:${bindEventField}`]: handler,
|
||||
[bindEventField]: value === undefined ? emptyStateValue : value,
|
||||
onChange: disabledOnChangeListener
|
||||
? undefined
|
||||
: (e: Record<string, any>) => {
|
||||
const shouldUnwrap = isEventObjectLike(e);
|
||||
const onChange = slotProps?.componentField?.onChange;
|
||||
if (!shouldUnwrap) {
|
||||
return onChange?.(e);
|
||||
}
|
||||
|
||||
return onChange?.(e?.target?.[bindEventField] ?? e);
|
||||
},
|
||||
...(disabledOnInputListener ? { onInput: undefined } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...(disabledOnInputListener ? { onInput: undefined } : {}),
|
||||
...(disabledOnChangeListener ? { onChange: undefined } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createComponentProps(slotProps: Record<string, any>) {
|
||||
const bindEvents = fieldBindEvent(slotProps);
|
||||
|
||||
const binds = {
|
||||
...slotProps.componentField,
|
||||
...computedProps.value,
|
||||
...bindEvents,
|
||||
...(Reflect.has(computedProps.value, 'onChange')
|
||||
? { onChange: computedProps.value.onChange }
|
||||
: {}),
|
||||
...(Reflect.has(computedProps.value, 'onInput')
|
||||
? { onInput: computedProps.value.onInput }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return binds;
|
||||
}
|
||||
|
||||
function autofocus() {
|
||||
if (
|
||||
fieldComponentRef.value &&
|
||||
isFunction(fieldComponentRef.value.focus) &&
|
||||
// 检查当前是否有元素被聚焦
|
||||
document.activeElement !== fieldComponentRef.value
|
||||
) {
|
||||
fieldComponentRef.value?.focus?.();
|
||||
}
|
||||
}
|
||||
const componentRefMap = injectComponentRefMap();
|
||||
watch(fieldComponentRef, (componentRef) => {
|
||||
componentRefMap?.set(fieldName, componentRef);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
if (componentRefMap?.has(fieldName)) {
|
||||
componentRefMap.delete(fieldName);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField
|
||||
v-if="!hide && isIf"
|
||||
v-bind="fieldProps"
|
||||
v-slot="slotProps"
|
||||
:name="fieldName"
|
||||
>
|
||||
<FormItem
|
||||
v-show="isShow"
|
||||
:class="{
|
||||
'form-valid-error': isInValid,
|
||||
'form-is-required': shouldRequired,
|
||||
'flex-col': isVertical,
|
||||
'flex-row items-center': !isVertical,
|
||||
'pb-4': !compact,
|
||||
'pb-2': compact,
|
||||
}"
|
||||
class="relative flex"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<FormLabel
|
||||
v-if="!hideLabel"
|
||||
:class="
|
||||
cn(
|
||||
'flex leading-6',
|
||||
{
|
||||
'mr-2 flex-shrink-0 justify-end': !isVertical,
|
||||
'mb-1 flex-row': isVertical,
|
||||
},
|
||||
labelClass,
|
||||
)
|
||||
"
|
||||
:help="help"
|
||||
:colon="colon"
|
||||
:label="label"
|
||||
:required="shouldRequired && !hideRequiredMark"
|
||||
:style="labelStyle"
|
||||
>
|
||||
<template v-if="label">
|
||||
<VbenRenderContent :content="label" />
|
||||
</template>
|
||||
</FormLabel>
|
||||
<div class="flex-auto overflow-hidden p-[1px]">
|
||||
<div :class="cn('relative flex w-full items-center', wrapperClass)">
|
||||
<FormControl :class="cn(controlClass)">
|
||||
<slot
|
||||
v-bind="{
|
||||
...slotProps,
|
||||
...createComponentProps(slotProps),
|
||||
disabled: shouldDisabled,
|
||||
isInValid,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="FieldComponent"
|
||||
ref="fieldComponentRef"
|
||||
:class="{
|
||||
'border-destructive hover:border-destructive/80 focus:border-destructive focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
|
||||
isInValid,
|
||||
}"
|
||||
v-bind="createComponentProps(slotProps)"
|
||||
:disabled="shouldDisabled"
|
||||
>
|
||||
<template
|
||||
v-for="name in renderContentKey"
|
||||
:key="name"
|
||||
#[name]="renderSlotProps"
|
||||
>
|
||||
<VbenRenderContent
|
||||
:content="customContentRender[name]"
|
||||
v-bind="{ ...renderSlotProps, formContext: slotProps }"
|
||||
/>
|
||||
</template>
|
||||
<!-- <slot></slot> -->
|
||||
</component>
|
||||
<VbenTooltip
|
||||
v-if="compact && isInValid"
|
||||
:delay-duration="300"
|
||||
side="left"
|
||||
>
|
||||
<template #trigger>
|
||||
<slot name="trigger">
|
||||
<CircleAlert
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex size-5 cursor-pointer text-foreground/80 hover:text-foreground',
|
||||
)
|
||||
"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
<FormMessage />
|
||||
</VbenTooltip>
|
||||
</slot>
|
||||
</FormControl>
|
||||
<!-- 自定义后缀 -->
|
||||
<div v-if="suffix" class="ml-1">
|
||||
<VbenRenderContent :content="suffix" />
|
||||
</div>
|
||||
<FormDescription v-if="description" class="ml-1">
|
||||
<VbenRenderContent :content="description" />
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
<Transition name="slide-up" v-if="!compact">
|
||||
<FormMessage class="absolute" />
|
||||
</Transition>
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
31
packages/@core/ui-kit/form-ui/src/form-render/form-label.vue
Normal file
31
packages/@core/ui-kit/form-ui/src/form-render/form-label.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { CustomRenderType } from '../types';
|
||||
|
||||
import {
|
||||
FormLabel,
|
||||
VbenHelpTooltip,
|
||||
VbenRenderContent,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
colon?: boolean;
|
||||
help?: CustomRenderType;
|
||||
label?: CustomRenderType;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormLabel :class="cn('flex items-center', props.class)">
|
||||
<span v-if="required" class="mr-[2px] text-destructive">*</span>
|
||||
<slot></slot>
|
||||
<VbenHelpTooltip v-if="help" trigger-class="size-3.5 ml-1">
|
||||
<VbenRenderContent :content="help" />
|
||||
</VbenHelpTooltip>
|
||||
<span v-if="colon && label" class="ml-[2px]">:</span>
|
||||
</FormLabel>
|
||||
</template>
|
||||
191
packages/@core/ui-kit/form-ui/src/form-render/form.vue
Normal file
191
packages/@core/ui-kit/form-ui/src/form-render/form.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import type { GenericObject } from 'vee-validate';
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
|
||||
import type {
|
||||
FormCommonConfig,
|
||||
FormRenderProps,
|
||||
FormSchema,
|
||||
FormShape,
|
||||
} from '../types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Form } from '@vben-core/shadcn-ui';
|
||||
import {
|
||||
cn,
|
||||
isFunction,
|
||||
isString,
|
||||
mergeWithArrayOverride,
|
||||
} from '@vben-core/shared/utils';
|
||||
|
||||
import { provideFormRenderProps } from './context';
|
||||
import { useExpandable } from './expandable';
|
||||
import FormField from './form-field.vue';
|
||||
import { getBaseRules, getDefaultValueInZodStack } from './helper';
|
||||
|
||||
interface Props extends FormRenderProps {}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<Props & { globalCommonConfig?: FormCommonConfig }>(),
|
||||
{
|
||||
collapsedRows: 1,
|
||||
commonConfig: () => ({}),
|
||||
globalCommonConfig: () => ({}),
|
||||
showCollapseButton: false,
|
||||
wrapperClass: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3',
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
submit: [event: any];
|
||||
}>();
|
||||
|
||||
const wrapperClass = computed(() => {
|
||||
const cls = ['flex'];
|
||||
if (props.layout === 'inline') {
|
||||
cls.push('flex-wrap gap-x-2');
|
||||
} else {
|
||||
cls.push(props.compact ? 'gap-x-2' : 'gap-x-4', 'flex-col grid');
|
||||
}
|
||||
return cn(...cls, props.wrapperClass);
|
||||
});
|
||||
|
||||
provideFormRenderProps(props);
|
||||
|
||||
const { isCalculated, keepFormItemIndex, wrapperRef } = useExpandable(props);
|
||||
|
||||
const shapes = computed(() => {
|
||||
const resultShapes: FormShape[] = [];
|
||||
props.schema?.forEach((schema) => {
|
||||
const { fieldName } = schema;
|
||||
const rules = schema.rules as ZodTypeAny;
|
||||
|
||||
let typeName = '';
|
||||
if (rules && !isString(rules)) {
|
||||
typeName = rules._def.typeName;
|
||||
}
|
||||
|
||||
const baseRules = getBaseRules(rules) as ZodTypeAny;
|
||||
|
||||
resultShapes.push({
|
||||
default: getDefaultValueInZodStack(rules),
|
||||
fieldName,
|
||||
required: !['ZodNullable', 'ZodOptional'].includes(typeName),
|
||||
rules: baseRules,
|
||||
});
|
||||
});
|
||||
return resultShapes;
|
||||
});
|
||||
|
||||
const formComponent = computed(() => (props.form ? 'form' : Form));
|
||||
|
||||
const formComponentProps = computed(() => {
|
||||
return props.form
|
||||
? {
|
||||
onSubmit: props.form.handleSubmit((val) => emits('submit', val)),
|
||||
}
|
||||
: {
|
||||
onSubmit: (val: GenericObject) => emits('submit', val),
|
||||
};
|
||||
});
|
||||
|
||||
const formCollapsed = computed(() => {
|
||||
return props.collapsed && isCalculated.value;
|
||||
});
|
||||
|
||||
const computedSchema = computed(
|
||||
(): (Omit<FormSchema, 'formFieldProps'> & {
|
||||
commonComponentProps: Record<string, any>;
|
||||
formFieldProps: Record<string, any>;
|
||||
})[] => {
|
||||
const {
|
||||
colon = false,
|
||||
componentProps = {},
|
||||
controlClass = '',
|
||||
disabled,
|
||||
disabledOnChangeListener = true,
|
||||
disabledOnInputListener = true,
|
||||
emptyStateValue = undefined,
|
||||
formFieldProps = {},
|
||||
formItemClass = '',
|
||||
hideLabel = false,
|
||||
hideRequiredMark = false,
|
||||
labelClass = '',
|
||||
labelWidth = 100,
|
||||
modelPropName = '',
|
||||
wrapperClass = '',
|
||||
} = mergeWithArrayOverride(props.commonConfig, props.globalCommonConfig);
|
||||
return (props.schema || []).map((schema, index) => {
|
||||
const keepIndex = keepFormItemIndex.value;
|
||||
|
||||
const hidden =
|
||||
// 折叠状态 & 显示折叠按钮 & 当前索引大于保留索引
|
||||
props.showCollapseButton && !!formCollapsed.value && keepIndex
|
||||
? keepIndex <= index
|
||||
: false;
|
||||
|
||||
// 处理函数形式的formItemClass
|
||||
let resolvedSchemaFormItemClass = schema.formItemClass;
|
||||
if (isFunction(schema.formItemClass)) {
|
||||
try {
|
||||
resolvedSchemaFormItemClass = schema.formItemClass();
|
||||
} catch (error) {
|
||||
console.error('Error calling formItemClass function:', error);
|
||||
resolvedSchemaFormItemClass = '';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
colon,
|
||||
disabled,
|
||||
disabledOnChangeListener,
|
||||
disabledOnInputListener,
|
||||
emptyStateValue,
|
||||
hideLabel,
|
||||
hideRequiredMark,
|
||||
labelWidth,
|
||||
modelPropName,
|
||||
wrapperClass,
|
||||
...schema,
|
||||
commonComponentProps: componentProps,
|
||||
componentProps: schema.componentProps,
|
||||
controlClass: cn(controlClass, schema.controlClass),
|
||||
formFieldProps: {
|
||||
...formFieldProps,
|
||||
...schema.formFieldProps,
|
||||
},
|
||||
formItemClass: cn(
|
||||
'flex-shrink-0',
|
||||
{ hidden },
|
||||
formItemClass,
|
||||
resolvedSchemaFormItemClass,
|
||||
),
|
||||
labelClass: cn(labelClass, schema.labelClass),
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="formComponent" v-bind="formComponentProps">
|
||||
<div ref="wrapperRef" :class="wrapperClass">
|
||||
<template v-for="cSchema in computedSchema" :key="cSchema.fieldName">
|
||||
<!-- <div v-if="$slots[cSchema.fieldName]" :class="cSchema.formItemClass">
|
||||
<slot :definition="cSchema" :name="cSchema.fieldName"> </slot>
|
||||
</div> -->
|
||||
<FormField
|
||||
v-bind="cSchema"
|
||||
:class="cSchema.formItemClass"
|
||||
:rules="cSchema.rules"
|
||||
>
|
||||
<template #default="slotProps">
|
||||
<slot v-bind="slotProps" :name="cSchema.fieldName"> </slot>
|
||||
</template>
|
||||
</FormField>
|
||||
</template>
|
||||
<slot :shapes="shapes"></slot>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
476
packages/@core/ui-kit/form-ui/src/types.ts
Normal file
476
packages/@core/ui-kit/form-ui/src/types.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import type { FieldOptions, FormContext, GenericObject } from 'vee-validate';
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
|
||||
import type { Component, HtmlHTMLAttributes, Ref } from 'vue';
|
||||
|
||||
import type { VbenButtonProps } from '@vben-core/shadcn-ui';
|
||||
import type { ClassType, MaybeComputedRef } from '@vben-core/typings';
|
||||
|
||||
import type { FormApi } from './form-api';
|
||||
|
||||
export type FormLayout = 'horizontal' | 'inline' | 'vertical';
|
||||
|
||||
export type BaseFormComponentType =
|
||||
| 'DefaultButton'
|
||||
| 'PrimaryButton'
|
||||
| 'VbenCheckbox'
|
||||
| 'VbenInput'
|
||||
| 'VbenInputPassword'
|
||||
| 'VbenPinInput'
|
||||
| 'VbenSelect'
|
||||
| (Record<never, never> & string);
|
||||
|
||||
type Breakpoints = '2xl:' | '3xl:' | '' | 'lg:' | 'md:' | 'sm:' | 'xl:';
|
||||
|
||||
type GridCols = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13;
|
||||
|
||||
export type WrapperClassType =
|
||||
| `${Breakpoints}grid-cols-${GridCols}`
|
||||
| (Record<never, never> & string);
|
||||
|
||||
export type FormItemClassType =
|
||||
| `${Breakpoints}cols-end-${'auto' | GridCols}`
|
||||
| `${Breakpoints}cols-span-${'auto' | 'full' | GridCols}`
|
||||
| `${Breakpoints}cols-start-${'auto' | GridCols}`
|
||||
| (Record<never, never> & string)
|
||||
| WrapperClassType;
|
||||
|
||||
export type FormFieldOptions = Partial<
|
||||
FieldOptions & {
|
||||
validateOnBlur?: boolean;
|
||||
validateOnChange?: boolean;
|
||||
validateOnInput?: boolean;
|
||||
validateOnModelUpdate?: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface FormShape {
|
||||
/** 默认值 */
|
||||
default?: any;
|
||||
/** 字段名 */
|
||||
fieldName: string;
|
||||
/** 是否必填 */
|
||||
required?: boolean;
|
||||
rules?: ZodTypeAny;
|
||||
}
|
||||
|
||||
export type MaybeComponentPropKey =
|
||||
| 'options'
|
||||
| 'placeholder'
|
||||
| 'title'
|
||||
| keyof HtmlHTMLAttributes
|
||||
| (Record<never, never> & string);
|
||||
|
||||
export type MaybeComponentProps = { [K in MaybeComponentPropKey]?: any };
|
||||
|
||||
export type FormActions = FormContext<GenericObject>;
|
||||
|
||||
export type CustomRenderType = (() => Component | string) | string;
|
||||
|
||||
export type FormSchemaRuleType =
|
||||
| 'mobile'
|
||||
| 'mobileRequired'
|
||||
| 'required'
|
||||
| 'selectRequired'
|
||||
| null
|
||||
| (Record<never, never> & string)
|
||||
| ZodTypeAny;
|
||||
|
||||
type FormItemDependenciesCondition<T = boolean | PromiseLike<boolean>> = (
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
) => T;
|
||||
|
||||
type FormItemDependenciesConditionWithRules = (
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
) => FormSchemaRuleType | PromiseLike<FormSchemaRuleType>;
|
||||
|
||||
type FormItemDependenciesConditionWithProps = (
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
) => MaybeComponentProps | PromiseLike<MaybeComponentProps>;
|
||||
|
||||
export interface FormItemDependencies {
|
||||
/**
|
||||
* 组件参数
|
||||
* @returns 组件参数
|
||||
*/
|
||||
componentProps?: FormItemDependenciesConditionWithProps;
|
||||
/**
|
||||
* 是否禁用
|
||||
* @returns 是否禁用
|
||||
*/
|
||||
disabled?: boolean | FormItemDependenciesCondition;
|
||||
/**
|
||||
* 是否渲染(删除dom)
|
||||
* @returns 是否渲染
|
||||
*/
|
||||
if?: boolean | FormItemDependenciesCondition;
|
||||
/**
|
||||
* 是否必填
|
||||
* @returns 是否必填
|
||||
*/
|
||||
required?: FormItemDependenciesCondition;
|
||||
/**
|
||||
* 字段规则
|
||||
*/
|
||||
rules?: FormItemDependenciesConditionWithRules;
|
||||
/**
|
||||
* 是否隐藏(Css)
|
||||
* @returns 是否隐藏
|
||||
*/
|
||||
show?: boolean | FormItemDependenciesCondition;
|
||||
/**
|
||||
* 任意触发都会执行
|
||||
*/
|
||||
trigger?: FormItemDependenciesCondition<void>;
|
||||
/**
|
||||
* 触发字段
|
||||
*/
|
||||
triggerFields: string[];
|
||||
}
|
||||
|
||||
type ComponentProps =
|
||||
| ((
|
||||
value: Partial<Record<string, any>>,
|
||||
actions: FormActions,
|
||||
) => MaybeComponentProps)
|
||||
| MaybeComponentProps;
|
||||
|
||||
export interface FormCommonConfig {
|
||||
/**
|
||||
* 在Label后显示一个冒号
|
||||
*/
|
||||
colon?: boolean;
|
||||
/**
|
||||
* 所有表单项的props
|
||||
*/
|
||||
componentProps?: ComponentProps;
|
||||
/**
|
||||
* 所有表单项的控件样式
|
||||
*/
|
||||
controlClass?: string;
|
||||
/**
|
||||
* 所有表单项的禁用状态
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 是否禁用所有表单项的change事件监听
|
||||
* @default true
|
||||
*/
|
||||
disabledOnChangeListener?: boolean;
|
||||
/**
|
||||
* 是否禁用所有表单项的input事件监听
|
||||
* @default true
|
||||
*/
|
||||
disabledOnInputListener?: boolean;
|
||||
/**
|
||||
* 所有表单项的空状态值,默认都是undefined,naive-ui的空状态值是null
|
||||
*/
|
||||
emptyStateValue?: null | undefined;
|
||||
/**
|
||||
* 所有表单项的控件样式
|
||||
* @default {}
|
||||
*/
|
||||
formFieldProps?: FormFieldOptions;
|
||||
/**
|
||||
* 所有表单项的栅格布局,支持函数形式
|
||||
* @default ""
|
||||
*/
|
||||
formItemClass?: (() => string) | string;
|
||||
/**
|
||||
* 隐藏所有表单项label
|
||||
* @default false
|
||||
*/
|
||||
hideLabel?: boolean;
|
||||
/**
|
||||
* 是否隐藏必填标记
|
||||
* @default false
|
||||
*/
|
||||
hideRequiredMark?: boolean;
|
||||
/**
|
||||
* 所有表单项的label样式
|
||||
* @default ""
|
||||
*/
|
||||
labelClass?: string;
|
||||
/**
|
||||
* 所有表单项的label宽度
|
||||
*/
|
||||
labelWidth?: number;
|
||||
/**
|
||||
* 所有表单项的model属性名
|
||||
* @default "modelValue"
|
||||
*/
|
||||
modelPropName?: string;
|
||||
/**
|
||||
* 所有表单项的wrapper样式
|
||||
*/
|
||||
wrapperClass?: string;
|
||||
}
|
||||
|
||||
type RenderComponentContentType = (
|
||||
value: Partial<Record<string, any>>,
|
||||
api: FormActions,
|
||||
) => Record<string, any>;
|
||||
|
||||
export type HandleSubmitFn = (
|
||||
values: Record<string, any>,
|
||||
) => Promise<void> | void;
|
||||
|
||||
export type HandleResetFn = (
|
||||
values: Record<string, any>,
|
||||
) => Promise<void> | void;
|
||||
|
||||
export type FieldMappingTime = [
|
||||
string,
|
||||
[string, string],
|
||||
(
|
||||
| ((value: any, fieldName: string) => any)
|
||||
| [string, string]
|
||||
| null
|
||||
| string
|
||||
)?,
|
||||
][];
|
||||
|
||||
export type ArrayToStringFields = Array<
|
||||
| [string[], string?] // 嵌套数组格式,可选分隔符
|
||||
| string // 单个字段,使用默认分隔符
|
||||
| string[] // 简单数组格式,最后一个元素可以是分隔符
|
||||
>;
|
||||
|
||||
export interface FormSchema<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
> extends FormCommonConfig {
|
||||
/** 组件 */
|
||||
component: Component | T;
|
||||
/** 组件参数 */
|
||||
componentProps?: ComponentProps;
|
||||
/** 默认值 */
|
||||
defaultValue?: any;
|
||||
/** 依赖 */
|
||||
dependencies?: FormItemDependencies;
|
||||
/** 描述 */
|
||||
description?: CustomRenderType;
|
||||
/** 字段名 */
|
||||
fieldName: string;
|
||||
/** 帮助信息 */
|
||||
help?: CustomRenderType;
|
||||
/** 是否隐藏表单项 */
|
||||
hide?: boolean;
|
||||
/** 表单项 */
|
||||
label?: CustomRenderType;
|
||||
// 自定义组件内部渲染
|
||||
renderComponentContent?: RenderComponentContentType;
|
||||
/** 字段规则 */
|
||||
rules?: FormSchemaRuleType;
|
||||
/** 后缀 */
|
||||
suffix?: CustomRenderType;
|
||||
}
|
||||
|
||||
export interface FormFieldProps extends FormSchema {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface FormRenderProps<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
> {
|
||||
/**
|
||||
* 表单字段数组映射字符串配置 默认使用","
|
||||
*/
|
||||
arrayToStringFields?: ArrayToStringFields;
|
||||
/**
|
||||
* 是否折叠,在showCollapseButton=true下生效
|
||||
* true:折叠 false:展开
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
/**
|
||||
* 折叠时保持行数
|
||||
* @default 1
|
||||
*/
|
||||
collapsedRows?: number;
|
||||
/**
|
||||
* 是否触发resize事件
|
||||
* @default false
|
||||
*/
|
||||
collapseTriggerResize?: boolean;
|
||||
/**
|
||||
* 表单项通用后备配置,当子项目没配置时使用这里的配置,子项目配置优先级高于此配置
|
||||
*/
|
||||
commonConfig?: FormCommonConfig;
|
||||
/**
|
||||
* 紧凑模式(移除表单每一项底部为校验信息预留的空间)
|
||||
*/
|
||||
compact?: boolean;
|
||||
/**
|
||||
* 组件v-model事件绑定
|
||||
*/
|
||||
componentBindEventMap?: Partial<Record<BaseFormComponentType, string>>;
|
||||
/**
|
||||
* 组件集合
|
||||
*/
|
||||
componentMap: Record<BaseFormComponentType, Component>;
|
||||
/**
|
||||
* 表单字段映射到时间格式
|
||||
*/
|
||||
fieldMappingTime?: FieldMappingTime;
|
||||
/**
|
||||
* 表单实例
|
||||
*/
|
||||
form?: FormContext<GenericObject>;
|
||||
/**
|
||||
* 表单项布局
|
||||
*/
|
||||
layout?: FormLayout;
|
||||
/**
|
||||
* 表单定义
|
||||
*/
|
||||
schema?: FormSchema<T>[];
|
||||
|
||||
/**
|
||||
* 是否显示展开/折叠
|
||||
*/
|
||||
showCollapseButton?: boolean;
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
|
||||
/**
|
||||
* 表单栅格布局
|
||||
* @default "grid-cols-1"
|
||||
*/
|
||||
wrapperClass?: WrapperClassType;
|
||||
}
|
||||
|
||||
export interface ActionButtonOptions extends VbenButtonProps {
|
||||
[key: string]: any;
|
||||
content?: MaybeComputedRef<string>;
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
export interface VbenFormProps<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
> extends Omit<
|
||||
FormRenderProps<T>,
|
||||
'componentBindEventMap' | 'componentMap' | 'form'
|
||||
> {
|
||||
/**
|
||||
* 操作按钮是否反转(提交按钮前置)
|
||||
*/
|
||||
actionButtonsReverse?: boolean;
|
||||
/**
|
||||
* 操作按钮组的样式
|
||||
* newLine: 在新行显示。rowEnd: 在行内显示,靠右对齐(默认)。inline: 使用grid默认样式
|
||||
*/
|
||||
actionLayout?: 'inline' | 'newLine' | 'rowEnd';
|
||||
/**
|
||||
* 操作按钮组显示位置,默认靠右显示
|
||||
*/
|
||||
actionPosition?: 'center' | 'left' | 'right';
|
||||
/**
|
||||
* 表单操作区域class
|
||||
*/
|
||||
actionWrapperClass?: ClassType;
|
||||
/**
|
||||
* 表单字段数组映射字符串配置 默认使用","
|
||||
*/
|
||||
arrayToStringFields?: ArrayToStringFields;
|
||||
|
||||
/**
|
||||
* 表单字段映射
|
||||
*/
|
||||
fieldMappingTime?: FieldMappingTime;
|
||||
/**
|
||||
* 表单收起展开状态变化回调
|
||||
*/
|
||||
handleCollapsedChange?: (collapsed: boolean) => void;
|
||||
/**
|
||||
* 表单重置回调
|
||||
*/
|
||||
handleReset?: HandleResetFn;
|
||||
/**
|
||||
* 表单提交回调
|
||||
*/
|
||||
handleSubmit?: HandleSubmitFn;
|
||||
/**
|
||||
* 表单值变化回调
|
||||
*/
|
||||
handleValuesChange?: (
|
||||
values: Record<string, any>,
|
||||
fieldsChanged: string[],
|
||||
) => void;
|
||||
/**
|
||||
* 重置按钮参数
|
||||
*/
|
||||
resetButtonOptions?: ActionButtonOptions;
|
||||
|
||||
/**
|
||||
* 验证失败时是否自动滚动到第一个错误字段
|
||||
* @default false
|
||||
*/
|
||||
scrollToFirstError?: boolean;
|
||||
|
||||
/**
|
||||
* 是否显示默认操作按钮
|
||||
* @default true
|
||||
*/
|
||||
showDefaultActions?: boolean;
|
||||
|
||||
/**
|
||||
* 提交按钮参数
|
||||
*/
|
||||
submitButtonOptions?: ActionButtonOptions;
|
||||
|
||||
/**
|
||||
* 是否在字段值改变时提交表单
|
||||
* @default false
|
||||
*/
|
||||
submitOnChange?: boolean;
|
||||
|
||||
/**
|
||||
* 是否在回车时提交表单
|
||||
* @default false
|
||||
*/
|
||||
submitOnEnter?: boolean;
|
||||
}
|
||||
|
||||
export type ExtendedFormApi = FormApi & {
|
||||
useStore: <T = NoInfer<VbenFormProps>>(
|
||||
selector?: (state: NoInfer<VbenFormProps>) => T,
|
||||
) => Readonly<Ref<T>>;
|
||||
};
|
||||
|
||||
export interface VbenFormAdapterOptions<
|
||||
T extends BaseFormComponentType = BaseFormComponentType,
|
||||
> {
|
||||
config?: {
|
||||
baseModelPropName?: string;
|
||||
disabledOnChangeListener?: boolean;
|
||||
disabledOnInputListener?: boolean;
|
||||
emptyStateValue?: null | undefined;
|
||||
modelPropNameMap?: Partial<Record<T, string>>;
|
||||
};
|
||||
defineRules?: {
|
||||
mobile?: (
|
||||
value: any,
|
||||
params: any,
|
||||
ctx: Record<string, any>,
|
||||
) => boolean | string;
|
||||
mobileRequired?: (
|
||||
value: any,
|
||||
params: any,
|
||||
ctx: Record<string, any>,
|
||||
) => boolean | string;
|
||||
required?: (
|
||||
value: any,
|
||||
params: any,
|
||||
ctx: Record<string, any>,
|
||||
) => boolean | string;
|
||||
selectRequired?: (
|
||||
value: any,
|
||||
params: any,
|
||||
ctx: Record<string, any>,
|
||||
) => boolean | string;
|
||||
};
|
||||
}
|
||||
109
packages/@core/ui-kit/form-ui/src/use-form-context.ts
Normal file
109
packages/@core/ui-kit/form-ui/src/use-form-context.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { ZodRawShape } from 'zod';
|
||||
|
||||
import type { ComputedRef } from 'vue';
|
||||
|
||||
import type { ExtendedFormApi, FormActions, VbenFormProps } from './types';
|
||||
|
||||
import { computed, unref, useSlots } from 'vue';
|
||||
|
||||
import { createContext } from '@vben-core/shadcn-ui';
|
||||
import { isString, mergeWithArrayOverride, set } from '@vben-core/shared/utils';
|
||||
|
||||
import { useForm } from 'vee-validate';
|
||||
import { object, ZodIntersection, ZodNumber, ZodObject, ZodString } from 'zod';
|
||||
import { getDefaultsForSchema } from 'zod-defaults';
|
||||
|
||||
type ExtendFormProps = VbenFormProps & { formApi?: ExtendedFormApi };
|
||||
|
||||
export const [injectFormProps, provideFormProps] =
|
||||
createContext<[ComputedRef<ExtendFormProps> | ExtendFormProps, FormActions]>(
|
||||
'VbenFormProps',
|
||||
);
|
||||
|
||||
export const [injectComponentRefMap, provideComponentRefMap] =
|
||||
createContext<Map<string, unknown>>('ComponentRefMap');
|
||||
|
||||
export function useFormInitial(
|
||||
props: ComputedRef<VbenFormProps> | VbenFormProps,
|
||||
) {
|
||||
const slots = useSlots();
|
||||
const initialValues = generateInitialValues();
|
||||
|
||||
const form = useForm({
|
||||
...(Object.keys(initialValues)?.length ? { initialValues } : {}),
|
||||
});
|
||||
|
||||
const delegatedSlots = computed(() => {
|
||||
const resultSlots: string[] = [];
|
||||
|
||||
for (const key of Object.keys(slots)) {
|
||||
if (key !== 'default') {
|
||||
resultSlots.push(key);
|
||||
}
|
||||
}
|
||||
return resultSlots;
|
||||
});
|
||||
|
||||
function generateInitialValues() {
|
||||
const initialValues: Record<string, any> = {};
|
||||
|
||||
const zodObject: ZodRawShape = {};
|
||||
(unref(props).schema || []).forEach((item) => {
|
||||
if (Reflect.has(item, 'defaultValue')) {
|
||||
set(initialValues, item.fieldName, item.defaultValue);
|
||||
} else if (item.rules && !isString(item.rules)) {
|
||||
// 检查规则是否适合提取默认值
|
||||
const customDefaultValue = getCustomDefaultValue(item.rules);
|
||||
zodObject[item.fieldName] = item.rules;
|
||||
if (customDefaultValue !== undefined) {
|
||||
initialValues[item.fieldName] = customDefaultValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const schemaInitialValues = getDefaultsForSchema(object(zodObject));
|
||||
|
||||
const zodDefaults: Record<string, any> = {};
|
||||
for (const key in schemaInitialValues) {
|
||||
set(zodDefaults, key, schemaInitialValues[key]);
|
||||
}
|
||||
return mergeWithArrayOverride(initialValues, zodDefaults);
|
||||
}
|
||||
// 自定义默认值提取逻辑
|
||||
function getCustomDefaultValue(rule: any): any {
|
||||
if (rule instanceof ZodString) {
|
||||
return ''; // 默认为空字符串
|
||||
} else if (rule instanceof ZodNumber) {
|
||||
return null; // 默认为 null(避免显示 0)
|
||||
} else if (rule instanceof ZodObject) {
|
||||
// 递归提取嵌套对象的默认值
|
||||
const defaultValues: Record<string, any> = {};
|
||||
for (const [key, valueSchema] of Object.entries(rule.shape)) {
|
||||
defaultValues[key] = getCustomDefaultValue(valueSchema);
|
||||
}
|
||||
return defaultValues;
|
||||
} else if (rule instanceof ZodIntersection) {
|
||||
// 对于交集类型,从schema 提取默认值
|
||||
const leftDefaultValue = getCustomDefaultValue(rule._def.left);
|
||||
const rightDefaultValue = getCustomDefaultValue(rule._def.right);
|
||||
|
||||
// 如果左右两边都能提取默认值,合并它们
|
||||
if (
|
||||
typeof leftDefaultValue === 'object' &&
|
||||
typeof rightDefaultValue === 'object'
|
||||
) {
|
||||
return { ...leftDefaultValue, ...rightDefaultValue };
|
||||
}
|
||||
|
||||
// 否则优先使用左边的默认值
|
||||
return leftDefaultValue ?? rightDefaultValue;
|
||||
} else {
|
||||
return undefined; // 其他类型不提供默认值
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
delegatedSlots,
|
||||
form,
|
||||
};
|
||||
}
|
||||
79
packages/@core/ui-kit/form-ui/src/vben-form.vue
Normal file
79
packages/@core/ui-kit/form-ui/src/vben-form.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormProps } from './types';
|
||||
|
||||
import { ref, watchEffect } from 'vue';
|
||||
|
||||
import { useForwardPropsEmits } from '@vben-core/composables';
|
||||
|
||||
import FormActions from './components/form-actions.vue';
|
||||
import {
|
||||
COMPONENT_BIND_EVENT_MAP,
|
||||
COMPONENT_MAP,
|
||||
DEFAULT_FORM_COMMON_CONFIG,
|
||||
} from './config';
|
||||
import { Form } from './form-render';
|
||||
import { provideFormProps, useFormInitial } from './use-form-context';
|
||||
|
||||
// 通过 extends 会导致热更新卡死
|
||||
interface Props extends VbenFormProps {}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
actionWrapperClass: '',
|
||||
collapsed: false,
|
||||
collapsedRows: 1,
|
||||
commonConfig: () => ({}),
|
||||
handleReset: undefined,
|
||||
handleSubmit: undefined,
|
||||
layout: 'horizontal',
|
||||
resetButtonOptions: () => ({}),
|
||||
showCollapseButton: false,
|
||||
showDefaultActions: true,
|
||||
submitButtonOptions: () => ({}),
|
||||
wrapperClass: 'grid-cols-1',
|
||||
});
|
||||
|
||||
const forward = useForwardPropsEmits(props);
|
||||
|
||||
const currentCollapsed = ref(false);
|
||||
|
||||
const { delegatedSlots, form } = useFormInitial(props);
|
||||
|
||||
provideFormProps([props, form]);
|
||||
|
||||
const handleUpdateCollapsed = (value: boolean) => {
|
||||
currentCollapsed.value = value;
|
||||
// 触发收起展开状态变化回调
|
||||
props.handleCollapsedChange?.(value);
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
currentCollapsed.value = props.collapsed;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
v-bind="forward"
|
||||
:collapsed="currentCollapsed"
|
||||
:component-bind-event-map="COMPONENT_BIND_EVENT_MAP"
|
||||
:component-map="COMPONENT_MAP"
|
||||
:form="form"
|
||||
:global-common-config="DEFAULT_FORM_COMMON_CONFIG"
|
||||
>
|
||||
<template
|
||||
v-for="slotName in delegatedSlots"
|
||||
:key="slotName"
|
||||
#[slotName]="slotProps"
|
||||
>
|
||||
<slot :name="slotName" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
<template #default="slotProps">
|
||||
<slot v-bind="slotProps">
|
||||
<FormActions
|
||||
v-if="showDefaultActions"
|
||||
:model-value="currentCollapsed"
|
||||
@update:model-value="handleUpdateCollapsed"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
</Form>
|
||||
</template>
|
||||
151
packages/@core/ui-kit/form-ui/src/vben-use-form.vue
Normal file
151
packages/@core/ui-kit/form-ui/src/vben-use-form.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import type { ExtendedFormApi, VbenFormProps } from './types';
|
||||
|
||||
// import { toRaw, watch } from 'vue';
|
||||
import { nextTick, onMounted, watch } from 'vue';
|
||||
|
||||
import { useForwardPriorityValues } from '@vben-core/composables';
|
||||
import { cloneDeep, get, isEqual, set } from '@vben-core/shared/utils';
|
||||
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
|
||||
import FormActions from './components/form-actions.vue';
|
||||
import {
|
||||
COMPONENT_BIND_EVENT_MAP,
|
||||
COMPONENT_MAP,
|
||||
DEFAULT_FORM_COMMON_CONFIG,
|
||||
} from './config';
|
||||
import { Form } from './form-render';
|
||||
import {
|
||||
provideComponentRefMap,
|
||||
provideFormProps,
|
||||
useFormInitial,
|
||||
} from './use-form-context';
|
||||
// 通过 extends 会导致热更新卡死,所以重复写了一遍
|
||||
interface Props extends VbenFormProps {
|
||||
formApi?: ExtendedFormApi;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const state = props.formApi?.useStore?.();
|
||||
|
||||
const forward = useForwardPriorityValues(props, state);
|
||||
|
||||
const componentRefMap = new Map<string, unknown>();
|
||||
|
||||
const { delegatedSlots, form } = useFormInitial(forward);
|
||||
|
||||
provideFormProps([forward, form]);
|
||||
provideComponentRefMap(componentRefMap);
|
||||
|
||||
props.formApi?.mount?.(form, componentRefMap);
|
||||
|
||||
const handleUpdateCollapsed = (value: boolean) => {
|
||||
props.formApi?.setState({ collapsed: value });
|
||||
// 触发收起展开状态变化回调
|
||||
forward.value.handleCollapsedChange?.(value);
|
||||
};
|
||||
|
||||
function handleKeyDownEnter(event: KeyboardEvent) {
|
||||
if (!state?.value.submitOnEnter || !forward.value.formApi?.isMounted) {
|
||||
return;
|
||||
}
|
||||
// 如果是 textarea 不阻止默认行为,否则会导致无法换行。
|
||||
// 跳过 textarea 的回车提交处理
|
||||
if (event.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
forward.value.formApi?.validateAndSubmitForm();
|
||||
}
|
||||
|
||||
const handleValuesChangeDebounced = useDebounceFn(async () => {
|
||||
state?.value.submitOnChange && forward.value.formApi?.validateAndSubmitForm();
|
||||
}, 300);
|
||||
|
||||
const valuesCache: Recordable<any> = {};
|
||||
|
||||
onMounted(async () => {
|
||||
// 只在挂载后开始监听,form.values会有一个初始化的过程
|
||||
await nextTick();
|
||||
watch(
|
||||
() => form.values,
|
||||
async (newVal) => {
|
||||
if (forward.value.handleValuesChange) {
|
||||
const fields = state?.value.schema?.map((item) => {
|
||||
return item.fieldName;
|
||||
});
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
const changedFields: string[] = [];
|
||||
fields.forEach((field) => {
|
||||
const newFieldValue = get(newVal, field);
|
||||
const oldFieldValue = get(valuesCache, field);
|
||||
if (!isEqual(newFieldValue, oldFieldValue)) {
|
||||
changedFields.push(field);
|
||||
set(valuesCache, field, newFieldValue);
|
||||
}
|
||||
});
|
||||
|
||||
if (changedFields.length > 0) {
|
||||
// 调用handleValuesChange回调,传入所有表单值的深拷贝和变更的字段列表
|
||||
const values = await forward.value.formApi?.getValues();
|
||||
forward.value.handleValuesChange(
|
||||
cloneDeep(values ?? {}) as Record<string, any>,
|
||||
changedFields,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
handleValuesChangeDebounced();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
@keydown.enter="handleKeyDownEnter"
|
||||
v-bind="forward"
|
||||
:collapsed="state?.collapsed"
|
||||
:component-bind-event-map="COMPONENT_BIND_EVENT_MAP"
|
||||
:component-map="COMPONENT_MAP"
|
||||
:form="form"
|
||||
:global-common-config="DEFAULT_FORM_COMMON_CONFIG"
|
||||
>
|
||||
<template
|
||||
v-for="slotName in delegatedSlots"
|
||||
:key="slotName"
|
||||
#[slotName]="slotProps"
|
||||
>
|
||||
<slot :name="slotName" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
<template #default="slotProps">
|
||||
<slot v-bind="slotProps">
|
||||
<FormActions
|
||||
v-if="forward.showDefaultActions"
|
||||
:model-value="state?.collapsed"
|
||||
@update:model-value="handleUpdateCollapsed"
|
||||
>
|
||||
<template #reset-before="resetSlotProps">
|
||||
<slot name="reset-before" v-bind="resetSlotProps"></slot>
|
||||
</template>
|
||||
<template #submit-before="submitSlotProps">
|
||||
<slot name="submit-before" v-bind="submitSlotProps"></slot>
|
||||
</template>
|
||||
<template #expand-before="expandBeforeSlotProps">
|
||||
<slot name="expand-before" v-bind="expandBeforeSlotProps"></slot>
|
||||
</template>
|
||||
<template #expand-after="expandAfterSlotProps">
|
||||
<slot name="expand-after" v-bind="expandAfterSlotProps"></slot>
|
||||
</template>
|
||||
</FormActions>
|
||||
</slot>
|
||||
</template>
|
||||
</Form>
|
||||
</template>
|
||||
48
packages/@core/ui-kit/layout-ui/package.json
Normal file
48
packages/@core/ui-kit/layout-ui/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@vben-core/layout-ui",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/uikit/layout-ui"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/composables": "workspace:*",
|
||||
"@vben-core/icons": "workspace:*",
|
||||
"@vben-core/shadcn-ui": "workspace:*",
|
||||
"@vben-core/shared": "workspace:*",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { ContentCompactType } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useLayoutContentStyle } from '@vben-core/composables';
|
||||
import { Slot } from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 内容区域定宽
|
||||
*/
|
||||
contentCompact: ContentCompactType;
|
||||
/**
|
||||
* 定宽布局宽度
|
||||
*/
|
||||
contentCompactWidth: number;
|
||||
padding: number;
|
||||
paddingBottom: number;
|
||||
paddingLeft: number;
|
||||
paddingRight: number;
|
||||
paddingTop: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const { contentElement, overlayStyle } = useLayoutContentStyle();
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const {
|
||||
contentCompact,
|
||||
padding,
|
||||
paddingBottom,
|
||||
paddingLeft,
|
||||
paddingRight,
|
||||
paddingTop,
|
||||
} = props;
|
||||
|
||||
const compactStyle: CSSProperties =
|
||||
contentCompact === 'compact'
|
||||
? { margin: '0 auto', width: `${props.contentCompactWidth}px` }
|
||||
: {};
|
||||
return {
|
||||
...compactStyle,
|
||||
flex: 1,
|
||||
padding: `${padding}px`,
|
||||
paddingBottom: `${paddingBottom}px`,
|
||||
paddingLeft: `${paddingLeft}px`,
|
||||
paddingRight: `${paddingRight}px`,
|
||||
paddingTop: `${paddingTop}px`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main ref="contentElement" :style="style" class="relative bg-background-deep">
|
||||
<Slot :style="overlayStyle">
|
||||
<slot name="overlay"></slot>
|
||||
</Slot>
|
||||
<slot></slot>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 是否固定在底部
|
||||
*/
|
||||
fixed?: boolean;
|
||||
height: number;
|
||||
/**
|
||||
* 是否显示
|
||||
* @default true
|
||||
*/
|
||||
show?: boolean;
|
||||
width: string;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
show: true,
|
||||
});
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { fixed, height, show, width, zIndex } = props;
|
||||
return {
|
||||
height: `${height}px`,
|
||||
marginBottom: show ? '0' : `-${height}px`,
|
||||
position: fixed ? 'fixed' : 'static',
|
||||
width,
|
||||
zIndex,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer
|
||||
:style="style"
|
||||
class="bottom-0 w-full bg-background-deep transition-all duration-200"
|
||||
>
|
||||
<slot></slot>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, useSlots } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 横屏
|
||||
*/
|
||||
fullWidth: boolean;
|
||||
/**
|
||||
* 高度
|
||||
*/
|
||||
height: number;
|
||||
/**
|
||||
* 是否移动端
|
||||
*/
|
||||
isMobile: boolean;
|
||||
/**
|
||||
* 是否显示
|
||||
*/
|
||||
show: boolean;
|
||||
/**
|
||||
* 侧边菜单宽度
|
||||
*/
|
||||
sidebarWidth: number;
|
||||
/**
|
||||
* 主题
|
||||
*/
|
||||
theme: string | undefined;
|
||||
/**
|
||||
* 宽度
|
||||
*/
|
||||
width: string;
|
||||
/**
|
||||
* zIndex
|
||||
*/
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { fullWidth, height, show } = props;
|
||||
const right = !show || !fullWidth ? undefined : 0;
|
||||
|
||||
return {
|
||||
height: `${height}px`,
|
||||
marginTop: show ? 0 : `-${height}px`,
|
||||
right,
|
||||
};
|
||||
});
|
||||
|
||||
const logoStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
minWidth: `${props.isMobile ? 40 : props.sidebarWidth}px`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
:class="theme"
|
||||
:style="style"
|
||||
class="top-0 flex w-full flex-[0_0_auto] items-center border-b border-border bg-header pl-2 transition-[margin-top] duration-200"
|
||||
>
|
||||
<div v-if="slots.logo" :style="logoStyle">
|
||||
<slot name="logo"></slot>
|
||||
</div>
|
||||
|
||||
<slot name="toggle-button"> </slot>
|
||||
|
||||
<slot></slot>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, shallowRef, useSlots, watchEffect } from 'vue';
|
||||
|
||||
import { VbenScrollbar } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useScrollLock } from '@vueuse/core';
|
||||
|
||||
import { SidebarCollapseButton, SidebarFixedButton } from './widgets';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 折叠区域高度
|
||||
* @default 42
|
||||
*/
|
||||
collapseHeight?: number;
|
||||
/**
|
||||
* 折叠宽度
|
||||
* @default 48
|
||||
*/
|
||||
collapseWidth?: number;
|
||||
/**
|
||||
* 隐藏的dom是否可见
|
||||
* @default true
|
||||
*/
|
||||
domVisible?: boolean;
|
||||
/**
|
||||
* 扩展区域宽度
|
||||
*/
|
||||
extraWidth: number;
|
||||
/**
|
||||
* 固定扩展区域
|
||||
* @default false
|
||||
*/
|
||||
fixedExtra?: boolean;
|
||||
/**
|
||||
* 头部高度
|
||||
*/
|
||||
headerHeight: number;
|
||||
/**
|
||||
* 是否侧边混合模式
|
||||
* @default false
|
||||
*/
|
||||
isSidebarMixed?: boolean;
|
||||
/**
|
||||
* 顶部margin
|
||||
* @default 60
|
||||
*/
|
||||
marginTop?: number;
|
||||
/**
|
||||
* 混合菜单宽度
|
||||
* @default 80
|
||||
*/
|
||||
mixedWidth?: number;
|
||||
/**
|
||||
* 顶部padding
|
||||
* @default 60
|
||||
*/
|
||||
paddingTop?: number;
|
||||
/**
|
||||
* 是否显示
|
||||
* @default true
|
||||
*/
|
||||
show?: boolean;
|
||||
/**
|
||||
* 显示折叠按钮
|
||||
* @default true
|
||||
*/
|
||||
showCollapseButton?: boolean;
|
||||
/**
|
||||
* 显示固定按钮
|
||||
* @default true
|
||||
*/
|
||||
showFixedButton?: boolean;
|
||||
/**
|
||||
* 主题
|
||||
*/
|
||||
theme: string;
|
||||
|
||||
/**
|
||||
* 宽度
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* zIndex
|
||||
* @default 0
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
collapseHeight: 42,
|
||||
collapseWidth: 48,
|
||||
domVisible: true,
|
||||
fixedExtra: false,
|
||||
isSidebarMixed: false,
|
||||
marginTop: 0,
|
||||
mixedWidth: 70,
|
||||
paddingTop: 0,
|
||||
show: true,
|
||||
showCollapseButton: true,
|
||||
showFixedButton: true,
|
||||
zIndex: 0,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ leave: [] }>();
|
||||
const collapse = defineModel<boolean>('collapse');
|
||||
const extraCollapse = defineModel<boolean>('extraCollapse');
|
||||
const expandOnHovering = defineModel<boolean>('expandOnHovering');
|
||||
const expandOnHover = defineModel<boolean>('expandOnHover');
|
||||
const extraVisible = defineModel<boolean>('extraVisible');
|
||||
|
||||
const isLocked = useScrollLock(document.body);
|
||||
const slots = useSlots();
|
||||
|
||||
const asideRef = shallowRef<HTMLDivElement | null>();
|
||||
|
||||
const hiddenSideStyle = computed((): CSSProperties => calcMenuWidthStyle(true));
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { isSidebarMixed, marginTop, paddingTop, zIndex } = props;
|
||||
|
||||
return {
|
||||
'--scroll-shadow': 'var(--sidebar)',
|
||||
...calcMenuWidthStyle(false),
|
||||
height: `calc(100% - ${marginTop}px)`,
|
||||
marginTop: `${marginTop}px`,
|
||||
paddingTop: `${paddingTop}px`,
|
||||
zIndex,
|
||||
...(isSidebarMixed && extraVisible.value ? { transition: 'none' } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const extraStyle = computed((): CSSProperties => {
|
||||
const { extraWidth, show, width, zIndex } = props;
|
||||
|
||||
return {
|
||||
left: `${width}px`,
|
||||
width: extraVisible.value && show ? `${extraWidth}px` : 0,
|
||||
zIndex,
|
||||
};
|
||||
});
|
||||
|
||||
const extraTitleStyle = computed((): CSSProperties => {
|
||||
const { headerHeight } = props;
|
||||
|
||||
return {
|
||||
height: `${headerHeight - 1}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const contentWidthStyle = computed((): CSSProperties => {
|
||||
const { collapseWidth, fixedExtra, isSidebarMixed, mixedWidth } = props;
|
||||
if (isSidebarMixed && fixedExtra) {
|
||||
return { width: `${collapse.value ? collapseWidth : mixedWidth}px` };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const contentStyle = computed((): CSSProperties => {
|
||||
const { collapseHeight, headerHeight } = props;
|
||||
|
||||
return {
|
||||
height: `calc(100% - ${headerHeight + collapseHeight}px)`,
|
||||
paddingTop: '8px',
|
||||
...contentWidthStyle.value,
|
||||
};
|
||||
});
|
||||
|
||||
const headerStyle = computed((): CSSProperties => {
|
||||
const { headerHeight, isSidebarMixed } = props;
|
||||
|
||||
return {
|
||||
...(isSidebarMixed ? { display: 'flex', justifyContent: 'center' } : {}),
|
||||
height: `${headerHeight - 1}px`,
|
||||
...contentWidthStyle.value,
|
||||
};
|
||||
});
|
||||
|
||||
const extraContentStyle = computed((): CSSProperties => {
|
||||
const { collapseHeight, headerHeight } = props;
|
||||
return {
|
||||
height: `calc(100% - ${headerHeight + collapseHeight}px)`,
|
||||
};
|
||||
});
|
||||
|
||||
const collapseStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
height: `${props.collapseHeight}px`,
|
||||
};
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
extraVisible.value = props.fixedExtra ? true : extraVisible.value;
|
||||
});
|
||||
|
||||
function calcMenuWidthStyle(isHiddenDom: boolean): CSSProperties {
|
||||
const { extraWidth, fixedExtra, isSidebarMixed, show, width } = props;
|
||||
|
||||
let widthValue =
|
||||
width === 0
|
||||
? '0px'
|
||||
: `${width + (isSidebarMixed && fixedExtra && extraVisible.value ? extraWidth : 0)}px`;
|
||||
|
||||
const { collapseWidth } = props;
|
||||
|
||||
if (isHiddenDom && expandOnHovering.value && !expandOnHover.value) {
|
||||
widthValue = `${collapseWidth}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
...(widthValue === '0px' ? { overflow: 'hidden' } : {}),
|
||||
flex: `0 0 ${widthValue}`,
|
||||
marginLeft: show ? 0 : `-${widthValue}`,
|
||||
maxWidth: widthValue,
|
||||
minWidth: widthValue,
|
||||
width: widthValue,
|
||||
};
|
||||
}
|
||||
|
||||
function handleMouseenter(e: MouseEvent) {
|
||||
if (e?.offsetX < 10) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 未开启和未折叠状态不生效
|
||||
if (expandOnHover.value) {
|
||||
return;
|
||||
}
|
||||
if (!expandOnHovering.value) {
|
||||
collapse.value = false;
|
||||
}
|
||||
if (props.isSidebarMixed) {
|
||||
isLocked.value = true;
|
||||
}
|
||||
expandOnHovering.value = true;
|
||||
}
|
||||
|
||||
function handleMouseleave() {
|
||||
emit('leave');
|
||||
if (props.isSidebarMixed) {
|
||||
isLocked.value = false;
|
||||
}
|
||||
if (expandOnHover.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
expandOnHovering.value = false;
|
||||
collapse.value = true;
|
||||
extraVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="domVisible"
|
||||
:class="theme"
|
||||
:style="hiddenSideStyle"
|
||||
class="h-full transition-all duration-150"
|
||||
></div>
|
||||
<aside
|
||||
:class="[
|
||||
theme,
|
||||
{
|
||||
'bg-sidebar-deep': isSidebarMixed,
|
||||
'border-r border-border bg-sidebar': !isSidebarMixed,
|
||||
},
|
||||
]"
|
||||
:style="style"
|
||||
class="fixed left-0 top-0 h-full transition-all duration-150"
|
||||
@mouseenter="handleMouseenter"
|
||||
@mouseleave="handleMouseleave"
|
||||
>
|
||||
<SidebarFixedButton
|
||||
v-if="!collapse && !isSidebarMixed && showFixedButton"
|
||||
v-model:expand-on-hover="expandOnHover"
|
||||
/>
|
||||
<div v-if="slots.logo" :style="headerStyle">
|
||||
<slot name="logo"></slot>
|
||||
</div>
|
||||
<VbenScrollbar :style="contentStyle" shadow shadow-border>
|
||||
<slot></slot>
|
||||
</VbenScrollbar>
|
||||
|
||||
<div :style="collapseStyle"></div>
|
||||
<SidebarCollapseButton
|
||||
v-if="showCollapseButton && !isSidebarMixed"
|
||||
v-model:collapsed="collapse"
|
||||
/>
|
||||
<div
|
||||
v-if="isSidebarMixed"
|
||||
ref="asideRef"
|
||||
:class="{
|
||||
'border-l': extraVisible,
|
||||
}"
|
||||
:style="extraStyle"
|
||||
class="fixed top-0 h-full overflow-hidden border-r border-border bg-sidebar transition-all duration-200"
|
||||
>
|
||||
<SidebarCollapseButton
|
||||
v-if="isSidebarMixed && expandOnHover"
|
||||
v-model:collapsed="extraCollapse"
|
||||
/>
|
||||
|
||||
<SidebarFixedButton
|
||||
v-if="!extraCollapse"
|
||||
v-model:expand-on-hover="expandOnHover"
|
||||
/>
|
||||
<div v-if="!extraCollapse" :style="extraTitleStyle" class="pl-2">
|
||||
<slot name="extra-title"></slot>
|
||||
</div>
|
||||
<VbenScrollbar
|
||||
:style="extraContentStyle"
|
||||
class="border-border py-2"
|
||||
shadow
|
||||
shadow-border
|
||||
>
|
||||
<slot name="extra"></slot>
|
||||
</VbenScrollbar>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 高度
|
||||
*/
|
||||
height: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { height } = props;
|
||||
return {
|
||||
height: `${height}px`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:style="style"
|
||||
class="flex w-full border-b border-border bg-background transition-all"
|
||||
>
|
||||
<slot></slot>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronsLeft, ChevronsRight } from '@vben-core/icons';
|
||||
|
||||
const collapsed = defineModel<boolean>('collapsed');
|
||||
|
||||
function handleCollapsed() {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex-center absolute bottom-2 left-3 z-10 cursor-pointer rounded-sm bg-accent p-1 text-foreground/60 hover:bg-accent-hover hover:text-foreground"
|
||||
@click.stop="handleCollapsed"
|
||||
>
|
||||
<ChevronsRight v-if="collapsed" class="size-4" />
|
||||
<ChevronsLeft v-else class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { Pin, PinOff } from '@vben-core/icons';
|
||||
|
||||
const expandOnHover = defineModel<boolean>('expandOnHover');
|
||||
|
||||
function toggleFixed() {
|
||||
expandOnHover.value = !expandOnHover.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex-center absolute bottom-2 right-3 z-10 cursor-pointer rounded-sm bg-accent p-[5px] text-foreground/60 transition-all duration-300 hover:bg-accent-hover hover:text-foreground"
|
||||
@click="toggleFixed"
|
||||
>
|
||||
<PinOff v-if="!expandOnHover" class="size-3.5" />
|
||||
<Pin v-else class="size-3.5" />
|
||||
</div>
|
||||
</template>
|
||||
617
packages/@core/ui-kit/layout-ui/src/vben-layout.vue
Normal file
617
packages/@core/ui-kit/layout-ui/src/vben-layout.vue
Normal file
@@ -0,0 +1,617 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { VbenLayoutProps } from './vben-layout';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
SCROLL_FIXED_CLASS,
|
||||
useLayoutFooterStyle,
|
||||
useLayoutHeaderStyle,
|
||||
} from '@vben-core/composables';
|
||||
import { IconifyIcon } from '@vben-core/icons';
|
||||
import { VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants';
|
||||
|
||||
import { useMouse, useScroll, useThrottleFn } from '@vueuse/core';
|
||||
|
||||
import {
|
||||
LayoutContent,
|
||||
LayoutFooter,
|
||||
LayoutHeader,
|
||||
LayoutSidebar,
|
||||
LayoutTabbar,
|
||||
} from './components';
|
||||
import { useLayout } from './hooks/use-layout';
|
||||
|
||||
interface Props extends VbenLayoutProps {}
|
||||
|
||||
defineOptions({
|
||||
name: 'VbenLayout',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentCompact: 'wide',
|
||||
contentCompactWidth: 1200,
|
||||
contentPadding: 0,
|
||||
contentPaddingBottom: 0,
|
||||
contentPaddingLeft: 0,
|
||||
contentPaddingRight: 0,
|
||||
contentPaddingTop: 0,
|
||||
footerEnable: false,
|
||||
footerFixed: true,
|
||||
footerHeight: 32,
|
||||
headerHeight: 50,
|
||||
headerHidden: false,
|
||||
headerMode: 'fixed',
|
||||
headerToggleSidebarButton: true,
|
||||
headerVisible: true,
|
||||
isMobile: false,
|
||||
layout: 'sidebar-nav',
|
||||
sidebarCollapsedButton: true,
|
||||
sidebarCollapseShowTitle: false,
|
||||
sidebarExtraCollapsedWidth: 60,
|
||||
sidebarFixedButton: true,
|
||||
sidebarHidden: false,
|
||||
sidebarMixedWidth: 80,
|
||||
sidebarTheme: 'dark',
|
||||
sidebarWidth: 180,
|
||||
sideCollapseWidth: 60,
|
||||
tabbarEnable: true,
|
||||
tabbarHeight: 40,
|
||||
zIndex: 200,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ sideMouseLeave: []; toggleSidebar: [] }>();
|
||||
const sidebarCollapse = defineModel<boolean>('sidebarCollapse', {
|
||||
default: false,
|
||||
});
|
||||
const sidebarExtraVisible = defineModel<boolean>('sidebarExtraVisible');
|
||||
const sidebarExtraCollapse = defineModel<boolean>('sidebarExtraCollapse', {
|
||||
default: false,
|
||||
});
|
||||
const sidebarExpandOnHover = defineModel<boolean>('sidebarExpandOnHover', {
|
||||
default: false,
|
||||
});
|
||||
const sidebarEnable = defineModel<boolean>('sidebarEnable', { default: true });
|
||||
|
||||
// side是否处于hover状态展开菜单中
|
||||
const sidebarExpandOnHovering = ref(false);
|
||||
const headerIsHidden = ref(false);
|
||||
const contentRef = ref();
|
||||
|
||||
const {
|
||||
arrivedState,
|
||||
directions,
|
||||
isScrolling,
|
||||
y: scrollY,
|
||||
} = useScroll(document);
|
||||
|
||||
const { setLayoutHeaderHeight } = useLayoutHeaderStyle();
|
||||
const { setLayoutFooterHeight } = useLayoutFooterStyle();
|
||||
|
||||
const { y: mouseY } = useMouse({ target: contentRef, type: 'client' });
|
||||
|
||||
const {
|
||||
currentLayout,
|
||||
isFullContent,
|
||||
isHeaderMixedNav,
|
||||
isHeaderNav,
|
||||
isMixedNav,
|
||||
isSidebarMixedNav,
|
||||
} = useLayout(props);
|
||||
|
||||
/**
|
||||
* 顶栏是否自动隐藏
|
||||
*/
|
||||
const isHeaderAutoMode = computed(() => props.headerMode === 'auto');
|
||||
|
||||
const headerWrapperHeight = computed(() => {
|
||||
let height = 0;
|
||||
if (props.headerVisible && !props.headerHidden) {
|
||||
height += props.headerHeight;
|
||||
}
|
||||
if (props.tabbarEnable) {
|
||||
height += props.tabbarHeight;
|
||||
}
|
||||
return height;
|
||||
});
|
||||
|
||||
const getSideCollapseWidth = computed(() => {
|
||||
const { sidebarCollapseShowTitle, sidebarMixedWidth, sideCollapseWidth } =
|
||||
props;
|
||||
|
||||
return sidebarCollapseShowTitle ||
|
||||
isSidebarMixedNav.value ||
|
||||
isHeaderMixedNav.value
|
||||
? sidebarMixedWidth
|
||||
: sideCollapseWidth;
|
||||
});
|
||||
|
||||
/**
|
||||
* 动态获取侧边区域是否可见
|
||||
*/
|
||||
const sidebarEnableState = computed(() => {
|
||||
return !isHeaderNav.value && sidebarEnable.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 侧边区域离顶部高度
|
||||
*/
|
||||
const sidebarMarginTop = computed(() => {
|
||||
const { headerHeight, isMobile } = props;
|
||||
return isMixedNav.value && !isMobile ? headerHeight : 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* 动态获取侧边宽度
|
||||
*/
|
||||
const getSidebarWidth = computed(() => {
|
||||
const { isMobile, sidebarHidden, sidebarMixedWidth, sidebarWidth } = props;
|
||||
let width = 0;
|
||||
|
||||
if (sidebarHidden) {
|
||||
return width;
|
||||
}
|
||||
|
||||
if (
|
||||
!sidebarEnableState.value ||
|
||||
(sidebarHidden &&
|
||||
!isSidebarMixedNav.value &&
|
||||
!isMixedNav.value &&
|
||||
!isHeaderMixedNav.value)
|
||||
) {
|
||||
return width;
|
||||
}
|
||||
|
||||
if ((isHeaderMixedNav.value || isSidebarMixedNav.value) && !isMobile) {
|
||||
width = sidebarMixedWidth;
|
||||
} else if (sidebarCollapse.value) {
|
||||
width = isMobile ? 0 : getSideCollapseWidth.value;
|
||||
} else {
|
||||
width = sidebarWidth;
|
||||
}
|
||||
return width;
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取扩展区域宽度
|
||||
*/
|
||||
const sidebarExtraWidth = computed(() => {
|
||||
const { sidebarExtraCollapsedWidth, sidebarWidth } = props;
|
||||
|
||||
return sidebarExtraCollapse.value ? sidebarExtraCollapsedWidth : sidebarWidth;
|
||||
});
|
||||
|
||||
/**
|
||||
* 是否侧边栏模式,包含混合侧边
|
||||
*/
|
||||
const isSideMode = computed(
|
||||
() =>
|
||||
currentLayout.value === 'mixed-nav' ||
|
||||
currentLayout.value === 'sidebar-mixed-nav' ||
|
||||
currentLayout.value === 'sidebar-nav' ||
|
||||
currentLayout.value === 'header-mixed-nav' ||
|
||||
currentLayout.value === 'header-sidebar-nav',
|
||||
);
|
||||
|
||||
/**
|
||||
* header fixed值
|
||||
*/
|
||||
const headerFixed = computed(() => {
|
||||
const { headerMode } = props;
|
||||
return (
|
||||
isMixedNav.value ||
|
||||
headerMode === 'fixed' ||
|
||||
headerMode === 'auto-scroll' ||
|
||||
headerMode === 'auto'
|
||||
);
|
||||
});
|
||||
|
||||
const showSidebar = computed(() => {
|
||||
return isSideMode.value && sidebarEnable.value && !props.sidebarHidden;
|
||||
});
|
||||
|
||||
/**
|
||||
* 遮罩可见性
|
||||
*/
|
||||
const maskVisible = computed(() => !sidebarCollapse.value && props.isMobile);
|
||||
|
||||
const mainStyle = computed(() => {
|
||||
let width = '100%';
|
||||
let sidebarAndExtraWidth = 'unset';
|
||||
if (
|
||||
headerFixed.value &&
|
||||
currentLayout.value !== 'header-nav' &&
|
||||
currentLayout.value !== 'mixed-nav' &&
|
||||
currentLayout.value !== 'header-sidebar-nav' &&
|
||||
showSidebar.value &&
|
||||
!props.isMobile
|
||||
) {
|
||||
// fixed模式下生效
|
||||
const isSideNavEffective =
|
||||
(isSidebarMixedNav.value || isHeaderMixedNav.value) &&
|
||||
sidebarExpandOnHover.value &&
|
||||
sidebarExtraVisible.value;
|
||||
|
||||
if (isSideNavEffective) {
|
||||
const sideCollapseWidth = sidebarCollapse.value
|
||||
? getSideCollapseWidth.value
|
||||
: props.sidebarMixedWidth;
|
||||
const sideWidth = sidebarExtraCollapse.value
|
||||
? props.sidebarExtraCollapsedWidth
|
||||
: props.sidebarWidth;
|
||||
|
||||
// 100% - 侧边菜单混合宽度 - 菜单宽度
|
||||
sidebarAndExtraWidth = `${sideCollapseWidth + sideWidth}px`;
|
||||
width = `calc(100% - ${sidebarAndExtraWidth})`;
|
||||
} else {
|
||||
sidebarAndExtraWidth =
|
||||
sidebarExpandOnHovering.value && !sidebarExpandOnHover.value
|
||||
? `${getSideCollapseWidth.value}px`
|
||||
: `${getSidebarWidth.value}px`;
|
||||
width = `calc(100% - ${sidebarAndExtraWidth})`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
sidebarAndExtraWidth,
|
||||
width,
|
||||
};
|
||||
});
|
||||
|
||||
// 计算 tabbar 的样式
|
||||
const tabbarStyle = computed((): CSSProperties => {
|
||||
let width = '';
|
||||
let marginLeft = 0;
|
||||
|
||||
// 如果不是混合导航,tabbar 的宽度为 100%
|
||||
if (!isMixedNav.value || props.sidebarHidden) {
|
||||
width = '100%';
|
||||
} else if (sidebarEnable.value) {
|
||||
// 鼠标在侧边栏上时,且侧边栏展开时的宽度
|
||||
const onHoveringWidth = sidebarExpandOnHover.value
|
||||
? props.sidebarWidth
|
||||
: getSideCollapseWidth.value;
|
||||
|
||||
// 设置 marginLeft,根据侧边栏是否折叠来决定
|
||||
marginLeft = sidebarCollapse.value
|
||||
? getSideCollapseWidth.value
|
||||
: onHoveringWidth;
|
||||
|
||||
// 设置 tabbar 的宽度,计算方式为 100% 减去侧边栏的宽度
|
||||
width = `calc(100% - ${sidebarCollapse.value ? getSidebarWidth.value : onHoveringWidth}px)`;
|
||||
} else {
|
||||
// 默认情况下,tabbar 的宽度为 100%
|
||||
width = '100%';
|
||||
}
|
||||
|
||||
return {
|
||||
marginLeft: `${marginLeft}px`,
|
||||
width,
|
||||
};
|
||||
});
|
||||
|
||||
const contentStyle = computed((): CSSProperties => {
|
||||
const fixed = headerFixed.value;
|
||||
|
||||
const { footerEnable, footerFixed, footerHeight } = props;
|
||||
return {
|
||||
marginTop:
|
||||
fixed &&
|
||||
!isFullContent.value &&
|
||||
!headerIsHidden.value &&
|
||||
(!isHeaderAutoMode.value || scrollY.value < headerWrapperHeight.value)
|
||||
? `${headerWrapperHeight.value}px`
|
||||
: 0,
|
||||
paddingBottom: `${footerEnable && footerFixed ? footerHeight : 0}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const headerZIndex = computed(() => {
|
||||
const { zIndex } = props;
|
||||
const offset = isMixedNav.value ? 1 : 0;
|
||||
return zIndex + offset;
|
||||
});
|
||||
|
||||
const headerWrapperStyle = computed((): CSSProperties => {
|
||||
const fixed = headerFixed.value;
|
||||
return {
|
||||
height: isFullContent.value ? '0' : `${headerWrapperHeight.value}px`,
|
||||
left: isMixedNav.value ? 0 : mainStyle.value.sidebarAndExtraWidth,
|
||||
position: fixed ? 'fixed' : 'static',
|
||||
top:
|
||||
headerIsHidden.value || isFullContent.value
|
||||
? `-${headerWrapperHeight.value}px`
|
||||
: 0,
|
||||
width: mainStyle.value.width,
|
||||
'z-index': headerZIndex.value,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 侧边栏z-index
|
||||
*/
|
||||
const sidebarZIndex = computed(() => {
|
||||
const { isMobile, zIndex } = props;
|
||||
let offset = isMobile || isSideMode.value ? 1 : -1;
|
||||
|
||||
if (isMixedNav.value) {
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
return zIndex + offset;
|
||||
});
|
||||
|
||||
const footerWidth = computed(() => {
|
||||
if (!props.footerFixed) {
|
||||
return '100%';
|
||||
}
|
||||
|
||||
return mainStyle.value.width;
|
||||
});
|
||||
|
||||
const maskStyle = computed((): CSSProperties => {
|
||||
return { zIndex: props.zIndex };
|
||||
});
|
||||
|
||||
const showHeaderToggleButton = computed(() => {
|
||||
return (
|
||||
props.isMobile ||
|
||||
(props.headerToggleSidebarButton &&
|
||||
isSideMode.value &&
|
||||
!isSidebarMixedNav.value &&
|
||||
!isMixedNav.value &&
|
||||
!props.isMobile)
|
||||
);
|
||||
});
|
||||
|
||||
const showHeaderLogo = computed(() => {
|
||||
return !isSideMode.value || isMixedNav.value || props.isMobile;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.isMobile,
|
||||
(val) => {
|
||||
if (val) {
|
||||
sidebarCollapse.value = true;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
[() => headerWrapperHeight.value, () => isFullContent.value],
|
||||
([height]) => {
|
||||
setLayoutHeaderHeight(isFullContent.value ? 0 : height);
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.footerHeight,
|
||||
(height: number) => {
|
||||
setLayoutFooterHeight(height);
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
{
|
||||
const mouseMove = () => {
|
||||
mouseY.value > headerWrapperHeight.value
|
||||
? (headerIsHidden.value = true)
|
||||
: (headerIsHidden.value = false);
|
||||
};
|
||||
watch(
|
||||
[() => props.headerMode, () => mouseY.value],
|
||||
() => {
|
||||
if (!isHeaderAutoMode.value || isMixedNav.value || isFullContent.value) {
|
||||
if (props.headerMode !== 'auto-scroll') {
|
||||
headerIsHidden.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
headerIsHidden.value = true;
|
||||
mouseMove();
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const checkHeaderIsHidden = useThrottleFn((top, bottom, topArrived) => {
|
||||
if (scrollY.value < headerWrapperHeight.value) {
|
||||
headerIsHidden.value = false;
|
||||
return;
|
||||
}
|
||||
if (topArrived) {
|
||||
headerIsHidden.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (top) {
|
||||
headerIsHidden.value = false;
|
||||
} else if (bottom) {
|
||||
headerIsHidden.value = true;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
watch(
|
||||
() => scrollY.value,
|
||||
() => {
|
||||
if (
|
||||
props.headerMode !== 'auto-scroll' ||
|
||||
isMixedNav.value ||
|
||||
isFullContent.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isScrolling.value) {
|
||||
checkHeaderIsHidden(
|
||||
directions.top,
|
||||
directions.bottom,
|
||||
arrivedState.top,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleClickMask() {
|
||||
sidebarCollapse.value = true;
|
||||
}
|
||||
|
||||
function handleHeaderToggle() {
|
||||
if (props.isMobile) {
|
||||
sidebarCollapse.value = false;
|
||||
} else {
|
||||
emit('toggleSidebar');
|
||||
}
|
||||
}
|
||||
|
||||
const idMainContent = ELEMENT_ID_MAIN_CONTENT;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex min-h-full w-full">
|
||||
<LayoutSidebar
|
||||
v-if="sidebarEnableState"
|
||||
v-model:collapse="sidebarCollapse"
|
||||
v-model:expand-on-hover="sidebarExpandOnHover"
|
||||
v-model:expand-on-hovering="sidebarExpandOnHovering"
|
||||
v-model:extra-collapse="sidebarExtraCollapse"
|
||||
v-model:extra-visible="sidebarExtraVisible"
|
||||
:show-collapse-button="sidebarCollapsedButton"
|
||||
:show-fixed-button="sidebarFixedButton"
|
||||
:collapse-width="getSideCollapseWidth"
|
||||
:dom-visible="!isMobile"
|
||||
:extra-width="sidebarExtraWidth"
|
||||
:fixed-extra="sidebarExpandOnHover"
|
||||
:header-height="isMixedNav ? 0 : headerHeight"
|
||||
:is-sidebar-mixed="isSidebarMixedNav || isHeaderMixedNav"
|
||||
:margin-top="sidebarMarginTop"
|
||||
:mixed-width="sidebarMixedWidth"
|
||||
:show="showSidebar"
|
||||
:theme="sidebarTheme"
|
||||
:width="getSidebarWidth"
|
||||
:z-index="sidebarZIndex"
|
||||
@leave="() => emit('sideMouseLeave')"
|
||||
>
|
||||
<template v-if="isSideMode && !isMixedNav" #logo>
|
||||
<slot name="logo"></slot>
|
||||
</template>
|
||||
|
||||
<template v-if="isSidebarMixedNav || isHeaderMixedNav">
|
||||
<slot name="mixed-menu"></slot>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="menu"></slot>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<slot name="side-extra"></slot>
|
||||
</template>
|
||||
<template #extra-title>
|
||||
<slot name="side-extra-title"></slot>
|
||||
</template>
|
||||
</LayoutSidebar>
|
||||
|
||||
<div
|
||||
ref="contentRef"
|
||||
class="flex flex-1 flex-col overflow-hidden transition-all duration-300 ease-in"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
{
|
||||
'shadow-[0_16px_24px_hsl(var(--background))]': scrollY > 20,
|
||||
},
|
||||
SCROLL_FIXED_CLASS,
|
||||
]"
|
||||
:style="headerWrapperStyle"
|
||||
class="overflow-hidden transition-all duration-200"
|
||||
>
|
||||
<LayoutHeader
|
||||
v-if="headerVisible"
|
||||
:full-width="!isSideMode"
|
||||
:height="headerHeight"
|
||||
:is-mobile="isMobile"
|
||||
:show="!isFullContent && !headerHidden"
|
||||
:sidebar-width="sidebarWidth"
|
||||
:theme="headerTheme"
|
||||
:width="mainStyle.width"
|
||||
:z-index="headerZIndex"
|
||||
>
|
||||
<template v-if="showHeaderLogo" #logo>
|
||||
<slot name="logo"></slot>
|
||||
</template>
|
||||
|
||||
<template #toggle-button>
|
||||
<VbenIconButton
|
||||
v-if="showHeaderToggleButton"
|
||||
class="my-0 mr-1 rounded-md"
|
||||
@click="handleHeaderToggle"
|
||||
>
|
||||
<IconifyIcon v-if="showSidebar" icon="ep:fold" />
|
||||
<IconifyIcon v-else icon="ep:expand" />
|
||||
</VbenIconButton>
|
||||
</template>
|
||||
<slot name="header"></slot>
|
||||
</LayoutHeader>
|
||||
|
||||
<LayoutTabbar
|
||||
v-if="tabbarEnable"
|
||||
:height="tabbarHeight"
|
||||
:style="tabbarStyle"
|
||||
>
|
||||
<slot name="tabbar"></slot>
|
||||
</LayoutTabbar>
|
||||
</div>
|
||||
|
||||
<!-- </div> -->
|
||||
<LayoutContent
|
||||
:id="idMainContent"
|
||||
:content-compact="contentCompact"
|
||||
:content-compact-width="contentCompactWidth"
|
||||
:padding="contentPadding"
|
||||
:padding-bottom="contentPaddingBottom"
|
||||
:padding-left="contentPaddingLeft"
|
||||
:padding-right="contentPaddingRight"
|
||||
:padding-top="contentPaddingTop"
|
||||
:style="contentStyle"
|
||||
class="transition-[margin-top] duration-200"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
|
||||
<template #overlay>
|
||||
<slot name="content-overlay"></slot>
|
||||
</template>
|
||||
</LayoutContent>
|
||||
|
||||
<LayoutFooter
|
||||
v-if="footerEnable"
|
||||
:fixed="footerFixed"
|
||||
:height="footerHeight"
|
||||
:show="!isFullContent"
|
||||
:width="footerWidth"
|
||||
:z-index="zIndex"
|
||||
>
|
||||
<slot name="footer"></slot>
|
||||
</LayoutFooter>
|
||||
</div>
|
||||
<slot name="extra"></slot>
|
||||
<div
|
||||
v-if="maskVisible"
|
||||
:style="maskStyle"
|
||||
class="fixed left-0 top-0 h-full w-full bg-overlay transition-[background-color] duration-200"
|
||||
@click="handleClickMask"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
48
packages/@core/ui-kit/menu-ui/package.json
Normal file
48
packages/@core/ui-kit/menu-ui/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@vben-core/menu-ui",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/uikit/menu-ui"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/composables": "workspace:*",
|
||||
"@vben-core/icons": "workspace:*",
|
||||
"@vben-core/shadcn-ui": "workspace:*",
|
||||
"@vben-core/shared": "workspace:*",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
57
packages/@core/ui-kit/menu-ui/src/components/menu-badge.vue
Normal file
57
packages/@core/ui-kit/menu-ui/src/components/menu-badge.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordBadgeRaw } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { isValidColor } from '@vben-core/shared/color';
|
||||
|
||||
import BadgeDot from './menu-badge-dot.vue';
|
||||
|
||||
interface Props extends MenuRecordBadgeRaw {
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const variantsMap: Record<string, string> = {
|
||||
default: 'bg-green-500',
|
||||
destructive: 'bg-destructive',
|
||||
primary: 'bg-primary',
|
||||
success: 'bg-green-500',
|
||||
warning: 'bg-yellow-500',
|
||||
};
|
||||
|
||||
const isDot = computed(() => props.badgeType === 'dot');
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
const { badgeVariants } = props;
|
||||
|
||||
if (!badgeVariants) {
|
||||
return variantsMap.default;
|
||||
}
|
||||
|
||||
return variantsMap[badgeVariants] || badgeVariants;
|
||||
});
|
||||
|
||||
const badgeStyle = computed(() => {
|
||||
if (badgeClass.value && isValidColor(badgeClass.value)) {
|
||||
return {
|
||||
backgroundColor: badgeClass.value,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<span v-if="isDot || badge" :class="$attrs.class" class="absolute">
|
||||
<BadgeDot v-if="isDot" :dot-class="badgeClass" :dot-style="badgeStyle" />
|
||||
<div
|
||||
v-else
|
||||
:class="badgeClass"
|
||||
:style="badgeStyle"
|
||||
class="flex-center rounded-xl px-1.5 py-0.5 text-[10px] text-primary-foreground"
|
||||
>
|
||||
{{ badge }}
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
import type { NormalMenuProps } from './normal-menu';
|
||||
|
||||
import { useNamespace } from '@vben-core/composables';
|
||||
import { VbenIcon } from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props extends NormalMenuProps {}
|
||||
|
||||
defineOptions({
|
||||
name: 'NormalMenu',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
activePath: '',
|
||||
collapse: false,
|
||||
menus: () => [],
|
||||
theme: 'dark',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
enter: [MenuRecordRaw];
|
||||
select: [MenuRecordRaw];
|
||||
}>();
|
||||
|
||||
const { b, e, is } = useNamespace('normal-menu');
|
||||
|
||||
function menuIcon(menu: MenuRecordRaw) {
|
||||
return props.activePath === menu.path
|
||||
? menu.activeIcon || menu.icon
|
||||
: menu.icon;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
:class="[
|
||||
theme,
|
||||
b(),
|
||||
is('collapse', collapse),
|
||||
is(theme, true),
|
||||
is('rounded', rounded),
|
||||
]"
|
||||
class="relative"
|
||||
>
|
||||
<template v-for="menu in menus" :key="menu.path">
|
||||
<li
|
||||
:class="[e('item'), is('active', activePath === menu.path)]"
|
||||
@click="() => emit('select', menu)"
|
||||
@mouseenter="() => emit('enter', menu)"
|
||||
>
|
||||
<VbenIcon :class="e('icon')" :icon="menuIcon(menu)" fallback />
|
||||
|
||||
<span :class="e('name')" class="truncate"> {{ menu.name }}</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
$namespace: vben;
|
||||
|
||||
.#{$namespace}-normal-menu {
|
||||
--menu-item-margin-y: 4px;
|
||||
--menu-item-margin-x: 0px;
|
||||
--menu-item-padding-y: 9px;
|
||||
--menu-item-padding-x: 0px;
|
||||
--menu-item-radius: 0px;
|
||||
|
||||
height: calc(100% - 4px);
|
||||
|
||||
&.is-rounded {
|
||||
--menu-item-radius: 6px;
|
||||
--menu-item-margin-x: 8px;
|
||||
}
|
||||
|
||||
&.is-dark {
|
||||
.#{$namespace}-normal-menu__item {
|
||||
@apply text-foreground/80;
|
||||
// color: hsl(var(--foreground) / 80%);
|
||||
|
||||
&:not(.is-active):hover {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
.#{$namespace}-normal-menu__name,
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
@apply text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-collapse {
|
||||
.#{$namespace}-normal-menu__name {
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-top: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// max-width: 64px;
|
||||
// max-height: 64px;
|
||||
padding: var(--menu-item-padding-y) var(--menu-item-padding-x);
|
||||
margin: var(--menu-item-margin-y) var(--menu-item-margin-x);
|
||||
color: hsl(var(--foreground) / 90%);
|
||||
cursor: pointer;
|
||||
border-radius: var(--menu-item-radius);
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
padding 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&.is-active {
|
||||
@apply bg-primary text-primary dark:bg-accent;
|
||||
|
||||
.#{$namespace}-normal-menu__name,
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
@apply font-semibold text-primary-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.is-active):hover {
|
||||
@apply bg-heavy text-primary dark:bg-accent dark:text-foreground;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.#{$namespace}-normal-menu__icon {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
max-height: 20px;
|
||||
font-size: 20px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
275
packages/@core/ui-kit/menu-ui/src/components/sub-menu.vue
Normal file
275
packages/@core/ui-kit/menu-ui/src/components/sub-menu.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HoverCardContentProps } from '@vben-core/shadcn-ui';
|
||||
|
||||
import type { MenuItemRegistered, MenuProvider, SubMenuProps } from '../types';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useNamespace } from '@vben-core/composables';
|
||||
import { VbenHoverCard } from '@vben-core/shadcn-ui';
|
||||
|
||||
import {
|
||||
createSubMenuContext,
|
||||
useMenu,
|
||||
useMenuContext,
|
||||
useMenuStyle,
|
||||
useSubMenuContext,
|
||||
} from '../hooks';
|
||||
import CollapseTransition from './collapse-transition.vue';
|
||||
import SubMenuContent from './sub-menu-content.vue';
|
||||
|
||||
interface Props extends SubMenuProps {
|
||||
isSubMenuMore?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'SubMenu' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
isSubMenuMore: false,
|
||||
});
|
||||
|
||||
const { parentMenu, parentPaths } = useMenu();
|
||||
const { b, is } = useNamespace('sub-menu');
|
||||
const nsMenu = useNamespace('menu');
|
||||
const rootMenu = useMenuContext();
|
||||
const subMenu = useSubMenuContext();
|
||||
const subMenuStyle = useMenuStyle(subMenu);
|
||||
|
||||
const mouseInChild = ref(false);
|
||||
|
||||
const items = ref<MenuProvider['items']>({});
|
||||
const subMenus = ref<MenuProvider['subMenus']>({});
|
||||
const timer = ref<null | ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
createSubMenuContext({
|
||||
addSubMenu,
|
||||
handleMouseleave,
|
||||
level: (subMenu?.level ?? 0) + 1,
|
||||
mouseInChild,
|
||||
removeSubMenu,
|
||||
});
|
||||
|
||||
const opened = computed(() => {
|
||||
return rootMenu?.openedMenus.includes(props.path);
|
||||
});
|
||||
const isTopLevelMenuSubmenu = computed(
|
||||
() => parentMenu.value?.type.name === 'Menu',
|
||||
);
|
||||
const mode = computed(() => rootMenu?.props.mode ?? 'vertical');
|
||||
const rounded = computed(() => rootMenu?.props.rounded);
|
||||
const currentLevel = computed(() => subMenu?.level ?? 0);
|
||||
const isFirstLevel = computed(() => {
|
||||
return currentLevel.value === 1;
|
||||
});
|
||||
|
||||
const contentProps = computed((): HoverCardContentProps => {
|
||||
const isHorizontal = mode.value === 'horizontal';
|
||||
const side = isHorizontal && isFirstLevel.value ? 'bottom' : 'right';
|
||||
return {
|
||||
collisionPadding: { top: 20 },
|
||||
side,
|
||||
sideOffset: isHorizontal ? 5 : 10,
|
||||
};
|
||||
});
|
||||
|
||||
const active = computed(() => {
|
||||
let isActive = false;
|
||||
|
||||
Object.values(items.value).forEach((item) => {
|
||||
if (item.active) {
|
||||
isActive = true;
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(subMenus.value).forEach((subItem) => {
|
||||
if (subItem.active) {
|
||||
isActive = true;
|
||||
}
|
||||
});
|
||||
return isActive;
|
||||
});
|
||||
|
||||
function addSubMenu(subMenu: MenuItemRegistered) {
|
||||
subMenus.value[subMenu.path] = subMenu;
|
||||
}
|
||||
|
||||
function removeSubMenu(subMenu: MenuItemRegistered) {
|
||||
Reflect.deleteProperty(subMenus.value, subMenu.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击submenu展开/关闭
|
||||
*/
|
||||
function handleClick() {
|
||||
const mode = rootMenu?.props.mode;
|
||||
if (
|
||||
// 当前菜单禁用时,不展开
|
||||
props.disabled ||
|
||||
(rootMenu?.props.collapse && mode === 'vertical') ||
|
||||
// 水平模式下不展开
|
||||
mode === 'horizontal'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
rootMenu?.handleSubMenuClick({
|
||||
active: active.value,
|
||||
parentPaths: parentPaths.value,
|
||||
path: props.path,
|
||||
});
|
||||
}
|
||||
|
||||
function handleMouseenter(event: FocusEvent | MouseEvent, showTimeout = 300) {
|
||||
if (event.type === 'focus') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(!rootMenu?.props.collapse && rootMenu?.props.mode === 'vertical') ||
|
||||
props.disabled
|
||||
) {
|
||||
if (subMenu) {
|
||||
subMenu.mouseInChild.value = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (subMenu) {
|
||||
subMenu.mouseInChild.value = true;
|
||||
}
|
||||
|
||||
timer.value && window.clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => {
|
||||
rootMenu?.openMenu(props.path, parentPaths.value);
|
||||
}, showTimeout);
|
||||
parentMenu.value?.vnode.el?.dispatchEvent(new MouseEvent('mouseenter'));
|
||||
}
|
||||
|
||||
function handleMouseleave(deepDispatch = false) {
|
||||
if (
|
||||
!rootMenu?.props.collapse &&
|
||||
rootMenu?.props.mode === 'vertical' &&
|
||||
subMenu
|
||||
) {
|
||||
subMenu.mouseInChild.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
timer.value && window.clearTimeout(timer.value);
|
||||
|
||||
if (subMenu) {
|
||||
subMenu.mouseInChild.value = false;
|
||||
}
|
||||
timer.value = setTimeout(() => {
|
||||
!mouseInChild.value && rootMenu?.closeMenu(props.path, parentPaths.value);
|
||||
}, 300);
|
||||
|
||||
if (deepDispatch) {
|
||||
subMenu?.handleMouseleave?.(true);
|
||||
}
|
||||
}
|
||||
|
||||
const menuIcon = computed(() =>
|
||||
active.value ? props.activeIcon || props.icon : props.icon,
|
||||
);
|
||||
|
||||
const item = reactive({
|
||||
active,
|
||||
parentPaths,
|
||||
path: props.path,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
subMenu?.addSubMenu?.(item);
|
||||
rootMenu?.addSubMenu?.(item);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
subMenu?.removeSubMenu?.(item);
|
||||
rootMenu?.removeSubMenu?.(item);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
b(),
|
||||
is('opened', opened),
|
||||
is('active', active),
|
||||
is('disabled', disabled),
|
||||
]"
|
||||
@focus="handleMouseenter"
|
||||
@mouseenter="handleMouseenter"
|
||||
@mouseleave="() => handleMouseleave()"
|
||||
>
|
||||
<template v-if="rootMenu.isMenuPopup">
|
||||
<VbenHoverCard
|
||||
:content-class="[
|
||||
rootMenu.theme,
|
||||
nsMenu.e('popup-container'),
|
||||
is(rootMenu.theme, true),
|
||||
opened ? '' : 'hidden',
|
||||
'overflow-auto',
|
||||
'max-h-[calc(var(--reka-hover-card-content-available-height)-20px)]',
|
||||
]"
|
||||
:content-props="contentProps"
|
||||
:open="true"
|
||||
:open-delay="0"
|
||||
>
|
||||
<template #trigger>
|
||||
<SubMenuContent
|
||||
:class="is('active', active)"
|
||||
:icon="menuIcon"
|
||||
:is-menu-more="isSubMenuMore"
|
||||
:is-top-level-menu-submenu="isTopLevelMenuSubmenu"
|
||||
:level="currentLevel"
|
||||
:path="path"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="title"></slot>
|
||||
</template>
|
||||
</SubMenuContent>
|
||||
</template>
|
||||
<div
|
||||
:class="[nsMenu.is(mode, true), nsMenu.e('popup')]"
|
||||
@focus="(e) => handleMouseenter(e, 100)"
|
||||
@mouseenter="(e) => handleMouseenter(e, 100)"
|
||||
@mouseleave="() => handleMouseleave(true)"
|
||||
>
|
||||
<ul
|
||||
:class="[nsMenu.b(), is('rounded', rounded)]"
|
||||
:style="subMenuStyle"
|
||||
>
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</div>
|
||||
</VbenHoverCard>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<SubMenuContent
|
||||
:class="is('active', active)"
|
||||
:icon="menuIcon"
|
||||
:is-menu-more="isSubMenuMore"
|
||||
:is-top-level-menu-submenu="isTopLevelMenuSubmenu"
|
||||
:level="currentLevel"
|
||||
:path="path"
|
||||
@click.stop="handleClick"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
<template #title>
|
||||
<slot name="title"></slot>
|
||||
</template>
|
||||
</SubMenuContent>
|
||||
<CollapseTransition>
|
||||
<ul
|
||||
v-show="opened"
|
||||
:class="[nsMenu.b(), is('rounded', rounded)]"
|
||||
:style="subMenuStyle"
|
||||
>
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</CollapseTransition>
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
||||
246
packages/@core/ui-kit/popup-ui/src/alert/AlertBuilder.ts
Normal file
246
packages/@core/ui-kit/popup-ui/src/alert/AlertBuilder.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type { Component, VNode } from 'vue';
|
||||
|
||||
import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import type { AlertProps, BeforeCloseScope, PromptProps } from './alert';
|
||||
|
||||
import { h, nextTick, ref, render } from 'vue';
|
||||
|
||||
import { useSimpleLocale } from '@vben-core/composables';
|
||||
import { Input, VbenRenderContent } from '@vben-core/shadcn-ui';
|
||||
import { isFunction, isString } from '@vben-core/shared/utils';
|
||||
|
||||
import Alert from './alert.vue';
|
||||
|
||||
const alerts = ref<Array<{ container: HTMLElement; instance: Component }>>([]);
|
||||
|
||||
const { $t } = useSimpleLocale();
|
||||
|
||||
export function vbenAlert(options: AlertProps): Promise<void>;
|
||||
export function vbenAlert(
|
||||
message: string,
|
||||
options?: Partial<AlertProps>,
|
||||
): Promise<void>;
|
||||
export function vbenAlert(
|
||||
message: string,
|
||||
title?: string,
|
||||
options?: Partial<AlertProps>,
|
||||
): Promise<void>;
|
||||
|
||||
export function vbenAlert(
|
||||
arg0: AlertProps | string,
|
||||
arg1?: Partial<AlertProps> | string,
|
||||
arg2?: Partial<AlertProps>,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options: AlertProps = isString(arg0)
|
||||
? {
|
||||
content: arg0,
|
||||
}
|
||||
: { ...arg0 };
|
||||
if (arg1) {
|
||||
if (isString(arg1)) {
|
||||
options.title = arg1;
|
||||
} else if (!isString(arg1)) {
|
||||
// 如果第二个参数是对象,则合并到选项中
|
||||
Object.assign(options, arg1);
|
||||
}
|
||||
}
|
||||
|
||||
if (arg2 && !isString(arg2)) {
|
||||
Object.assign(options, arg2);
|
||||
}
|
||||
// 创建容器元素
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
|
||||
// 创建一个引用,用于在回调中访问实例
|
||||
const alertRef = { container, instance: null as any };
|
||||
|
||||
const props: AlertProps & Recordable<any> = {
|
||||
onClosed: (isConfirm: boolean) => {
|
||||
// 移除组件实例以及创建的所有dom(恢复页面到打开前的状态)
|
||||
// 从alerts数组中移除该实例
|
||||
alerts.value = alerts.value.filter((item) => item !== alertRef);
|
||||
|
||||
// 从DOM中移除容器
|
||||
render(null, container);
|
||||
if (container.parentNode) {
|
||||
container.remove();
|
||||
}
|
||||
|
||||
// 解析 Promise,传递用户操作结果
|
||||
if (isConfirm) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('dialog cancelled'));
|
||||
}
|
||||
},
|
||||
...options,
|
||||
open: true,
|
||||
title: options.title ?? $t.value('prompt'),
|
||||
};
|
||||
|
||||
// 创建Alert组件的VNode
|
||||
const vnode = h(Alert, props);
|
||||
|
||||
// 渲染组件到容器
|
||||
render(vnode, container);
|
||||
|
||||
// 保存组件实例引用
|
||||
alertRef.instance = vnode.component?.proxy as Component;
|
||||
|
||||
// 将实例和容器添加到alerts数组中
|
||||
alerts.value.push(alertRef);
|
||||
});
|
||||
}
|
||||
|
||||
export function vbenConfirm(options: AlertProps): Promise<void>;
|
||||
export function vbenConfirm(
|
||||
message: string,
|
||||
options?: Partial<AlertProps>,
|
||||
): Promise<void>;
|
||||
export function vbenConfirm(
|
||||
message: string,
|
||||
title?: string,
|
||||
options?: Partial<AlertProps>,
|
||||
): Promise<void>;
|
||||
|
||||
export function vbenConfirm(
|
||||
arg0: AlertProps | string,
|
||||
arg1?: Partial<AlertProps> | string,
|
||||
arg2?: Partial<AlertProps>,
|
||||
): Promise<void> {
|
||||
const defaultProps: Partial<AlertProps> = {
|
||||
showCancel: true,
|
||||
};
|
||||
if (!arg1) {
|
||||
return isString(arg0)
|
||||
? vbenAlert(arg0, defaultProps)
|
||||
: vbenAlert({ ...defaultProps, ...arg0 });
|
||||
} else if (!arg2) {
|
||||
return isString(arg1)
|
||||
? vbenAlert(arg0 as string, arg1, defaultProps)
|
||||
: vbenAlert(arg0 as string, { ...defaultProps, ...arg1 });
|
||||
}
|
||||
return vbenAlert(arg0 as string, arg1 as string, {
|
||||
...defaultProps,
|
||||
...arg2,
|
||||
});
|
||||
}
|
||||
|
||||
export async function vbenPrompt<T = any>(
|
||||
options: PromptProps<T>,
|
||||
): Promise<T | undefined> {
|
||||
const {
|
||||
component: _component,
|
||||
componentProps: _componentProps,
|
||||
componentSlots,
|
||||
content,
|
||||
defaultValue,
|
||||
modelPropName: _modelPropName,
|
||||
...delegated
|
||||
} = options;
|
||||
|
||||
const modelValue = ref<T | undefined>(defaultValue);
|
||||
const inputComponentRef = ref<null | VNode>(null);
|
||||
const staticContents: Component[] = [
|
||||
h(VbenRenderContent, { content, renderBr: true }),
|
||||
];
|
||||
|
||||
const modelPropName = _modelPropName || 'modelValue';
|
||||
const componentProps = { ..._componentProps };
|
||||
|
||||
// 每次渲染时都会重新计算的内容函数
|
||||
const contentRenderer = () => {
|
||||
const currentProps = {
|
||||
...componentProps,
|
||||
[modelPropName]: modelValue.value,
|
||||
[`onUpdate:${modelPropName}`]: (val: T) => {
|
||||
modelValue.value = val;
|
||||
},
|
||||
};
|
||||
|
||||
// 设置当前值
|
||||
|
||||
// 设置更新处理函数
|
||||
|
||||
// 创建输入组件
|
||||
inputComponentRef.value = h(
|
||||
_component || Input,
|
||||
currentProps,
|
||||
componentSlots,
|
||||
);
|
||||
|
||||
// 返回包含静态内容和输入组件的数组
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex flex-col gap-2' },
|
||||
{ default: () => [...staticContents, inputComponentRef.value] },
|
||||
);
|
||||
};
|
||||
|
||||
const props: AlertProps & Recordable<any> = {
|
||||
...delegated,
|
||||
async beforeClose(scope: BeforeCloseScope) {
|
||||
if (delegated.beforeClose) {
|
||||
return await delegated.beforeClose({
|
||||
...scope,
|
||||
value: modelValue.value,
|
||||
});
|
||||
}
|
||||
},
|
||||
// 使用函数形式,每次渲染都会重新计算内容
|
||||
content: contentRenderer,
|
||||
contentMasking: true,
|
||||
async onOpened() {
|
||||
await nextTick();
|
||||
const componentRef: null | VNode = inputComponentRef.value;
|
||||
if (componentRef) {
|
||||
if (
|
||||
componentRef.component?.exposed &&
|
||||
isFunction(componentRef.component.exposed.focus)
|
||||
) {
|
||||
componentRef.component.exposed.focus();
|
||||
} else {
|
||||
if (componentRef.el) {
|
||||
if (
|
||||
isFunction(componentRef.el.focus) &&
|
||||
['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(
|
||||
componentRef.el.tagName,
|
||||
)
|
||||
) {
|
||||
componentRef.el.focus();
|
||||
} else if (isFunction(componentRef.el.querySelector)) {
|
||||
const focusableElement = componentRef.el.querySelector(
|
||||
'input, select, textarea, button',
|
||||
);
|
||||
if (focusableElement && isFunction(focusableElement.focus)) {
|
||||
focusableElement.focus();
|
||||
}
|
||||
} else if (
|
||||
componentRef.el.nextElementSibling &&
|
||||
isFunction(componentRef.el.nextElementSibling.focus)
|
||||
) {
|
||||
componentRef.el.nextElementSibling.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
await vbenConfirm(props);
|
||||
return modelValue.value;
|
||||
}
|
||||
|
||||
export function clearAllAlerts() {
|
||||
alerts.value.forEach((alert) => {
|
||||
// 从DOM中移除容器
|
||||
render(null, alert.container);
|
||||
if (alert.container.parentNode) {
|
||||
alert.container.remove();
|
||||
}
|
||||
});
|
||||
alerts.value = [];
|
||||
}
|
||||
210
packages/@core/ui-kit/popup-ui/src/alert/alert.vue
Normal file
210
packages/@core/ui-kit/popup-ui/src/alert/alert.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { AlertProps } from './alert';
|
||||
|
||||
import { computed, h, nextTick, ref } from 'vue';
|
||||
|
||||
import { useSimpleLocale } from '@vben-core/composables';
|
||||
import {
|
||||
CircleAlert,
|
||||
CircleCheckBig,
|
||||
CircleHelp,
|
||||
CircleX,
|
||||
Info,
|
||||
X,
|
||||
} from '@vben-core/icons';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
VbenButton,
|
||||
VbenLoading,
|
||||
VbenRenderContent,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { globalShareState } from '@vben-core/shared/global-state';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { provideAlertContext } from './alert';
|
||||
|
||||
const props = withDefaults(defineProps<AlertProps>(), {
|
||||
bordered: true,
|
||||
buttonAlign: 'end',
|
||||
centered: true,
|
||||
});
|
||||
const emits = defineEmits(['closed', 'confirm', 'opened']);
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
const { $t } = useSimpleLocale();
|
||||
const components = globalShareState.getComponents();
|
||||
const isConfirm = ref(false);
|
||||
|
||||
function onAlertClosed() {
|
||||
emits('closed', isConfirm.value);
|
||||
isConfirm.value = false;
|
||||
}
|
||||
|
||||
function onEscapeKeyDown() {
|
||||
isConfirm.value = false;
|
||||
}
|
||||
|
||||
const getIconRender = computed(() => {
|
||||
let iconRender: Component | null = null;
|
||||
if (props.icon) {
|
||||
if (typeof props.icon === 'string') {
|
||||
switch (props.icon) {
|
||||
case 'error': {
|
||||
iconRender = h(CircleX, {
|
||||
style: { color: 'hsl(var(--destructive))' },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'info': {
|
||||
iconRender = h(Info, { style: { color: 'hsl(var(--info))' } });
|
||||
break;
|
||||
}
|
||||
case 'question': {
|
||||
iconRender = CircleHelp;
|
||||
break;
|
||||
}
|
||||
case 'success': {
|
||||
iconRender = h(CircleCheckBig, {
|
||||
style: { color: 'hsl(var(--success))' },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'warning': {
|
||||
iconRender = h(CircleAlert, {
|
||||
style: { color: 'hsl(var(--warning))' },
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
iconRender = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
iconRender = props.icon ?? null;
|
||||
}
|
||||
return iconRender;
|
||||
});
|
||||
|
||||
function doCancel() {
|
||||
handleCancel();
|
||||
handleOpenChange(false);
|
||||
}
|
||||
|
||||
function doConfirm() {
|
||||
handleConfirm();
|
||||
handleOpenChange(false);
|
||||
}
|
||||
|
||||
provideAlertContext({
|
||||
doCancel,
|
||||
doConfirm,
|
||||
});
|
||||
|
||||
function handleConfirm() {
|
||||
isConfirm.value = true;
|
||||
emits('confirm');
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
isConfirm.value = false;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
async function handleOpenChange(val: boolean) {
|
||||
await nextTick(); // 等待标记isConfirm状态
|
||||
if (!val && props.beforeClose) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await props.beforeClose({ isConfirm: isConfirm.value });
|
||||
if (res !== false) {
|
||||
open.value = false;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
} else {
|
||||
open.value = val;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AlertDialog :open="open" @update:open="handleOpenChange">
|
||||
<AlertDialogContent
|
||||
:open="open"
|
||||
:centered="centered"
|
||||
:overlay-blur="overlayBlur"
|
||||
@opened="emits('opened')"
|
||||
@closed="onAlertClosed"
|
||||
@escape-key-down="onEscapeKeyDown"
|
||||
:class="
|
||||
cn(
|
||||
containerClass,
|
||||
'left-0 right-0 mx-auto flex max-h-[80%] flex-col p-0 duration-300 sm:w-[520px] sm:max-w-[80%] sm:rounded-[var(--radius)]',
|
||||
{
|
||||
'border border-border': bordered,
|
||||
'shadow-3xl': !bordered,
|
||||
},
|
||||
)
|
||||
"
|
||||
>
|
||||
<div :class="cn('relative flex-1 overflow-y-auto p-3', contentClass)">
|
||||
<AlertDialogTitle v-if="title">
|
||||
<div class="flex items-center">
|
||||
<component :is="getIconRender" class="mr-2" />
|
||||
<span class="flex-auto">{{ $t(title) }}</span>
|
||||
<AlertDialogCancel v-if="showCancel" as-child>
|
||||
<VbenButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:disabled="loading"
|
||||
@click="handleCancel"
|
||||
>
|
||||
<X class="size-4 text-muted-foreground" />
|
||||
</VbenButton>
|
||||
</AlertDialogCancel>
|
||||
</div>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<div class="m-4 min-h-[30px]">
|
||||
<VbenRenderContent :content="content" render-br />
|
||||
</div>
|
||||
<VbenLoading v-if="loading && contentMasking" :spinning="loading" />
|
||||
</AlertDialogDescription>
|
||||
<div
|
||||
class="flex items-center justify-end gap-x-2"
|
||||
:class="`justify-${buttonAlign}`"
|
||||
>
|
||||
<VbenRenderContent :content="footer" />
|
||||
<AlertDialogCancel v-if="showCancel" as-child>
|
||||
<component
|
||||
:is="components.DefaultButton || VbenButton"
|
||||
:disabled="loading"
|
||||
variant="ghost"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelText || $t('cancel') }}
|
||||
</component>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<component
|
||||
:is="components.PrimaryButton || VbenButton"
|
||||
:loading="loading"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmText || $t('confirm') }}
|
||||
</component>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
332
packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue
Normal file
332
packages/@core/ui-kit/popup-ui/src/drawer/drawer.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DrawerProps, ExtendedDrawerApi } from './drawer';
|
||||
|
||||
import {
|
||||
computed,
|
||||
onDeactivated,
|
||||
provide,
|
||||
ref,
|
||||
unref,
|
||||
useId,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
useIsMobile,
|
||||
usePriorityValues,
|
||||
useSimpleLocale,
|
||||
} from '@vben-core/composables';
|
||||
import { X } from '@vben-core/icons';
|
||||
import {
|
||||
Separator,
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
VbenButton,
|
||||
VbenHelpTooltip,
|
||||
VbenIconButton,
|
||||
VbenLoading,
|
||||
VisuallyHidden,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants';
|
||||
import { globalShareState } from '@vben-core/shared/global-state';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
interface Props extends DrawerProps {
|
||||
drawerApi?: ExtendedDrawerApi;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
appendToMain: false,
|
||||
closeIconPlacement: 'right',
|
||||
destroyOnClose: false,
|
||||
drawerApi: undefined,
|
||||
submitting: false,
|
||||
zIndex: 1000,
|
||||
});
|
||||
|
||||
const components = globalShareState.getComponents();
|
||||
|
||||
const id = useId();
|
||||
provide('DISMISSABLE_DRAWER_ID', id);
|
||||
|
||||
const wrapperRef = ref<HTMLElement>();
|
||||
const { $t } = useSimpleLocale();
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
const state = props.drawerApi?.useStore?.();
|
||||
|
||||
const {
|
||||
appendToMain,
|
||||
cancelText,
|
||||
class: drawerClass,
|
||||
closable,
|
||||
closeIconPlacement,
|
||||
closeOnClickModal,
|
||||
closeOnPressEscape,
|
||||
confirmLoading,
|
||||
confirmText,
|
||||
contentClass,
|
||||
description,
|
||||
destroyOnClose,
|
||||
footer: showFooter,
|
||||
footerClass,
|
||||
header: showHeader,
|
||||
headerClass,
|
||||
loading: showLoading,
|
||||
modal,
|
||||
openAutoFocus,
|
||||
overlayBlur,
|
||||
placement,
|
||||
showCancelButton,
|
||||
showConfirmButton,
|
||||
submitting,
|
||||
title,
|
||||
titleTooltip,
|
||||
zIndex,
|
||||
} = usePriorityValues(props, state);
|
||||
|
||||
// watch(
|
||||
// () => showLoading.value,
|
||||
// (v) => {
|
||||
// if (v && wrapperRef.value) {
|
||||
// wrapperRef.value.scrollTo({
|
||||
// // behavior: 'smooth',
|
||||
// top: 0,
|
||||
// });
|
||||
// }
|
||||
// },
|
||||
// );
|
||||
|
||||
/**
|
||||
* 在开启keepAlive情况下 直接通过浏览器按钮/手势等返回 不会关闭弹窗
|
||||
*/
|
||||
onDeactivated(() => {
|
||||
// 如果弹窗没有被挂载到内容区域,则关闭弹窗
|
||||
if (!appendToMain.value) {
|
||||
props.drawerApi?.close();
|
||||
}
|
||||
});
|
||||
|
||||
function interactOutside(e: Event) {
|
||||
if (!closeOnClickModal.value || submitting.value) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
function escapeKeyDown(e: KeyboardEvent) {
|
||||
if (!closeOnPressEscape.value || submitting.value) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
// pointer-down-outside
|
||||
function pointerDownOutside(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
const dismissableDrawer = target?.dataset.dismissableDrawer;
|
||||
if (
|
||||
submitting.value ||
|
||||
!closeOnClickModal.value ||
|
||||
dismissableDrawer !== id
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handerOpenAutoFocus(e: Event) {
|
||||
if (!openAutoFocus.value) {
|
||||
e?.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocusOutside(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
const getAppendTo = computed(() => {
|
||||
return appendToMain.value
|
||||
? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div`
|
||||
: undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* destroyOnClose功能完善
|
||||
*/
|
||||
// 是否打开过
|
||||
const hasOpened = ref(false);
|
||||
const isClosed = ref(true);
|
||||
watch(
|
||||
() => state?.value?.isOpen,
|
||||
(value) => {
|
||||
isClosed.value = false;
|
||||
if (value && !unref(hasOpened)) {
|
||||
hasOpened.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
function handleClosed() {
|
||||
isClosed.value = true;
|
||||
props.drawerApi?.onClosed();
|
||||
}
|
||||
const getForceMount = computed(() => {
|
||||
return !unref(destroyOnClose) && unref(hasOpened);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Sheet
|
||||
:modal="false"
|
||||
:open="state?.isOpen"
|
||||
@update:open="() => drawerApi?.close()"
|
||||
>
|
||||
<SheetContent
|
||||
:append-to="getAppendTo"
|
||||
:class="
|
||||
cn('flex w-[520px] flex-col', drawerClass, {
|
||||
'!w-full': isMobile || placement === 'bottom' || placement === 'top',
|
||||
'max-h-[100vh]': placement === 'bottom' || placement === 'top',
|
||||
hidden: isClosed,
|
||||
})
|
||||
"
|
||||
:modal="modal"
|
||||
:open="state?.isOpen"
|
||||
:side="placement"
|
||||
:z-index="zIndex"
|
||||
:force-mount="getForceMount"
|
||||
:overlay-blur="overlayBlur"
|
||||
@close-auto-focus="handleFocusOutside"
|
||||
@closed="handleClosed"
|
||||
@escape-key-down="escapeKeyDown"
|
||||
@focus-outside="handleFocusOutside"
|
||||
@interact-outside="interactOutside"
|
||||
@open-auto-focus="handerOpenAutoFocus"
|
||||
@opened="() => drawerApi?.onOpened()"
|
||||
@pointer-down-outside="pointerDownOutside"
|
||||
>
|
||||
<SheetHeader
|
||||
v-if="showHeader"
|
||||
:class="
|
||||
cn(
|
||||
'!flex flex-row items-center justify-between border-b px-6 py-5',
|
||||
headerClass,
|
||||
{
|
||||
'px-4 py-3': closable,
|
||||
'pl-2': closable && closeIconPlacement === 'left',
|
||||
},
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<SheetClose
|
||||
v-if="closable && closeIconPlacement === 'left'"
|
||||
as-child
|
||||
:disabled="submitting"
|
||||
class="ml-[2px] cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none data-[state=open]:bg-secondary"
|
||||
>
|
||||
<slot name="close-icon">
|
||||
<VbenIconButton>
|
||||
<X class="size-4" />
|
||||
</VbenIconButton>
|
||||
</slot>
|
||||
</SheetClose>
|
||||
<Separator
|
||||
v-if="closable && closeIconPlacement === 'left'"
|
||||
class="ml-1 mr-2 h-8"
|
||||
decorative
|
||||
orientation="vertical"
|
||||
/>
|
||||
<SheetTitle v-if="title" class="text-left">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
|
||||
<VbenHelpTooltip v-if="titleTooltip" trigger-class="pb-1">
|
||||
{{ titleTooltip }}
|
||||
</VbenHelpTooltip>
|
||||
</slot>
|
||||
</SheetTitle>
|
||||
<SheetDescription v-if="description" class="mt-1 text-xs">
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</SheetDescription>
|
||||
</div>
|
||||
|
||||
<VisuallyHidden v-if="!title || !description">
|
||||
<SheetTitle v-if="!title" />
|
||||
<SheetDescription v-if="!description" />
|
||||
</VisuallyHidden>
|
||||
|
||||
<div class="flex-center">
|
||||
<slot name="extra"></slot>
|
||||
<SheetClose
|
||||
v-if="closable && closeIconPlacement === 'right'"
|
||||
as-child
|
||||
:disabled="submitting"
|
||||
class="ml-[2px] cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none data-[state=open]:bg-secondary"
|
||||
>
|
||||
<slot name="close-icon">
|
||||
<VbenIconButton>
|
||||
<X class="size-4" />
|
||||
</VbenIconButton>
|
||||
</slot>
|
||||
</SheetClose>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<template v-else>
|
||||
<VisuallyHidden>
|
||||
<SheetTitle />
|
||||
<SheetDescription />
|
||||
</VisuallyHidden>
|
||||
</template>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
:class="
|
||||
cn('relative flex-1 overflow-y-auto p-3', contentClass, {
|
||||
'pointer-events-none': showLoading || submitting,
|
||||
})
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<VbenLoading v-if="showLoading || submitting" spinning />
|
||||
<SheetFooter
|
||||
v-if="showFooter"
|
||||
:class="
|
||||
cn(
|
||||
'w-full flex-row items-center justify-end border-t p-2 px-3',
|
||||
footerClass,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="prepend-footer"></slot>
|
||||
<slot name="footer">
|
||||
<component
|
||||
:is="components.DefaultButton || VbenButton"
|
||||
v-if="showCancelButton"
|
||||
variant="ghost"
|
||||
:disabled="submitting"
|
||||
@click="() => drawerApi?.onCancel()"
|
||||
>
|
||||
<slot name="cancelText">
|
||||
{{ cancelText || $t('cancel') }}
|
||||
</slot>
|
||||
</component>
|
||||
<slot name="center-footer"></slot>
|
||||
<component
|
||||
:is="components.PrimaryButton || VbenButton"
|
||||
v-if="showConfirmButton"
|
||||
:loading="confirmLoading || submitting"
|
||||
@click="() => drawerApi?.onConfirm()"
|
||||
>
|
||||
<slot name="confirmText">
|
||||
{{ confirmText || $t('confirm') }}
|
||||
</slot>
|
||||
</component>
|
||||
</slot>
|
||||
<slot name="append-footer"></slot>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</template>
|
||||
196
packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts
Normal file
196
packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { ModalApiOptions, ModalState } from './modal';
|
||||
|
||||
import { Store } from '@vben-core/shared/store';
|
||||
import { bindMethods, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
export class ModalApi {
|
||||
// 共享数据
|
||||
public sharedData: Record<'payload', any> = {
|
||||
payload: {},
|
||||
};
|
||||
public store: Store<ModalState>;
|
||||
|
||||
private api: Pick<
|
||||
ModalApiOptions,
|
||||
| 'onBeforeClose'
|
||||
| 'onCancel'
|
||||
| 'onClosed'
|
||||
| 'onConfirm'
|
||||
| 'onOpenChange'
|
||||
| 'onOpened'
|
||||
>;
|
||||
|
||||
// private prevState!: ModalState;
|
||||
private state!: ModalState;
|
||||
|
||||
constructor(options: ModalApiOptions = {}) {
|
||||
const {
|
||||
connectedComponent: _,
|
||||
onBeforeClose,
|
||||
onCancel,
|
||||
onClosed,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
onOpened,
|
||||
...storeState
|
||||
} = options;
|
||||
|
||||
const defaultState: ModalState = {
|
||||
bordered: true,
|
||||
centered: false,
|
||||
class: '',
|
||||
closeOnClickModal: true,
|
||||
closeOnPressEscape: true,
|
||||
confirmDisabled: false,
|
||||
confirmLoading: false,
|
||||
contentClass: '',
|
||||
destroyOnClose: true,
|
||||
draggable: false,
|
||||
footer: true,
|
||||
footerClass: '',
|
||||
fullscreen: false,
|
||||
fullscreenButton: true,
|
||||
header: true,
|
||||
headerClass: '',
|
||||
isOpen: false,
|
||||
loading: false,
|
||||
modal: true,
|
||||
openAutoFocus: false,
|
||||
showCancelButton: true,
|
||||
showConfirmButton: true,
|
||||
title: '',
|
||||
animationType: 'slide',
|
||||
};
|
||||
|
||||
this.store = new Store<ModalState>(
|
||||
{
|
||||
...defaultState,
|
||||
...storeState,
|
||||
},
|
||||
{
|
||||
onUpdate: () => {
|
||||
const state = this.store.state;
|
||||
|
||||
// 每次更新状态时,都会调用 onOpenChange 回调函数
|
||||
if (state?.isOpen === this.state?.isOpen) {
|
||||
this.state = state;
|
||||
} else {
|
||||
this.state = state;
|
||||
this.api.onOpenChange?.(!!state?.isOpen);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.state = this.store.state;
|
||||
|
||||
this.api = {
|
||||
onBeforeClose,
|
||||
onCancel,
|
||||
onClosed,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
onOpened,
|
||||
};
|
||||
bindMethods(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
* @description 关闭弹窗时会调用 onBeforeClose 钩子函数,如果 onBeforeClose 返回 false,则不关闭弹窗
|
||||
*/
|
||||
async close() {
|
||||
// 通过 onBeforeClose 钩子函数来判断是否允许关闭弹窗
|
||||
// 如果 onBeforeClose 返回 false,则不关闭弹窗
|
||||
const allowClose = (await this.api.onBeforeClose?.()) ?? true;
|
||||
if (allowClose) {
|
||||
this.store.setState((prev) => ({
|
||||
...prev,
|
||||
isOpen: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
getData<T extends object = Record<string, any>>() {
|
||||
return (this.sharedData?.payload ?? {}) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定弹窗状态(用于提交过程中的等待状态)
|
||||
* @description 锁定状态将禁用默认的取消按钮,使用spinner覆盖弹窗内容,隐藏关闭按钮,阻止手动关闭弹窗,将默认的提交按钮标记为loading状态
|
||||
* @param isLocked 是否锁定
|
||||
*/
|
||||
lock(isLocked = true) {
|
||||
return this.setState({ submitting: isLocked });
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消操作
|
||||
*/
|
||||
onCancel() {
|
||||
if (this.api.onCancel) {
|
||||
this.api.onCancel?.();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗关闭动画播放完毕后的回调
|
||||
*/
|
||||
onClosed() {
|
||||
if (!this.state.isOpen) {
|
||||
this.api.onClosed?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认操作
|
||||
*/
|
||||
onConfirm() {
|
||||
this.api.onConfirm?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗打开动画播放完毕后的回调
|
||||
*/
|
||||
onOpened() {
|
||||
if (this.state.isOpen) {
|
||||
this.api.onOpened?.();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.store.setState((prev) => ({
|
||||
...prev,
|
||||
isOpen: true,
|
||||
submitting: false,
|
||||
}));
|
||||
}
|
||||
|
||||
setData<T>(payload: T) {
|
||||
this.sharedData.payload = payload;
|
||||
return this;
|
||||
}
|
||||
|
||||
setState(
|
||||
stateOrFn:
|
||||
| ((prev: ModalState) => Partial<ModalState>)
|
||||
| Partial<ModalState>,
|
||||
) {
|
||||
if (isFunction(stateOrFn)) {
|
||||
this.store.setState(stateOrFn);
|
||||
} else {
|
||||
this.store.setState((prev) => ({ ...prev, ...stateOrFn }));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除弹窗的锁定状态
|
||||
* @description 解除由lock方法设置的锁定状态,是lock(false)的别名
|
||||
*/
|
||||
unlock() {
|
||||
return this.lock(false);
|
||||
}
|
||||
}
|
||||
194
packages/@core/ui-kit/popup-ui/src/modal/modal.ts
Normal file
194
packages/@core/ui-kit/popup-ui/src/modal/modal.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { Component, Ref } from 'vue';
|
||||
|
||||
import type { MaybePromise } from '@vben-core/typings';
|
||||
|
||||
import type { ModalApi } from './modal-api';
|
||||
|
||||
export interface ModalProps {
|
||||
/**
|
||||
* 动画类型
|
||||
* @default 'slide'
|
||||
*/
|
||||
animationType?: 'scale' | 'slide';
|
||||
/**
|
||||
* 是否要挂载到内容区域
|
||||
* @default false
|
||||
*/
|
||||
appendToMain?: boolean;
|
||||
/**
|
||||
* 是否显示边框
|
||||
* @default false
|
||||
*/
|
||||
bordered?: boolean;
|
||||
/**
|
||||
* 取消按钮文字
|
||||
*/
|
||||
cancelText?: string;
|
||||
/**
|
||||
* 是否居中
|
||||
* @default false
|
||||
*/
|
||||
centered?: boolean;
|
||||
|
||||
class?: string;
|
||||
|
||||
/**
|
||||
* 是否显示右上角的关闭按钮
|
||||
* @default true
|
||||
*/
|
||||
closable?: boolean;
|
||||
/**
|
||||
* 点击弹窗遮罩是否关闭弹窗
|
||||
* @default true
|
||||
*/
|
||||
closeOnClickModal?: boolean;
|
||||
/**
|
||||
* 按下 ESC 键是否关闭弹窗
|
||||
* @default true
|
||||
*/
|
||||
closeOnPressEscape?: boolean;
|
||||
/**
|
||||
* 禁用确认按钮
|
||||
*/
|
||||
confirmDisabled?: boolean;
|
||||
/**
|
||||
* 确定按钮 loading
|
||||
* @default false
|
||||
*/
|
||||
confirmLoading?: boolean;
|
||||
/**
|
||||
* 确定按钮文字
|
||||
*/
|
||||
confirmText?: string;
|
||||
contentClass?: string;
|
||||
/**
|
||||
* 弹窗描述
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* 在关闭时销毁弹窗
|
||||
*/
|
||||
destroyOnClose?: boolean;
|
||||
/**
|
||||
* 是否可拖拽
|
||||
* @default false
|
||||
*/
|
||||
draggable?: boolean;
|
||||
/**
|
||||
* 是否显示底部
|
||||
* @default true
|
||||
*/
|
||||
footer?: boolean;
|
||||
footerClass?: string;
|
||||
/**
|
||||
* 是否全屏
|
||||
* @default false
|
||||
*/
|
||||
fullscreen?: boolean;
|
||||
/**
|
||||
* 是否显示全屏按钮
|
||||
* @default true
|
||||
*/
|
||||
fullscreenButton?: boolean;
|
||||
/**
|
||||
* 是否显示顶栏
|
||||
* @default true
|
||||
*/
|
||||
header?: boolean;
|
||||
headerClass?: string;
|
||||
/**
|
||||
* 弹窗是否显示
|
||||
* @default false
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* 是否显示遮罩
|
||||
* @default true
|
||||
*/
|
||||
modal?: boolean;
|
||||
/**
|
||||
* 是否自动聚焦
|
||||
*/
|
||||
openAutoFocus?: boolean;
|
||||
/**
|
||||
* 弹窗遮罩模糊效果
|
||||
*/
|
||||
overlayBlur?: number;
|
||||
/**
|
||||
* 是否显示取消按钮
|
||||
* @default true
|
||||
*/
|
||||
showCancelButton?: boolean;
|
||||
/**
|
||||
* 是否显示确认按钮
|
||||
* @default true
|
||||
*/
|
||||
showConfirmButton?: boolean;
|
||||
/**
|
||||
* 提交中(锁定弹窗状态)
|
||||
*/
|
||||
submitting?: boolean;
|
||||
/**
|
||||
* 弹窗标题
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* 弹窗标题提示
|
||||
*/
|
||||
titleTooltip?: string;
|
||||
/**
|
||||
* 弹窗层级
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export interface ModalState extends ModalProps {
|
||||
/** 弹窗打开状态 */
|
||||
isOpen?: boolean;
|
||||
/**
|
||||
* 共享数据
|
||||
*/
|
||||
sharedData?: Record<string, any>;
|
||||
}
|
||||
|
||||
export type ExtendedModalApi = ModalApi & {
|
||||
useStore: <T = NoInfer<ModalState>>(
|
||||
selector?: (state: NoInfer<ModalState>) => T,
|
||||
) => Readonly<Ref<T>>;
|
||||
};
|
||||
|
||||
export interface ModalApiOptions extends ModalState {
|
||||
/**
|
||||
* 独立的弹窗组件
|
||||
*/
|
||||
connectedComponent?: Component;
|
||||
/**
|
||||
* 关闭前的回调,返回 false 可以阻止关闭
|
||||
* @returns
|
||||
*/
|
||||
onBeforeClose?: () => MaybePromise<boolean | undefined>;
|
||||
/**
|
||||
* 点击取消按钮的回调
|
||||
*/
|
||||
onCancel?: () => void;
|
||||
/**
|
||||
* 弹窗关闭动画结束的回调
|
||||
* @returns
|
||||
*/
|
||||
onClosed?: () => void;
|
||||
/**
|
||||
* 点击确定按钮的回调
|
||||
*/
|
||||
onConfirm?: () => void;
|
||||
/**
|
||||
* 弹窗状态变化回调
|
||||
* @param isOpen
|
||||
* @returns
|
||||
*/
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
/**
|
||||
* 弹窗打开动画结束的回调
|
||||
* @returns
|
||||
*/
|
||||
onOpened?: () => void;
|
||||
}
|
||||
371
packages/@core/ui-kit/popup-ui/src/modal/modal.vue
Normal file
371
packages/@core/ui-kit/popup-ui/src/modal/modal.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ExtendedModalApi, ModalProps } from './modal';
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onDeactivated,
|
||||
provide,
|
||||
ref,
|
||||
unref,
|
||||
useId,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
useIsMobile,
|
||||
usePriorityValues,
|
||||
useSimpleLocale,
|
||||
} from '@vben-core/composables';
|
||||
import { Expand, Shrink } from '@vben-core/icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
VbenButton,
|
||||
VbenHelpTooltip,
|
||||
VbenIconButton,
|
||||
VbenLoading,
|
||||
VisuallyHidden,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants';
|
||||
import { globalShareState } from '@vben-core/shared/global-state';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { useModalDraggable } from './use-modal-draggable';
|
||||
|
||||
interface Props extends ModalProps {
|
||||
modalApi?: ExtendedModalApi;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
appendToMain: false,
|
||||
destroyOnClose: false,
|
||||
modalApi: undefined,
|
||||
});
|
||||
|
||||
const components = globalShareState.getComponents();
|
||||
|
||||
const contentRef = ref();
|
||||
const wrapperRef = ref<HTMLElement>();
|
||||
const dialogRef = ref();
|
||||
const headerRef = ref();
|
||||
const footerRef = ref();
|
||||
|
||||
const id = useId();
|
||||
|
||||
provide('DISMISSABLE_MODAL_ID', id);
|
||||
|
||||
const { $t } = useSimpleLocale();
|
||||
const { isMobile } = useIsMobile();
|
||||
const state = props.modalApi?.useStore?.();
|
||||
|
||||
const {
|
||||
appendToMain,
|
||||
bordered,
|
||||
cancelText,
|
||||
centered,
|
||||
class: modalClass,
|
||||
closable,
|
||||
closeOnClickModal,
|
||||
closeOnPressEscape,
|
||||
confirmDisabled,
|
||||
confirmLoading,
|
||||
confirmText,
|
||||
contentClass,
|
||||
description,
|
||||
destroyOnClose,
|
||||
draggable,
|
||||
footer: showFooter,
|
||||
footerClass,
|
||||
fullscreen,
|
||||
fullscreenButton,
|
||||
header,
|
||||
headerClass,
|
||||
loading: showLoading,
|
||||
modal,
|
||||
openAutoFocus,
|
||||
overlayBlur,
|
||||
showCancelButton,
|
||||
showConfirmButton,
|
||||
submitting,
|
||||
title,
|
||||
titleTooltip,
|
||||
animationType,
|
||||
zIndex,
|
||||
} = usePriorityValues(props, state);
|
||||
|
||||
const shouldFullscreen = computed(() => fullscreen.value || isMobile.value);
|
||||
|
||||
const shouldDraggable = computed(
|
||||
() => draggable.value && !shouldFullscreen.value && header.value,
|
||||
);
|
||||
|
||||
const shouldCentered = computed(
|
||||
() => centered.value && !shouldFullscreen.value,
|
||||
);
|
||||
|
||||
const getAppendTo = computed(() => {
|
||||
return appendToMain.value
|
||||
? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div`
|
||||
: undefined;
|
||||
});
|
||||
|
||||
const { dragging, transform } = useModalDraggable(
|
||||
dialogRef,
|
||||
headerRef,
|
||||
shouldDraggable,
|
||||
getAppendTo,
|
||||
shouldCentered,
|
||||
);
|
||||
|
||||
const firstOpened = ref(false);
|
||||
const isClosed = ref(true);
|
||||
|
||||
watch(
|
||||
() => state?.value?.isOpen,
|
||||
async (v) => {
|
||||
if (v) {
|
||||
isClosed.value = false;
|
||||
if (!firstOpened.value) firstOpened.value = true;
|
||||
await nextTick();
|
||||
if (!contentRef.value) return;
|
||||
const innerContentRef = contentRef.value.getContentRef();
|
||||
dialogRef.value = innerContentRef.$el;
|
||||
// reopen modal reassign value
|
||||
const { offsetX, offsetY } = transform;
|
||||
dialogRef.value.style.transform = shouldCentered.value
|
||||
? `translate(${offsetX}px, calc(-50% + ${offsetY}px))`
|
||||
: `translate(${offsetX}px, ${offsetY}px)`;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// watch(
|
||||
// () => [showLoading.value, submitting.value],
|
||||
// ([l, s]) => {
|
||||
// if ((s || l) && wrapperRef.value) {
|
||||
// wrapperRef.value.scrollTo({
|
||||
// // behavior: 'smooth',
|
||||
// top: 0,
|
||||
// });
|
||||
// }
|
||||
// },
|
||||
// );
|
||||
|
||||
/**
|
||||
* 在开启keepAlive情况下 直接通过浏览器按钮/手势等返回 不会关闭弹窗
|
||||
*/
|
||||
onDeactivated(() => {
|
||||
// 如果弹窗没有被挂载到内容区域,则关闭弹窗
|
||||
if (!appendToMain.value) {
|
||||
props.modalApi?.close();
|
||||
}
|
||||
});
|
||||
|
||||
function handleFullscreen() {
|
||||
props.modalApi?.setState((prev) => {
|
||||
// if (prev.fullscreen) {
|
||||
// resetPosition();
|
||||
// }
|
||||
return { ...prev, fullscreen: !fullscreen.value };
|
||||
});
|
||||
}
|
||||
function interactOutside(e: Event) {
|
||||
if (!closeOnClickModal.value || submitting.value) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
function escapeKeyDown(e: KeyboardEvent) {
|
||||
if (!closeOnPressEscape.value || submitting.value) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenAutoFocus(e: Event) {
|
||||
if (!openAutoFocus.value) {
|
||||
e?.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// pointer-down-outside
|
||||
function pointerDownOutside(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
const isDismissableModal = target?.dataset.dismissableModal;
|
||||
if (
|
||||
!closeOnClickModal.value ||
|
||||
isDismissableModal !== id ||
|
||||
submitting.value
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocusOutside(e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
const getForceMount = computed(() => {
|
||||
return !unref(destroyOnClose) && unref(firstOpened);
|
||||
});
|
||||
|
||||
const handleOpened = () => {
|
||||
requestAnimationFrame(() => {
|
||||
props.modalApi?.onOpened();
|
||||
});
|
||||
};
|
||||
|
||||
function handleClosed() {
|
||||
isClosed.value = true;
|
||||
props.modalApi?.onClosed();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Dialog
|
||||
:modal="false"
|
||||
:open="state?.isOpen"
|
||||
@update:open="() => (!submitting ? modalApi?.close() : undefined)"
|
||||
>
|
||||
<DialogContent
|
||||
ref="contentRef"
|
||||
:append-to="getAppendTo"
|
||||
:class="
|
||||
cn(
|
||||
'left-0 right-0 top-[10vh] mx-auto flex max-h-[80%] w-[520px] flex-col p-0',
|
||||
shouldFullscreen ? 'sm:rounded-none' : 'sm:rounded-[var(--radius)]',
|
||||
modalClass,
|
||||
{
|
||||
'border border-border': bordered,
|
||||
'shadow-3xl': !bordered,
|
||||
'left-0 top-0 size-full max-h-full !translate-x-0 !translate-y-0':
|
||||
shouldFullscreen,
|
||||
'top-1/2': centered && !shouldFullscreen,
|
||||
'duration-300': !dragging,
|
||||
hidden: isClosed,
|
||||
},
|
||||
)
|
||||
"
|
||||
:force-mount="getForceMount"
|
||||
:modal="modal"
|
||||
:open="state?.isOpen"
|
||||
:show-close="closable"
|
||||
:animation-type="animationType"
|
||||
:z-index="zIndex"
|
||||
:overlay-blur="overlayBlur"
|
||||
close-class="top-3"
|
||||
@close-auto-focus="handleFocusOutside"
|
||||
@closed="handleClosed"
|
||||
:close-disabled="submitting"
|
||||
@escape-key-down="escapeKeyDown"
|
||||
@focus-outside="handleFocusOutside"
|
||||
@interact-outside="interactOutside"
|
||||
@open-auto-focus="handleOpenAutoFocus"
|
||||
@opened="handleOpened"
|
||||
@pointer-down-outside="pointerDownOutside"
|
||||
>
|
||||
<DialogHeader
|
||||
ref="headerRef"
|
||||
:class="
|
||||
cn(
|
||||
'px-5 py-4',
|
||||
{
|
||||
'border-b': bordered,
|
||||
hidden: !header,
|
||||
'cursor-move select-none': shouldDraggable,
|
||||
},
|
||||
headerClass,
|
||||
)
|
||||
"
|
||||
>
|
||||
<DialogTitle v-if="title" class="text-left">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
|
||||
<slot v-if="titleTooltip" name="titleTooltip">
|
||||
<VbenHelpTooltip trigger-class="pb-1">
|
||||
{{ titleTooltip }}
|
||||
</VbenHelpTooltip>
|
||||
</slot>
|
||||
</slot>
|
||||
</DialogTitle>
|
||||
<DialogDescription v-if="description">
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</DialogDescription>
|
||||
<VisuallyHidden v-if="!title || !description">
|
||||
<DialogTitle v-if="!title" />
|
||||
<DialogDescription v-if="!description" />
|
||||
</VisuallyHidden>
|
||||
</DialogHeader>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
:class="
|
||||
cn('relative min-h-40 flex-1 overflow-y-auto p-3', contentClass, {
|
||||
'pointer-events-none': showLoading || submitting,
|
||||
})
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<VbenLoading v-if="showLoading || submitting" spinning />
|
||||
<VbenIconButton
|
||||
v-if="fullscreenButton"
|
||||
class="flex-center absolute right-10 top-3 hidden size-6 rounded-full px-1 text-lg text-foreground/80 opacity-70 transition-opacity hover:bg-accent hover:text-accent-foreground hover:opacity-100 focus:outline-none disabled:pointer-events-none sm:block"
|
||||
@click="handleFullscreen"
|
||||
>
|
||||
<Shrink v-if="fullscreen" class="size-3.5" />
|
||||
<Expand v-else class="size-3.5" />
|
||||
</VbenIconButton>
|
||||
|
||||
<DialogFooter
|
||||
v-if="showFooter"
|
||||
ref="footerRef"
|
||||
:class="
|
||||
cn(
|
||||
'flex-row items-center justify-end p-2',
|
||||
{
|
||||
'border-t': bordered,
|
||||
},
|
||||
footerClass,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="prepend-footer"></slot>
|
||||
<slot name="footer">
|
||||
<component
|
||||
:is="components.DefaultButton || VbenButton"
|
||||
v-if="showCancelButton"
|
||||
variant="ghost"
|
||||
:disabled="submitting"
|
||||
@click="() => modalApi?.onCancel()"
|
||||
>
|
||||
<slot name="cancelText">
|
||||
{{ cancelText || $t('cancel') }}
|
||||
</slot>
|
||||
</component>
|
||||
<slot name="center-footer"></slot>
|
||||
<component
|
||||
:is="components.PrimaryButton || VbenButton"
|
||||
v-if="showConfirmButton"
|
||||
:disabled="confirmDisabled"
|
||||
:loading="confirmLoading || submitting"
|
||||
@click="() => modalApi?.onConfirm()"
|
||||
>
|
||||
<slot name="confirmText">
|
||||
{{ confirmText || $t('confirm') }}
|
||||
</slot>
|
||||
</component>
|
||||
</slot>
|
||||
<slot name="append-footer"></slot>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
138
packages/@core/ui-kit/popup-ui/src/modal/use-modal-draggable.ts
Normal file
138
packages/@core/ui-kit/popup-ui/src/modal/use-modal-draggable.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @copy https://github.com/element-plus/element-plus/blob/dev/packages/hooks/use-draggable/index.ts
|
||||
* 调整部分细节
|
||||
*/
|
||||
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
|
||||
import { onBeforeUnmount, onMounted, reactive, ref, watchEffect } from 'vue';
|
||||
|
||||
import { unrefElement } from '@vueuse/core';
|
||||
|
||||
export function useModalDraggable(
|
||||
targetRef: Ref<HTMLElement | undefined>,
|
||||
dragRef: Ref<HTMLElement | undefined>,
|
||||
draggable: ComputedRef<boolean>,
|
||||
containerSelector?: ComputedRef<string | undefined>,
|
||||
centered?: ComputedRef<boolean>,
|
||||
) {
|
||||
const transform = reactive({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
|
||||
const dragging = ref(false);
|
||||
|
||||
const onMousedown = (e: MouseEvent) => {
|
||||
const downX = e.clientX;
|
||||
const downY = e.clientY;
|
||||
|
||||
if (!targetRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRect = targetRef.value.getBoundingClientRect();
|
||||
const { offsetX, offsetY } = transform;
|
||||
const targetLeft = targetRect.left;
|
||||
const targetTop = targetRect.top;
|
||||
const targetWidth = targetRect.width;
|
||||
const targetHeight = targetRect.height;
|
||||
|
||||
let containerRect: DOMRect | null = null;
|
||||
|
||||
if (containerSelector?.value) {
|
||||
const container = document.querySelector(containerSelector.value);
|
||||
if (container) {
|
||||
containerRect = container.getBoundingClientRect();
|
||||
}
|
||||
}
|
||||
|
||||
let maxLeft, maxTop, minLeft, minTop;
|
||||
if (containerRect) {
|
||||
minLeft = containerRect.left - targetLeft + offsetX;
|
||||
maxLeft = containerRect.right - targetLeft - targetWidth + offsetX;
|
||||
minTop = containerRect.top - targetTop + offsetY;
|
||||
maxTop = containerRect.bottom - targetTop - targetHeight + offsetY;
|
||||
} else {
|
||||
const docElement = document.documentElement;
|
||||
const clientWidth = docElement.clientWidth;
|
||||
const clientHeight = docElement.clientHeight;
|
||||
minLeft = -targetLeft + offsetX;
|
||||
minTop = -targetTop + offsetY;
|
||||
maxLeft = clientWidth - targetLeft - targetWidth + offsetX;
|
||||
maxTop = clientHeight - targetTop - targetHeight + offsetY;
|
||||
}
|
||||
|
||||
const onMousemove = (e: MouseEvent) => {
|
||||
let moveX = offsetX + e.clientX - downX;
|
||||
let moveY = offsetY + e.clientY - downY;
|
||||
|
||||
moveX = Math.min(Math.max(moveX, minLeft), maxLeft);
|
||||
moveY = Math.min(Math.max(moveY, minTop), maxTop);
|
||||
|
||||
transform.offsetX = moveX;
|
||||
transform.offsetY = moveY;
|
||||
|
||||
if (targetRef.value) {
|
||||
const isCentered = centered?.value;
|
||||
targetRef.value.style.transform = isCentered
|
||||
? `translate(${moveX}px, calc(-50% + ${moveY}px))`
|
||||
: `translate(${moveX}px, ${moveY}px)`;
|
||||
dragging.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseup = () => {
|
||||
dragging.value = false;
|
||||
document.removeEventListener('mousemove', onMousemove);
|
||||
document.removeEventListener('mouseup', onMouseup);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMousemove);
|
||||
document.addEventListener('mouseup', onMouseup);
|
||||
};
|
||||
|
||||
const onDraggable = () => {
|
||||
const dragDom = unrefElement(dragRef);
|
||||
if (dragDom && targetRef.value) {
|
||||
dragDom.addEventListener('mousedown', onMousedown);
|
||||
}
|
||||
};
|
||||
|
||||
const offDraggable = () => {
|
||||
const dragDom = unrefElement(dragRef);
|
||||
if (dragDom && targetRef.value) {
|
||||
dragDom.removeEventListener('mousedown', onMousedown);
|
||||
}
|
||||
};
|
||||
|
||||
const resetPosition = () => {
|
||||
transform.offsetX = 0;
|
||||
transform.offsetY = 0;
|
||||
|
||||
const target = unrefElement(targetRef);
|
||||
if (target) {
|
||||
target.style.transform = '';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
watchEffect(() => {
|
||||
if (draggable.value) {
|
||||
onDraggable();
|
||||
} else {
|
||||
offDraggable();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
offDraggable();
|
||||
});
|
||||
|
||||
return {
|
||||
dragging,
|
||||
resetPosition,
|
||||
transform,
|
||||
};
|
||||
}
|
||||
54
packages/@core/ui-kit/shadcn-ui/package.json
Normal file
54
packages/@core/ui-kit/shadcn-ui/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@vben-core/shadcn-ui",
|
||||
"version": "5.5.9",
|
||||
"#main": "./dist/index.mjs",
|
||||
"#module": "./dist/index.mjs",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/uikit/shadcn-ui"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"#build": "pnpm unbuild",
|
||||
"#prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./src/index.ts",
|
||||
"//default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/composables": "workspace:*",
|
||||
"@vben-core/icons": "workspace:*",
|
||||
"@vben-core/shared": "workspace:*",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
"lucide-vue-next": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"vee-validate": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
AvatarFallbackProps,
|
||||
AvatarImageProps,
|
||||
AvatarRootProps,
|
||||
} from 'reka-ui';
|
||||
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { ClassType } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../../ui';
|
||||
|
||||
interface Props extends AvatarFallbackProps, AvatarImageProps, AvatarRootProps {
|
||||
alt?: string;
|
||||
class?: ClassType;
|
||||
dot?: boolean;
|
||||
dotClass?: ClassType;
|
||||
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
|
||||
size?: number;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
alt: 'avatar',
|
||||
as: 'button',
|
||||
dot: false,
|
||||
dotClass: 'bg-green-500',
|
||||
fit: 'cover',
|
||||
});
|
||||
|
||||
const imageStyle = computed<CSSProperties>(() => {
|
||||
const { fit } = props;
|
||||
if (fit) {
|
||||
return { objectFit: fit };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const text = computed(() => {
|
||||
return props.alt.slice(-2).toUpperCase();
|
||||
});
|
||||
|
||||
const rootStyle = computed(() => {
|
||||
return props.size !== undefined && props.size > 0
|
||||
? {
|
||||
height: `${props.size}px`,
|
||||
width: `${props.size}px`,
|
||||
}
|
||||
: {};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="props.class"
|
||||
:style="rootStyle"
|
||||
class="relative flex flex-shrink-0 items-center"
|
||||
>
|
||||
<Avatar :class="props.class" class="size-full">
|
||||
<AvatarImage :alt="alt" :src="src" :style="imageStyle" />
|
||||
<AvatarFallback>{{ text }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
v-if="dot"
|
||||
:class="dotClass"
|
||||
class="absolute bottom-0 right-0 size-3 rounded-full border-2 border-background"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts" setup>
|
||||
import type { BacktopProps } from './backtop';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ArrowUpToLine } from '@vben-core/icons';
|
||||
|
||||
import { VbenButton } from '../button';
|
||||
import { useBackTop } from './use-backtop';
|
||||
|
||||
interface Props extends BacktopProps {}
|
||||
|
||||
defineOptions({ name: 'BackTop' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
bottom: 20,
|
||||
isGroup: false,
|
||||
right: 24,
|
||||
target: '',
|
||||
visibilityHeight: 200,
|
||||
});
|
||||
|
||||
const backTopStyle = computed(() => ({
|
||||
bottom: `${props.bottom}px`,
|
||||
right: `${props.right}px`,
|
||||
}));
|
||||
|
||||
const { handleClick, visible } = useBackTop(props);
|
||||
</script>
|
||||
<template>
|
||||
<transition name="fade-down">
|
||||
<VbenButton
|
||||
v-if="visible"
|
||||
:style="backTopStyle"
|
||||
class="data z-popup fixed bottom-10 size-10 rounded-full bg-background shadow-float duration-500 hover:bg-heavy dark:bg-accent dark:hover:bg-heavy"
|
||||
size="icon"
|
||||
variant="icon"
|
||||
@click="handleClick"
|
||||
>
|
||||
<ArrowUpToLine class="size-4" />
|
||||
</VbenButton>
|
||||
</transition>
|
||||
</template>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts" setup>
|
||||
import type { BreadcrumbProps } from './types';
|
||||
|
||||
import { VbenIcon } from '../icon';
|
||||
|
||||
interface Props extends BreadcrumbProps {}
|
||||
|
||||
defineOptions({ name: 'Breadcrumb' });
|
||||
const { breadcrumbs, showIcon } = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{ select: [string] }>();
|
||||
|
||||
function handleClick(index: number, path?: string) {
|
||||
if (!path || index === breadcrumbs.length - 1) {
|
||||
return;
|
||||
}
|
||||
emit('select', path);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ul class="flex">
|
||||
<TransitionGroup name="breadcrumb-transition">
|
||||
<template
|
||||
v-for="(item, index) in breadcrumbs"
|
||||
:key="`${item.path}-${item.title}-${index}`"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="javascript:void 0"
|
||||
@click.stop="handleClick(index, item.path)"
|
||||
>
|
||||
<span class="flex-center z-10 h-full">
|
||||
<VbenIcon
|
||||
v-if="showIcon"
|
||||
:icon="item.icon"
|
||||
class="mr-1 size-4 flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
:class="{
|
||||
'font-normal text-foreground':
|
||||
index === breadcrumbs.length - 1,
|
||||
}"
|
||||
>{{ item.title }}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</TransitionGroup>
|
||||
</ul>
|
||||
</template>
|
||||
<style scoped>
|
||||
li {
|
||||
@apply h-7;
|
||||
}
|
||||
|
||||
li a {
|
||||
@apply relative mr-9 flex h-7 items-center bg-accent py-0 pl-[5px] pr-2 text-[13px] text-muted-foreground;
|
||||
}
|
||||
|
||||
li a > span {
|
||||
@apply -ml-3;
|
||||
}
|
||||
|
||||
li:first-child a > span {
|
||||
@apply -ml-1;
|
||||
}
|
||||
|
||||
li:first-child a {
|
||||
@apply rounded-[4px_0_0_4px] pl-[15px];
|
||||
}
|
||||
|
||||
li:first-child a::before {
|
||||
@apply border-none;
|
||||
}
|
||||
|
||||
li:last-child a {
|
||||
@apply rounded-[0_4px_4px_0] pr-[15px];
|
||||
}
|
||||
|
||||
li:last-child a::after {
|
||||
@apply border-none;
|
||||
}
|
||||
|
||||
li a::before,
|
||||
li a::after {
|
||||
@apply absolute top-0 h-0 w-0 border-[.875rem] border-solid border-accent content-[''];
|
||||
}
|
||||
|
||||
li a::before {
|
||||
@apply -left-7 z-10 border-l-transparent;
|
||||
}
|
||||
|
||||
li a::after {
|
||||
@apply left-full border-transparent border-l-accent;
|
||||
}
|
||||
|
||||
li:not(:last-child) a:hover {
|
||||
@apply bg-accent-hover;
|
||||
}
|
||||
|
||||
li:not(:last-child) a:hover::before {
|
||||
@apply border-accent-hover border-l-transparent;
|
||||
}
|
||||
|
||||
li:not(:last-child) a:hover::after {
|
||||
@apply border-l-accent-hover;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import type { BreadcrumbProps } from './types';
|
||||
|
||||
import { useForwardPropsEmits } from 'reka-ui';
|
||||
|
||||
import BreadcrumbBackground from './breadcrumb-background.vue';
|
||||
import Breadcrumb from './breadcrumb.vue';
|
||||
|
||||
interface Props extends BreadcrumbProps {
|
||||
class?: any;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const emit = defineEmits<{ select: [string] }>();
|
||||
|
||||
const forward = useForwardPropsEmits(props, emit);
|
||||
</script>
|
||||
<template>
|
||||
<Breadcrumb
|
||||
v-if="styleType === 'normal'"
|
||||
v-bind="forward"
|
||||
class="vben-breadcrumb"
|
||||
/>
|
||||
<BreadcrumbBackground
|
||||
v-if="styleType === 'background'"
|
||||
v-bind="forward"
|
||||
class="vben-breadcrumb"
|
||||
/>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
/** 修复全局引入Antd时,ol和ul的默认样式会被修改的问题 */
|
||||
.vben-breadcrumb {
|
||||
:deep(ol),
|
||||
:deep(ul) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AsTag } from 'reka-ui';
|
||||
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { ButtonVariants, ButtonVariantSize } from '../../ui';
|
||||
|
||||
export interface VbenButtonProps {
|
||||
/**
|
||||
* The element or component this component should render as. Can be overwrite by `asChild`
|
||||
* @defaultValue "div"
|
||||
*/
|
||||
as?: AsTag | Component;
|
||||
/**
|
||||
* Change the default rendered element for the one passed as a child, merging their props and behavior.
|
||||
*
|
||||
* Read our [Composition](https://www.reka-ui.com/docs/guides/composition) guide for more details.
|
||||
*/
|
||||
asChild?: boolean;
|
||||
class?: any;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
size?: ButtonVariantSize;
|
||||
variant?: ButtonVariants;
|
||||
}
|
||||
|
||||
export type CustomRenderType = (() => Component | string) | string;
|
||||
|
||||
export type ValueType = boolean | number | string;
|
||||
|
||||
export interface VbenButtonGroupProps
|
||||
extends Pick<VbenButtonProps, 'disabled'> {
|
||||
/** 单选模式下允许清除选中 */
|
||||
allowClear?: boolean;
|
||||
/** 值改变前的回调 */
|
||||
beforeChange?: (
|
||||
value: ValueType,
|
||||
isChecked: boolean,
|
||||
) => boolean | PromiseLike<boolean | undefined> | undefined;
|
||||
/** 按钮样式 */
|
||||
btnClass?: any;
|
||||
/** 按钮间隔距离 */
|
||||
gap?: number;
|
||||
/** 多选模式下限制最多选择的数量。0表示不限制 */
|
||||
maxCount?: number;
|
||||
/** 是否允许多选 */
|
||||
multiple?: boolean;
|
||||
/** 选项 */
|
||||
options?: { [key: string]: any; label: CustomRenderType; value: ValueType }[];
|
||||
/** 显示图标 */
|
||||
showIcon?: boolean;
|
||||
/** 尺寸 */
|
||||
size?: 'large' | 'middle' | 'small';
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenButtonProps } from './button';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { LoaderCircle } from '@vben-core/icons';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { Primitive } from 'reka-ui';
|
||||
|
||||
import { buttonVariants } from '../../ui';
|
||||
|
||||
interface Props extends VbenButtonProps {}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button',
|
||||
class: '',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
return props.disabled || props.loading;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
:disabled="isDisabled"
|
||||
>
|
||||
<LoaderCircle
|
||||
v-if="loading"
|
||||
class="text-md mr-2 size-4 flex-shrink-0 animate-spin"
|
||||
/>
|
||||
<slot></slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui';
|
||||
|
||||
import { useId } from 'vue';
|
||||
|
||||
import { useForwardPropsEmits } from 'reka-ui';
|
||||
|
||||
import { Checkbox } from '../../ui/checkbox';
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { indeterminate?: boolean }>();
|
||||
|
||||
const emits = defineEmits<CheckboxRootEmits>();
|
||||
|
||||
const checked = defineModel<boolean>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
|
||||
const id = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<Checkbox v-bind="forwarded" :id="id" v-model="checked" />
|
||||
<label :for="id" class="ml-2 cursor-pointer text-sm"> <slot></slot> </label>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ContextMenuContentProps,
|
||||
ContextMenuRootEmits,
|
||||
ContextMenuRootProps,
|
||||
} from 'reka-ui';
|
||||
|
||||
import type { ClassType } from '@vben-core/typings';
|
||||
|
||||
import type { IContextMenuItem } from './interface';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useForwardPropsEmits } from 'reka-ui';
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuTrigger,
|
||||
} from '../../ui/context-menu';
|
||||
|
||||
const props = defineProps<
|
||||
ContextMenuRootProps & {
|
||||
class?: ClassType;
|
||||
contentClass?: ClassType;
|
||||
contentProps?: ContextMenuContentProps;
|
||||
handlerData?: Record<string, any>;
|
||||
itemClass?: ClassType;
|
||||
menus: (data: any) => IContextMenuItem[];
|
||||
}
|
||||
>();
|
||||
|
||||
const emits = defineEmits<ContextMenuRootEmits>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const {
|
||||
class: _cls,
|
||||
contentClass: _,
|
||||
contentProps: _cProps,
|
||||
itemClass: _iCls,
|
||||
...delegated
|
||||
} = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
|
||||
const menusView = computed(() => {
|
||||
return props.menus?.(props.handlerData);
|
||||
});
|
||||
|
||||
function handleClick(menu: IContextMenuItem) {
|
||||
if (menu.disabled) {
|
||||
return;
|
||||
}
|
||||
menu?.handler?.(props.handlerData);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenu v-bind="forwarded">
|
||||
<ContextMenuTrigger as-child>
|
||||
<slot></slot>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent
|
||||
:class="contentClass"
|
||||
v-bind="contentProps"
|
||||
class="side-content z-popup"
|
||||
>
|
||||
<template v-for="menu in menusView" :key="menu.key">
|
||||
<ContextMenuItem
|
||||
:class="itemClass"
|
||||
:disabled="menu.disabled"
|
||||
:inset="menu.inset || !menu.icon"
|
||||
class="cursor-pointer"
|
||||
@click="handleClick(menu)"
|
||||
>
|
||||
<component
|
||||
:is="menu.icon"
|
||||
v-if="menu.icon"
|
||||
class="mr-2 size-4 text-lg"
|
||||
/>
|
||||
|
||||
{{ menu.text }}
|
||||
<ContextMenuShortcut v-if="menu.shortcut">
|
||||
{{ menu.shortcut }}
|
||||
</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator v-if="menu.separator" />
|
||||
</template>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
DropdownMenuProps,
|
||||
VbenDropdownMenuItem as IDropdownMenuItem,
|
||||
} from './interface';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../ui';
|
||||
|
||||
interface Props extends DropdownMenuProps {}
|
||||
|
||||
defineOptions({ name: 'DropdownMenu' });
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
function handleItemClick(menu: IDropdownMenuItem) {
|
||||
if (menu.disabled) {
|
||||
return;
|
||||
}
|
||||
menu?.handler?.(props);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="flex h-full items-center gap-1">
|
||||
<slot></slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuGroup>
|
||||
<template v-for="menu in menus" :key="menu.value">
|
||||
<DropdownMenuItem
|
||||
:disabled="menu.disabled"
|
||||
class="mb-1 cursor-pointer text-foreground/80 data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground"
|
||||
@click="handleItemClick(menu)"
|
||||
>
|
||||
<component :is="menu.icon" v-if="menu.icon" class="mr-2 size-4" />
|
||||
{{ menu.label }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator v-if="menu.separator" class="bg-border" />
|
||||
</template>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DropdownMenuProps } from './interface';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../ui';
|
||||
|
||||
interface Props extends DropdownMenuProps {}
|
||||
|
||||
defineOptions({ name: 'DropdownRadioMenu' });
|
||||
withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const modelValue = defineModel<string>();
|
||||
|
||||
function handleItemClick(value: string) {
|
||||
modelValue.value = value;
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child class="flex items-center gap-1">
|
||||
<slot></slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuGroup>
|
||||
<template v-for="menu in menus" :key="menu.key">
|
||||
<DropdownMenuItem
|
||||
:class="
|
||||
menu.value === modelValue
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''
|
||||
"
|
||||
class="mb-1 cursor-pointer text-foreground/80 data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground"
|
||||
@click="handleItemClick(menu.value)"
|
||||
>
|
||||
<component :is="menu.icon" v-if="menu.icon" class="mr-2 size-4" />
|
||||
<span
|
||||
v-if="!menu.icon"
|
||||
:class="menu.value === modelValue ? 'bg-foreground' : ''"
|
||||
class="mr-2 size-1.5 rounded-full"
|
||||
></span>
|
||||
{{ menu.label }}
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import { Maximize, Minimize } from '@vben-core/icons';
|
||||
|
||||
import { useFullscreen } from '@vueuse/core';
|
||||
|
||||
import { VbenIconButton } from '../button';
|
||||
|
||||
defineOptions({ name: 'FullScreen' });
|
||||
|
||||
const { isFullscreen, toggle } = useFullscreen();
|
||||
|
||||
// 重新检查全屏状态
|
||||
isFullscreen.value = !!(
|
||||
document.fullscreenElement ||
|
||||
// @ts-ignore
|
||||
document.webkitFullscreenElement ||
|
||||
// @ts-ignore
|
||||
document.mozFullScreenElement ||
|
||||
// @ts-ignore
|
||||
document.msFullscreenElement
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<VbenIconButton
|
||||
class="hover:animate-[shrink_0.3s_ease-in-out]"
|
||||
@click="toggle"
|
||||
>
|
||||
<Minimize v-if="isFullscreen" class="size-4 text-foreground" />
|
||||
<Maximize v-else class="size-4 text-foreground" />
|
||||
</VbenIconButton>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
HoverCardContentProps,
|
||||
HoverCardRootEmits,
|
||||
HoverCardRootProps,
|
||||
} from 'reka-ui';
|
||||
|
||||
import type { ClassType } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useForwardPropsEmits } from 'reka-ui';
|
||||
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '../../ui';
|
||||
|
||||
interface Props extends HoverCardRootProps {
|
||||
class?: ClassType;
|
||||
contentClass?: ClassType;
|
||||
contentProps?: HoverCardContentProps;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emits = defineEmits<HoverCardRootEmits>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const {
|
||||
class: _cls,
|
||||
contentClass: _,
|
||||
contentProps: _cProps,
|
||||
...delegated
|
||||
} = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCard v-bind="forwarded">
|
||||
<HoverCardTrigger as-child class="h-full">
|
||||
<div class="h-full cursor-pointer">
|
||||
<slot name="trigger"></slot>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
:class="contentClass"
|
||||
v-bind="contentProps"
|
||||
class="side-content z-popup"
|
||||
>
|
||||
<slot></slot>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as VbenHoverCard } from './hover-card.vue';
|
||||
export type { HoverCardContentProps } from 'reka-ui';
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useSlots } from 'vue';
|
||||
|
||||
import { Eye, EyeOff } from '@vben-core/icons';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { Input } from '../../ui';
|
||||
import PasswordStrength from './password-strength.vue';
|
||||
|
||||
interface Props {
|
||||
class?: any;
|
||||
/**
|
||||
* 是否显示密码强度
|
||||
*/
|
||||
passwordStrength?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const modelValue = defineModel<string>();
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const show = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<Input
|
||||
v-bind="$attrs"
|
||||
v-model="modelValue"
|
||||
:class="cn(props.class)"
|
||||
:type="show ? 'text' : 'password'"
|
||||
/>
|
||||
<template v-if="passwordStrength">
|
||||
<PasswordStrength :password="modelValue" />
|
||||
<p v-if="slots.strengthText" class="mt-1.5 text-xs text-muted-foreground">
|
||||
<slot name="strengthText"> </slot>
|
||||
</p>
|
||||
</template>
|
||||
<div
|
||||
:class="{
|
||||
'top-3': !!passwordStrength,
|
||||
'top-1/2 -translate-y-1/2 items-center': !passwordStrength,
|
||||
}"
|
||||
class="absolute inset-y-0 right-0 flex cursor-pointer pr-3 text-lg leading-5 text-foreground/60 hover:text-foreground"
|
||||
@click="show = !show"
|
||||
>
|
||||
<Eye v-if="show" class="size-4" />
|
||||
<EyeOff v-else class="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{ password?: string }>(), {
|
||||
password: '',
|
||||
});
|
||||
|
||||
const strengthList: string[] = [
|
||||
'',
|
||||
'#e74242',
|
||||
'#ED6F6F',
|
||||
'#EFBD47',
|
||||
'#55D18780',
|
||||
'#55D187',
|
||||
];
|
||||
|
||||
const currentStrength = computed(() => {
|
||||
return checkPasswordStrength(props.password);
|
||||
});
|
||||
|
||||
const currentColor = computed(() => {
|
||||
return strengthList[currentStrength.value];
|
||||
});
|
||||
|
||||
/**
|
||||
* Check the strength of a password
|
||||
*/
|
||||
function checkPasswordStrength(password: string) {
|
||||
let strength = 0;
|
||||
|
||||
// Check length
|
||||
if (password.length >= 8) strength++;
|
||||
|
||||
// Check for lowercase letters
|
||||
if (/[a-z]/.test(password)) strength++;
|
||||
|
||||
// Check for uppercase letters
|
||||
if (/[A-Z]/.test(password)) strength++;
|
||||
|
||||
// Check for numbers
|
||||
if (/\d/.test(password)) strength++;
|
||||
|
||||
// Check for special characters
|
||||
if (/[^\da-z]/i.test(password)) strength++;
|
||||
|
||||
return strength;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative mt-2 flex items-center justify-between">
|
||||
<template v-for="index in 5" :key="index">
|
||||
<div
|
||||
class="relative mr-1 h-1.5 w-1/5 rounded-sm bg-heavy last:mr-0 dark:bg-input-background"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
backgroundColor: currentColor,
|
||||
width: currentStrength >= index ? '100%' : '',
|
||||
}"
|
||||
class="absolute left-0 h-full w-0 rounded-sm transition-all duration-500"
|
||||
></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
92
packages/@core/ui-kit/shadcn-ui/src/components/logo/logo.vue
Normal file
92
packages/@core/ui-kit/shadcn-ui/src/components/logo/logo.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { VbenAvatar } from '../avatar';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* @zh_CN 是否收起文本
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
/**
|
||||
* @zh_CN Logo 图片适应方式
|
||||
*/
|
||||
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
|
||||
/**
|
||||
* @zh_CN Logo 跳转地址
|
||||
*/
|
||||
href?: string;
|
||||
/**
|
||||
* @zh_CN Logo 图片大小
|
||||
*/
|
||||
logoSize?: number;
|
||||
/**
|
||||
* @zh_CN Logo 图标
|
||||
*/
|
||||
src?: string;
|
||||
/**
|
||||
* @zh_CN 暗色主题 Logo 图标 (可选,若不设置则使用 src)
|
||||
*/
|
||||
srcDark?: string;
|
||||
/**
|
||||
* @zh_CN Logo 文本
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* @zh_CN Logo 主题
|
||||
*/
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'VbenLogo',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
collapsed: false,
|
||||
href: 'javascript:void 0',
|
||||
logoSize: 32,
|
||||
src: '',
|
||||
srcDark: '',
|
||||
theme: 'light',
|
||||
fit: 'cover',
|
||||
});
|
||||
|
||||
/**
|
||||
* @zh_CN 根据主题选择合适的 logo 图标
|
||||
*/
|
||||
const logoSrc = computed(() => {
|
||||
// 如果是暗色主题且提供了 srcDark,则使用暗色主题的 logo
|
||||
if (props.theme === 'dark' && props.srcDark) {
|
||||
return props.srcDark;
|
||||
}
|
||||
// 否则使用默认的 src
|
||||
return props.src;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="theme" class="flex h-full items-center text-lg">
|
||||
<a
|
||||
:class="$attrs.class"
|
||||
:href="href"
|
||||
class="flex h-full items-center gap-2 overflow-hidden px-3 text-lg leading-normal transition-all duration-500"
|
||||
>
|
||||
<VbenAvatar
|
||||
v-if="logoSrc"
|
||||
:alt="text"
|
||||
:src="logoSrc"
|
||||
:size="logoSize"
|
||||
:fit="fit"
|
||||
class="relative rounded-none bg-transparent"
|
||||
/>
|
||||
<template v-if="!collapsed">
|
||||
<slot name="text">
|
||||
<span class="truncate text-nowrap font-semibold text-foreground">
|
||||
{{ text }}
|
||||
</span>
|
||||
</slot>
|
||||
</template>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import type { PinInputProps } from './types';
|
||||
|
||||
import { computed, onBeforeUnmount, ref, useId, watch } from 'vue';
|
||||
|
||||
import { PinInput, PinInputGroup, PinInputInput } from '../../ui';
|
||||
import { VbenButton } from '../button';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const {
|
||||
codeLength = 6,
|
||||
createText = async () => {},
|
||||
disabled = false,
|
||||
handleSendCode = async () => {},
|
||||
loading = false,
|
||||
maxTime = 60,
|
||||
} = defineProps<PinInputProps>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: [];
|
||||
sendError: [error: any];
|
||||
}>();
|
||||
|
||||
const timer = ref<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const modelValue = defineModel<string>();
|
||||
|
||||
const inputValue = ref<string[]>([]);
|
||||
const countdown = ref(0);
|
||||
|
||||
const btnText = computed(() => {
|
||||
const countdownValue = countdown.value;
|
||||
return createText?.(countdownValue);
|
||||
});
|
||||
|
||||
const btnLoading = computed(() => {
|
||||
return loading || countdown.value > 0;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
() => {
|
||||
inputValue.value = modelValue.value?.split('') ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
watch(inputValue, (val) => {
|
||||
modelValue.value = val.join('');
|
||||
});
|
||||
|
||||
function handleComplete(e: string[]) {
|
||||
modelValue.value = e.join('');
|
||||
emit('complete');
|
||||
}
|
||||
|
||||
async function handleSend(e: Event) {
|
||||
try {
|
||||
e?.preventDefault();
|
||||
countdown.value = maxTime;
|
||||
startCountdown();
|
||||
await handleSendCode();
|
||||
} catch (error) {
|
||||
console.error('Failed to send code:', error);
|
||||
// Consider emitting an error event or showing a notification
|
||||
emit('sendError', error);
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
if (countdown.value > 0) {
|
||||
timer.value = setTimeout(() => {
|
||||
countdown.value--;
|
||||
startCountdown();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
countdown.value = 0;
|
||||
clearTimeout(timer.value);
|
||||
});
|
||||
|
||||
const id = useId();
|
||||
|
||||
const pinType = 'text' as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PinInput
|
||||
:id="id"
|
||||
v-model="inputValue"
|
||||
:disabled="disabled"
|
||||
class="flex w-full justify-between"
|
||||
otp
|
||||
placeholder="○"
|
||||
:type="pinType"
|
||||
@complete="handleComplete"
|
||||
>
|
||||
<div class="relative flex w-full">
|
||||
<PinInputGroup class="mr-2">
|
||||
<PinInputInput
|
||||
v-for="(item, index) in codeLength"
|
||||
:key="item"
|
||||
:index="index"
|
||||
/>
|
||||
</PinInputGroup>
|
||||
<VbenButton
|
||||
:disabled="disabled"
|
||||
:loading="btnLoading"
|
||||
class="flex-grow"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
@click="handleSend"
|
||||
>
|
||||
{{ btnText }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
</PinInput>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
PopoverContentProps,
|
||||
PopoverRootEmits,
|
||||
PopoverRootProps,
|
||||
} from 'reka-ui';
|
||||
|
||||
import type { ClassType } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useForwardPropsEmits } from 'reka-ui';
|
||||
|
||||
import {
|
||||
PopoverContent,
|
||||
Popover as PopoverRoot,
|
||||
PopoverTrigger,
|
||||
} from '../../ui';
|
||||
|
||||
interface Props extends PopoverRootProps {
|
||||
class?: ClassType;
|
||||
contentClass?: ClassType;
|
||||
contentProps?: PopoverContentProps;
|
||||
triggerClass?: ClassType;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const emits = defineEmits<PopoverRootEmits>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const {
|
||||
class: _cls,
|
||||
contentClass: _,
|
||||
contentProps: _cProps,
|
||||
triggerClass: _tClass,
|
||||
...delegated
|
||||
} = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot v-bind="forwarded">
|
||||
<PopoverTrigger :class="triggerClass">
|
||||
<slot name="trigger"></slot>
|
||||
|
||||
<PopoverContent
|
||||
:class="contentClass"
|
||||
class="side-content z-popup"
|
||||
v-bind="contentProps"
|
||||
>
|
||||
<slot></slot>
|
||||
</PopoverContent>
|
||||
</PopoverTrigger>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClassType } from '@vben-core/typings';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { ScrollArea, ScrollBar } from '../../ui';
|
||||
|
||||
interface Props {
|
||||
class?: ClassType;
|
||||
horizontal?: boolean;
|
||||
scrollBarClass?: ClassType;
|
||||
shadow?: boolean;
|
||||
shadowBorder?: boolean;
|
||||
shadowBottom?: boolean;
|
||||
shadowLeft?: boolean;
|
||||
shadowRight?: boolean;
|
||||
shadowTop?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
class: '',
|
||||
horizontal: false,
|
||||
shadow: false,
|
||||
shadowBorder: false,
|
||||
shadowBottom: true,
|
||||
shadowLeft: false,
|
||||
shadowRight: false,
|
||||
shadowTop: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
scrollAt: [{ bottom: boolean; left: boolean; right: boolean; top: boolean }];
|
||||
}>();
|
||||
|
||||
const isAtTop = ref(true);
|
||||
const isAtRight = ref(false);
|
||||
const isAtBottom = ref(false);
|
||||
const isAtLeft = ref(true);
|
||||
|
||||
/**
|
||||
* We have to check if the scroll amount is close enough to some threshold in order to
|
||||
* more accurately calculate arrivedState. This is because scrollTop/scrollLeft are non-rounded
|
||||
* numbers, while scrollHeight/scrollWidth and clientHeight/clientWidth are rounded.
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
|
||||
*/
|
||||
const ARRIVED_STATE_THRESHOLD_PIXELS = 1;
|
||||
|
||||
const showShadowTop = computed(() => props.shadow && props.shadowTop);
|
||||
const showShadowBottom = computed(() => props.shadow && props.shadowBottom);
|
||||
const showShadowLeft = computed(() => props.shadow && props.shadowLeft);
|
||||
const showShadowRight = computed(() => props.shadow && props.shadowRight);
|
||||
|
||||
const computedShadowClasses = computed(() => {
|
||||
return {
|
||||
'both-shadow':
|
||||
!isAtLeft.value &&
|
||||
!isAtRight.value &&
|
||||
showShadowLeft.value &&
|
||||
showShadowRight.value,
|
||||
'left-shadow': !isAtLeft.value && showShadowLeft.value,
|
||||
'right-shadow': !isAtRight.value && showShadowRight.value,
|
||||
};
|
||||
});
|
||||
|
||||
function handleScroll(event: Event) {
|
||||
const target = event.target as HTMLElement;
|
||||
const scrollTop = target?.scrollTop ?? 0;
|
||||
const scrollLeft = target?.scrollLeft ?? 0;
|
||||
const clientHeight = target?.clientHeight ?? 0;
|
||||
const clientWidth = target?.clientWidth ?? 0;
|
||||
const scrollHeight = target?.scrollHeight ?? 0;
|
||||
const scrollWidth = target?.scrollWidth ?? 0;
|
||||
isAtTop.value = scrollTop <= 0;
|
||||
isAtLeft.value = scrollLeft <= 0;
|
||||
isAtBottom.value =
|
||||
Math.abs(scrollTop) + clientHeight >=
|
||||
scrollHeight - ARRIVED_STATE_THRESHOLD_PIXELS;
|
||||
isAtRight.value =
|
||||
Math.abs(scrollLeft) + clientWidth >=
|
||||
scrollWidth - ARRIVED_STATE_THRESHOLD_PIXELS;
|
||||
|
||||
emit('scrollAt', {
|
||||
bottom: isAtBottom.value,
|
||||
left: isAtLeft.value,
|
||||
right: isAtRight.value,
|
||||
top: isAtTop.value,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollArea
|
||||
:class="[cn(props.class), computedShadowClasses]"
|
||||
:on-scroll="handleScroll"
|
||||
class="vben-scrollbar relative"
|
||||
>
|
||||
<div
|
||||
v-if="showShadowTop"
|
||||
:class="{
|
||||
'opacity-100': !isAtTop,
|
||||
'border-t border-border': shadowBorder && !isAtTop,
|
||||
}"
|
||||
class="scrollbar-top-shadow pointer-events-none absolute top-0 z-10 h-12 w-full opacity-0 transition-opacity duration-300 ease-in-out will-change-[opacity]"
|
||||
></div>
|
||||
<slot></slot>
|
||||
<div
|
||||
v-if="showShadowBottom"
|
||||
:class="{
|
||||
'opacity-100': !isAtTop && !isAtBottom,
|
||||
'border-b border-border': shadowBorder && !isAtTop && !isAtBottom,
|
||||
}"
|
||||
class="scrollbar-bottom-shadow pointer-events-none absolute bottom-0 z-10 h-12 w-full opacity-0 transition-opacity duration-300 ease-in-out will-change-[opacity]"
|
||||
></div>
|
||||
<ScrollBar
|
||||
v-if="horizontal"
|
||||
:class="scrollBarClass"
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</ScrollArea>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vben-scrollbar {
|
||||
&:not(.both-shadow).left-shadow {
|
||||
mask-image: linear-gradient(90deg, transparent, #000 16px);
|
||||
}
|
||||
|
||||
&:not(.both-shadow).right-shadow {
|
||||
mask-image: linear-gradient(
|
||||
90deg,
|
||||
#000 0%,
|
||||
#000 calc(100% - 16px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&.both-shadow {
|
||||
mask-image: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
#000 16px,
|
||||
#000 calc(100% - 16px),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar-top-shadow {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
hsl(var(--scroll-shadow, var(--background))),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.scrollbar-bottom-shadow {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
hsl(var(--scroll-shadow, var(--background))),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { SegmentedItem } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { TabsTrigger } from 'reka-ui';
|
||||
|
||||
import { Tabs, TabsContent, TabsList } from '../../ui';
|
||||
import TabsIndicator from './tabs-indicator.vue';
|
||||
|
||||
interface Props {
|
||||
defaultValue?: string;
|
||||
tabs?: SegmentedItem[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultValue: '',
|
||||
tabs: () => [],
|
||||
});
|
||||
|
||||
const activeTab = defineModel<string>();
|
||||
|
||||
const getDefaultValue = computed(() => {
|
||||
return props.defaultValue || props.tabs[0]?.value;
|
||||
});
|
||||
|
||||
const tabsStyle = computed(() => {
|
||||
return {
|
||||
'grid-template-columns': `repeat(${props.tabs.length}, minmax(0, 1fr))`,
|
||||
};
|
||||
});
|
||||
|
||||
const tabsIndicatorStyle = computed(() => {
|
||||
return {
|
||||
width: `${(100 / props.tabs.length).toFixed(0)}%`,
|
||||
};
|
||||
});
|
||||
|
||||
function activeClass(tab: string): string[] {
|
||||
return tab === activeTab.value ? ['!font-bold', 'text-primary'] : [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabs v-model="activeTab" :default-value="getDefaultValue">
|
||||
<TabsList
|
||||
:style="tabsStyle"
|
||||
class="relative grid w-full bg-accent !outline !outline-2 !outline-heavy"
|
||||
>
|
||||
<TabsIndicator :style="tabsIndicatorStyle" />
|
||||
<template v-for="tab in tabs" :key="tab.value">
|
||||
<TabsTrigger
|
||||
:value="tab.value"
|
||||
:class="activeClass(tab.value)"
|
||||
class="z-20 inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium hover:text-primary disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</TabsTrigger>
|
||||
</template>
|
||||
</TabsList>
|
||||
<template v-for="tab in tabs" :key="tab.value">
|
||||
<TabsContent :value="tab.value">
|
||||
<slot :name="tab.value"></slot>
|
||||
</TabsContent>
|
||||
</template>
|
||||
</Tabs>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { TabsIndicatorProps } from 'reka-ui';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { TabsIndicator, useForwardProps } from 'reka-ui';
|
||||
|
||||
const props = defineProps<TabsIndicatorProps & { class?: any }>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsIndicator
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'absolute bottom-0 left-0 z-10 h-full w-1/2 translate-x-[--reka-tabs-indicator-position] rounded-full px-0 py-1 pr-0.5 transition-[width,transform] duration-300',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="inline-flex h-full w-full items-center justify-center whitespace-nowrap rounded-md bg-background px-3 py-1 text-sm font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</TabsIndicator>
|
||||
</template>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
/**
|
||||
* @zh_CN 最小加载时间
|
||||
* @en_US Minimum loading time
|
||||
*/
|
||||
minLoadingTime?: number;
|
||||
|
||||
/**
|
||||
* @zh_CN loading状态开启
|
||||
*/
|
||||
spinning?: boolean;
|
||||
/**
|
||||
* @zh_CN 文字
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'VbenLoading',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
minLoadingTime: 50,
|
||||
text: '',
|
||||
});
|
||||
// const startTime = ref(0);
|
||||
const showSpinner = ref(false);
|
||||
const renderSpinner = ref(false);
|
||||
const timer = ref<ReturnType<typeof setTimeout>>();
|
||||
|
||||
watch(
|
||||
() => props.spinning,
|
||||
(show) => {
|
||||
if (!show) {
|
||||
showSpinner.value = false;
|
||||
clearTimeout(timer.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// startTime.value = performance.now();
|
||||
timer.value = setTimeout(() => {
|
||||
// const loadingTime = performance.now() - startTime.value;
|
||||
|
||||
showSpinner.value = true;
|
||||
if (showSpinner.value) {
|
||||
renderSpinner.value = true;
|
||||
}
|
||||
}, props.minLoadingTime);
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
function onTransitionEnd() {
|
||||
if (!showSpinner.value) {
|
||||
renderSpinner.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'absolute left-0 top-0 z-100 flex size-full flex-col items-center justify-center bg-overlay-content transition-all duration-500 dark:bg-overlay',
|
||||
{
|
||||
'invisible opacity-0': !showSpinner,
|
||||
},
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
@transitionend="onTransitionEnd"
|
||||
>
|
||||
<slot name="icon" v-if="renderSpinner">
|
||||
<span class="dot relative inline-block size-9 text-3xl">
|
||||
<i
|
||||
v-for="index in 4"
|
||||
:key="index"
|
||||
class="absolute block size-4 origin-[50%_50%] scale-75 rounded-full bg-primary opacity-30"
|
||||
></i>
|
||||
</span>
|
||||
</slot>
|
||||
|
||||
<div v-if="text" class="mt-4 text-xs text-primary">{{ text }}</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dot {
|
||||
transform: rotate(45deg);
|
||||
animation: rotate-ani 1.2s infinite linear;
|
||||
}
|
||||
|
||||
.dot i {
|
||||
animation: spin-move-ani 1s infinite linear alternate;
|
||||
}
|
||||
|
||||
.dot i:nth-child(1) {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.dot i:nth-child(2) {
|
||||
top: 0;
|
||||
right: 0;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.dot i:nth-child(3) {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
|
||||
.dot i:nth-child(4) {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
|
||||
@keyframes rotate-ani {
|
||||
to {
|
||||
transform: rotate(405deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin-move-ani {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
/**
|
||||
* @zh_CN 最小加载时间
|
||||
* @en_US Minimum loading time
|
||||
*/
|
||||
minLoadingTime?: number;
|
||||
/**
|
||||
* @zh_CN loading状态开启
|
||||
*/
|
||||
spinning?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'VbenSpinner',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
minLoadingTime: 50,
|
||||
});
|
||||
// const startTime = ref(0);
|
||||
const showSpinner = ref(false);
|
||||
const renderSpinner = ref(false);
|
||||
const timer = ref<ReturnType<typeof setTimeout>>();
|
||||
|
||||
watch(
|
||||
() => props.spinning,
|
||||
(show) => {
|
||||
if (!show) {
|
||||
showSpinner.value = false;
|
||||
clearTimeout(timer.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// startTime.value = performance.now();
|
||||
timer.value = setTimeout(() => {
|
||||
// const loadingTime = performance.now() - startTime.value;
|
||||
|
||||
showSpinner.value = true;
|
||||
if (showSpinner.value) {
|
||||
renderSpinner.value = true;
|
||||
}
|
||||
}, props.minLoadingTime);
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
function onTransitionEnd() {
|
||||
if (!showSpinner.value) {
|
||||
renderSpinner.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex-center absolute left-0 top-0 z-100 size-full bg-overlay-content backdrop-blur-sm transition-all duration-500',
|
||||
{
|
||||
'invisible opacity-0': !showSpinner,
|
||||
},
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
@transitionend="onTransitionEnd"
|
||||
>
|
||||
<div
|
||||
:class="{ paused: !renderSpinner }"
|
||||
v-if="renderSpinner"
|
||||
class="loader relative size-12 before:absolute before:left-0 before:top-[60px] before:h-[5px] before:w-12 before:rounded-[50%] before:bg-primary/50 before:content-[''] after:absolute after:left-0 after:top-0 after:h-full after:w-full after:rounded after:bg-primary after:content-['']"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.paused {
|
||||
&::before {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
|
||||
&::after {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
&::before {
|
||||
animation: loader-shadow-ani 0.5s linear infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
animation: loader-jump-ani 0.5s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loader-jump-ani {
|
||||
15% {
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateY(9px) rotate(22.5deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
border-bottom-right-radius: 40px;
|
||||
transform: translateY(18px) scale(1, 0.9) rotate(45deg);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateY(9px) rotate(67.5deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0) rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loader-shadow-ani {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1, 1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.2, 1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { CircleHelp } from 'lucide-vue-next';
|
||||
|
||||
import Tooltip from './tooltip.vue';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
defineProps<{ triggerClass?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip :delay-duration="300" side="right">
|
||||
<template #trigger>
|
||||
<slot name="trigger">
|
||||
<CircleHelp
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex size-5 cursor-pointer text-foreground/80 hover:text-foreground',
|
||||
triggerClass,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
<slot></slot>
|
||||
</Tooltip>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipContentProps } from 'reka-ui';
|
||||
|
||||
import type { StyleValue } from 'vue';
|
||||
|
||||
import type { ClassType } from '@vben-core/typings';
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '../../ui';
|
||||
|
||||
interface Props {
|
||||
contentClass?: ClassType;
|
||||
contentStyle?: StyleValue;
|
||||
delayDuration?: number;
|
||||
side?: TooltipContentProps['side'];
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
delayDuration: 0,
|
||||
side: 'right',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="delayDuration">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child tabindex="-1">
|
||||
<slot name="trigger"></slot>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
:class="contentClass"
|
||||
:side="side"
|
||||
:style="contentStyle"
|
||||
class="side-content rounded-md bg-accent text-popover-foreground"
|
||||
>
|
||||
<slot></slot>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
3
packages/@core/ui-kit/shadcn-ui/src/index.ts
Normal file
3
packages/@core/ui-kit/shadcn-ui/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './components';
|
||||
export * from './ui';
|
||||
export { createContext, Slot, VisuallyHidden } from 'reka-ui';
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionRootEmits, AccordionRootProps } from 'reka-ui';
|
||||
|
||||
import { AccordionRoot, useForwardPropsEmits } from 'reka-ui';
|
||||
|
||||
const props = defineProps<AccordionRootProps>();
|
||||
const emits = defineEmits<AccordionRootEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionRoot v-bind="forwarded">
|
||||
<slot></slot>
|
||||
</AccordionRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionContentProps } from 'reka-ui';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { AccordionContent } from 'reka-ui';
|
||||
|
||||
const props = defineProps<AccordionContentProps & { class?: any }>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionContent
|
||||
v-bind="delegatedProps"
|
||||
class="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
>
|
||||
<div :class="cn('pb-4 pt-0', props.class)">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionItemProps } from 'reka-ui';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { AccordionItem, useForwardProps } from 'reka-ui';
|
||||
|
||||
const props = defineProps<AccordionItemProps & { class?: any }>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionItem v-bind="forwardedProps" :class="cn('border-b', props.class)">
|
||||
<slot></slot>
|
||||
</AccordionItem>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionTriggerProps } from 'reka-ui';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { ChevronDown } from 'lucide-vue-next';
|
||||
import { AccordionHeader, AccordionTrigger } from 'reka-ui';
|
||||
|
||||
const props = defineProps<AccordionTriggerProps & { class?: any }>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionHeader class="flex">
|
||||
<AccordionTrigger
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
<slot name="icon">
|
||||
<ChevronDown
|
||||
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
|
||||
/>
|
||||
</slot>
|
||||
</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogEmits, AlertDialogProps } from 'reka-ui';
|
||||
|
||||
import { AlertDialogRoot, useForwardPropsEmits } from 'reka-ui';
|
||||
|
||||
const props = defineProps<AlertDialogProps>();
|
||||
const emits = defineEmits<AlertDialogEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-bind="forwarded">
|
||||
<slot></slot>
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogActionProps } from 'reka-ui';
|
||||
|
||||
import { AlertDialogAction } from 'reka-ui';
|
||||
|
||||
const props = defineProps<AlertDialogActionProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction v-bind="props">
|
||||
<slot></slot>
|
||||
</AlertDialogAction>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogCancelProps } from 'reka-ui';
|
||||
|
||||
import { AlertDialogCancel } from 'reka-ui';
|
||||
|
||||
const props = defineProps<AlertDialogCancelProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel v-bind="props">
|
||||
<slot></slot>
|
||||
</AlertDialogCancel>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogContentEmits, AlertDialogContentProps } from 'reka-ui';
|
||||
|
||||
import type { ClassType } from '@vben-core/typings';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import {
|
||||
AlertDialogContent,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui';
|
||||
|
||||
import AlertDialogOverlay from './AlertDialogOverlay.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
AlertDialogContentProps & {
|
||||
centered?: boolean;
|
||||
class?: ClassType;
|
||||
modal?: boolean;
|
||||
open?: boolean;
|
||||
overlayBlur?: number;
|
||||
zIndex?: number;
|
||||
}
|
||||
>(),
|
||||
{ modal: true },
|
||||
);
|
||||
const emits = defineEmits<
|
||||
AlertDialogContentEmits & { close: []; closed: []; opened: [] }
|
||||
>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, modal: _modal, open: _open, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
|
||||
const contentRef = ref<InstanceType<typeof AlertDialogContent> | null>(null);
|
||||
function onAnimationEnd(event: AnimationEvent) {
|
||||
// 只有在 contentRef 的动画结束时才触发 opened/closed 事件
|
||||
if (event.target === contentRef.value?.$el) {
|
||||
if (props.open) {
|
||||
emits('opened');
|
||||
} else {
|
||||
emits('closed');
|
||||
}
|
||||
}
|
||||
}
|
||||
defineExpose({
|
||||
getContentRef: () => contentRef.value,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogPortal>
|
||||
<Transition name="fade" appear>
|
||||
<AlertDialogOverlay
|
||||
v-if="open && modal"
|
||||
:style="{
|
||||
...(zIndex ? { zIndex } : {}),
|
||||
position: 'fixed',
|
||||
backdropFilter:
|
||||
overlayBlur && overlayBlur > 0 ? `blur(${overlayBlur}px)` : 'none',
|
||||
}"
|
||||
@click="() => emits('close')"
|
||||
/>
|
||||
</Transition>
|
||||
<AlertDialogContent
|
||||
ref="contentRef"
|
||||
:style="{ ...(zIndex ? { zIndex } : {}), position: 'fixed' }"
|
||||
@animationend="onAnimationEnd"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'z-popup bg-background p-6 shadow-lg outline-none sm:rounded-xl',
|
||||
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
{
|
||||
'data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%]':
|
||||
!centered,
|
||||
'data-[state=closed]:slide-out-to-top-[148%] data-[state=open]:slide-in-from-top-[98%]':
|
||||
centered,
|
||||
'top-[10vh]': !centered,
|
||||
'top-1/2 -translate-y-1/2': centered,
|
||||
},
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AlertDialogDescriptionProps } from 'reka-ui';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { AlertDialogDescription, useForwardProps } from 'reka-ui';
|
||||
|
||||
const props = defineProps<AlertDialogDescriptionProps & { class?: any }>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogDescription
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||
>
|
||||
<slot></slot>
|
||||
</AlertDialogDescription>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useScrollLock } from '@vben-core/composables';
|
||||
|
||||
useScrollLock();
|
||||
</script>
|
||||
<template>
|
||||
<div class="z-popup inset-0 bg-overlay"></div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogTitleProps } from 'reka-ui';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { AlertDialogTitle, useForwardProps } from 'reka-ui';
|
||||
|
||||
const props = defineProps<AlertDialogTitleProps & { class?: any }>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTitle
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn('text-lg font-semibold leading-none tracking-tight', props.class)
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
</AlertDialogTitle>
|
||||
</template>
|
||||
27
packages/@core/ui-kit/shadcn-ui/src/ui/avatar/Avatar.vue
Normal file
27
packages/@core/ui-kit/shadcn-ui/src/ui/avatar/Avatar.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { AvatarVariants } from './avatar';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { AvatarRoot } from 'reka-ui';
|
||||
|
||||
import { avatarVariant } from './avatar';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
class?: any;
|
||||
shape?: AvatarVariants['shape'];
|
||||
size?: AvatarVariants['size'];
|
||||
}>(),
|
||||
{
|
||||
shape: 'circle',
|
||||
size: 'sm',
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarRoot :class="cn(avatarVariant({ size, shape }), props.class)">
|
||||
<slot></slot>
|
||||
</AvatarRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { AvatarFallbackProps } from 'reka-ui';
|
||||
|
||||
import { AvatarFallback } from 'reka-ui';
|
||||
|
||||
const props = defineProps<AvatarFallbackProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarFallback v-bind="props">
|
||||
<slot></slot>
|
||||
</AvatarFallback>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { AvatarImageProps } from 'reka-ui';
|
||||
|
||||
import { AvatarImage } from 'reka-ui';
|
||||
|
||||
const props = defineProps<AvatarImageProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarImage v-bind="props" class="h-full w-full object-cover" />
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
class?: any;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
:class="
|
||||
cn('inline-flex items-center gap-1.5 hover:text-foreground', props.class)
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
</li>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user