refactor: 悦码项目重构
This commit is contained in:
6
apps/platform/src/components/basic/README.md
Normal file
6
apps/platform/src/components/basic/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
### 基础组件(目录说明)
|
||||
|
||||
| 组件名称 | 描述 | 是否全局组件 | 使用建议 |
|
||||
| --- | --- | --- | --- |
|
||||
| button | `按钮组件`基于 a-button 二次封装,主要扩展了按钮的颜色,基本使用方式与 antdv 的 a-button 保持一致 | 是 | -- |
|
||||
| check-box | `复选框`基于 a-checkbox 二次封装,基本使用方式与 antdv 的 a-checkbox 保持一致 | 否 | -- |
|
||||
1
apps/platform/src/components/basic/basic-arrow/index.ts
Normal file
1
apps/platform/src/components/basic/basic-arrow/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as BasicArrow } from './index.vue';
|
||||
24
apps/platform/src/components/basic/basic-arrow/index.vue
Normal file
24
apps/platform/src/components/basic/basic-arrow/index.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<DownOutlined class="collapse-icon" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { DownOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
expand: { type: Boolean },
|
||||
});
|
||||
|
||||
/**
|
||||
* @description 展开/收起 图标旋转转数
|
||||
*/
|
||||
const turn = computed(() => `${props.expand ? 0 : 0.5}turn`);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.collapse-icon {
|
||||
transform: rotate(v-bind(turn));
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
</style>
|
||||
94
apps/platform/src/components/basic/basic-help/index.vue
Normal file
94
apps/platform/src/components/basic/basic-help/index.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="tsx">
|
||||
import { defineComponent, computed, unref } from 'vue';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons-vue';
|
||||
import { Tooltip } from 'ant-design-vue';
|
||||
import type { CSSProperties, PropType } from 'vue';
|
||||
import { isString, isArray } from '@/utils/is';
|
||||
import { getSlot } from '@/utils/helper/tsxHelper';
|
||||
|
||||
const props = {
|
||||
/**
|
||||
* Help text max-width
|
||||
* @default: 600px
|
||||
*/
|
||||
maxWidth: { type: String, default: '600px' },
|
||||
/**
|
||||
* Whether to display the serial number
|
||||
* @default: false
|
||||
*/
|
||||
showIndex: { type: Boolean },
|
||||
/**
|
||||
* Help text font color
|
||||
* @default: #ffffff
|
||||
*/
|
||||
color: { type: String, default: '#ffffff' },
|
||||
/**
|
||||
* Help text font size
|
||||
* @default: 14px
|
||||
*/
|
||||
fontSize: { type: String, default: '14px' },
|
||||
/**
|
||||
* Help text list
|
||||
*/
|
||||
placement: { type: String, default: 'right' },
|
||||
/**
|
||||
* Help text list
|
||||
*/
|
||||
text: { type: [Array, String] as PropType<string[] | string> },
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BasicHelp',
|
||||
components: { Tooltip },
|
||||
props,
|
||||
setup(props, { slots }) {
|
||||
const getTooltipStyle = computed(
|
||||
(): CSSProperties => ({ color: props.color, fontSize: props.fontSize }),
|
||||
);
|
||||
|
||||
const getOverlayStyle = computed((): CSSProperties => ({ maxWidth: props.maxWidth }));
|
||||
|
||||
function renderTitle() {
|
||||
const textList = props.text;
|
||||
|
||||
if (isString(textList)) {
|
||||
return <p>{textList}</p>;
|
||||
}
|
||||
|
||||
if (isArray(textList)) {
|
||||
return textList.map((text, index) => {
|
||||
return (
|
||||
<p key={text}>
|
||||
<>
|
||||
{props.showIndex ? `${index + 1}. ` : ''}
|
||||
{text}
|
||||
</>
|
||||
</p>
|
||||
);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
return (
|
||||
<Tooltip
|
||||
overlayClassName="basic-help__wrap"
|
||||
title={<div style={unref(getTooltipStyle)}>{renderTitle()}</div>}
|
||||
autoAdjustOverflow={true}
|
||||
overlayStyle={unref(getOverlayStyle)}
|
||||
placement={props.placement as 'right'}
|
||||
>
|
||||
<span class="basic-help">{getSlot(slots) || <InfoCircleOutlined />}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.basic-help__wrap p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
22
apps/platform/src/components/basic/button/button.ts
Normal file
22
apps/platform/src/components/basic/button/button.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import buttonProps from 'ant-design-vue/es/button/buttonTypes';
|
||||
import { theme } from 'ant-design-vue';
|
||||
import type { ButtonType as AButtonType } from 'ant-design-vue/es/button/buttonTypes';
|
||||
import type { ExtractPropTypes } from 'vue';
|
||||
|
||||
const { defaultSeed } = theme;
|
||||
|
||||
export declare type ButtonProps = Partial<ExtractPropTypes<typeof buttonProps>>;
|
||||
|
||||
export type ButtonType = AButtonType | 'warning' | 'success' | 'error';
|
||||
|
||||
/** 这里自定义颜色 */
|
||||
export const buttonColorPrimary = {
|
||||
success: defaultSeed.colorSuccess,
|
||||
warning: defaultSeed.colorWarning,
|
||||
error: defaultSeed.colorError,
|
||||
} as const;
|
||||
|
||||
export const aButtonTypes = ['default', 'primary', 'ghost', 'dashed', 'link', 'text'];
|
||||
|
||||
export type { AButtonType };
|
||||
export { buttonProps };
|
||||
55
apps/platform/src/components/basic/button/button.vue
Normal file
55
apps/platform/src/components/basic/button/button.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<ProConfigProvider :theme="btnTheme">
|
||||
<component :is="h(Button, { ...$attrs, ...props, type: buttonType }, $slots)" />
|
||||
</ProConfigProvider>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, h } from 'vue';
|
||||
|
||||
import { useConfigContextInject } from 'ant-design-vue/es/config-provider/context';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
import { buttonProps, buttonColorPrimary, aButtonTypes } from './button';
|
||||
|
||||
import type { ButtonType, AButtonType } from './button';
|
||||
|
||||
const { theme: globalTheme } = useConfigContextInject();
|
||||
|
||||
defineOptions({
|
||||
name: 'AButton',
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
...buttonProps(),
|
||||
type: {
|
||||
type: String as PropType<ButtonType>,
|
||||
},
|
||||
// 自定义按钮颜色
|
||||
color: String,
|
||||
});
|
||||
|
||||
const isCustomType = computed(() => Reflect.has(buttonColorPrimary, props.type!));
|
||||
|
||||
const buttonType = computed<AButtonType>(() => {
|
||||
if (props.type && aButtonTypes.includes(props.type)) {
|
||||
return props.type as AButtonType;
|
||||
} else if (props.color || isCustomType.value) {
|
||||
return 'primary';
|
||||
}
|
||||
return 'default';
|
||||
});
|
||||
|
||||
const btnTheme = computed(() => {
|
||||
const type = props.type!;
|
||||
if (props.color || isCustomType.value) {
|
||||
return {
|
||||
...globalTheme.value,
|
||||
token: {
|
||||
colorPrimary: props.color || buttonColorPrimary[type],
|
||||
},
|
||||
};
|
||||
}
|
||||
return globalTheme.value;
|
||||
});
|
||||
</script>
|
||||
9
apps/platform/src/components/basic/button/index.ts
Normal file
9
apps/platform/src/components/basic/button/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import AButton from './button.vue';
|
||||
|
||||
export default AButton;
|
||||
|
||||
export const Button = AButton;
|
||||
|
||||
export * from './button';
|
||||
|
||||
export { AButton };
|
||||
54
apps/platform/src/components/basic/check-box/index.vue
Normal file
54
apps/platform/src/components/basic/check-box/index.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<Checkbox v-bind="getProps" v-model:checked="checkedModel" @change="handleChange">
|
||||
<slot />
|
||||
</Checkbox>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { checkboxProps } from 'ant-design-vue/es/checkbox';
|
||||
import { omit } from 'lodash-es';
|
||||
import { Checkbox } from 'ant-design-vue';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
...checkboxProps(),
|
||||
trueValue: {
|
||||
type: [Number, Boolean, String],
|
||||
default: true,
|
||||
},
|
||||
falseValue: {
|
||||
type: [Number, Boolean, String],
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:checked', 'change']);
|
||||
|
||||
const getProps = computed(() => {
|
||||
return omit(props, ['onUpdate:checked', 'onChange']);
|
||||
});
|
||||
|
||||
const checkedModel = computed<boolean>({
|
||||
get() {
|
||||
return props.checked === props.trueValue;
|
||||
},
|
||||
set(val) {
|
||||
emit('update:checked', val ? props.trueValue : props.falseValue);
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const evt = {
|
||||
...e,
|
||||
target: {
|
||||
...e.target,
|
||||
checked: e.target.checked ? props.trueValue : props.falseValue,
|
||||
},
|
||||
};
|
||||
emit('change', evt);
|
||||
};
|
||||
</script>
|
||||
3
apps/platform/src/components/basic/context-menu/index.ts
Normal file
3
apps/platform/src/components/basic/context-menu/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { createContextMenu, destroyContextMenu } from './src/createContextMenu';
|
||||
|
||||
export * from './src/typing';
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="tsx">
|
||||
import { defineComponent, computed, ref, unref } from 'vue';
|
||||
import { Menu, Dropdown } from 'ant-design-vue';
|
||||
import type { ContextMenuItem, ItemContentProps, Axis } from './typing';
|
||||
import type { FunctionalComponent, CSSProperties, PropType } from 'vue';
|
||||
import { IconFont } from '@/components/basic/icon';
|
||||
|
||||
const props = {
|
||||
width: { type: Number, default: 156 },
|
||||
customEvent: { type: Object as PropType<Event>, default: null },
|
||||
styles: { type: Object as PropType<CSSProperties> },
|
||||
showIcon: { type: Boolean, default: true },
|
||||
axis: {
|
||||
// The position of the right mouse button click
|
||||
type: Object as PropType<Axis>,
|
||||
default() {
|
||||
return { x: 0, y: 0 };
|
||||
},
|
||||
},
|
||||
items: {
|
||||
// The most important list, if not, will not be displayed
|
||||
type: Array as PropType<ContextMenuItem[]>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ItemContent: FunctionalComponent<ItemContentProps> = (props) => {
|
||||
const { item } = props;
|
||||
return (
|
||||
<span
|
||||
style="display: inline-block; width: 100%; "
|
||||
class="px-4"
|
||||
onClick={props.handler.bind(null, item)}
|
||||
>
|
||||
{props.showIcon && item.icon && <IconFont class="mr-2" type={item.icon} />}
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ContextMenu',
|
||||
props,
|
||||
setup(props, { expose }) {
|
||||
const open = ref(true);
|
||||
|
||||
const getStyle = computed((): CSSProperties => {
|
||||
const { axis, items, styles, width } = props;
|
||||
const { x, y } = axis || { x: 0, y: 0 };
|
||||
const menuHeight = (items || []).length * 40;
|
||||
const menuWidth = width;
|
||||
const body = document.body;
|
||||
|
||||
const left = body.clientWidth < x + menuWidth ? x - menuWidth : x;
|
||||
const top = body.clientHeight < y + menuHeight ? y - menuHeight : y;
|
||||
return {
|
||||
position: 'absolute',
|
||||
width: `${width}px`,
|
||||
left: `${left + 1}px`,
|
||||
top: `${top + 1}px`,
|
||||
...styles,
|
||||
};
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
function handleAction(item: ContextMenuItem, e: MouseEvent) {
|
||||
const { handler, disabled } = item;
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
handler?.();
|
||||
close();
|
||||
}
|
||||
|
||||
function renderMenuItem(items: ContextMenuItem[]) {
|
||||
const visibleItems = items.filter((item) => !item.hidden);
|
||||
return visibleItems.map((item) => {
|
||||
const { disabled, label, children, divider = false } = item;
|
||||
|
||||
const contentProps = {
|
||||
item,
|
||||
handler: handleAction,
|
||||
showIcon: props.showIcon,
|
||||
};
|
||||
|
||||
if (!children || children.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<Menu.Item disabled={disabled} key={label}>
|
||||
<ItemContent {...contentProps} />
|
||||
</Menu.Item>
|
||||
{divider ? <Menu.Divider key={`d-${label}`} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu.SubMenu key={label} disabled={disabled}>
|
||||
{{
|
||||
title: () => <ItemContent {...contentProps} />,
|
||||
default: () => renderMenuItem(children),
|
||||
}}
|
||||
</Menu.SubMenu>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
expose({
|
||||
close,
|
||||
});
|
||||
|
||||
return () => {
|
||||
const { items } = props;
|
||||
return (
|
||||
<Dropdown open={open.value}>
|
||||
{{
|
||||
default: () => <div style={unref(getStyle)}></div>,
|
||||
overlay: () => (
|
||||
<Menu inlineIndent={12} mode="vertical">
|
||||
{renderMenuItem(items)}
|
||||
</Menu>
|
||||
),
|
||||
}}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createVNode, render } from 'vue';
|
||||
import contextMenuVue from './ContextMenu.vue';
|
||||
import type { CreateContextOptions, ContextMenuProps } from './typing';
|
||||
import { isClient } from '@/utils/is';
|
||||
|
||||
const menuManager: {
|
||||
domList: Element[];
|
||||
resolve: Fn;
|
||||
} = {
|
||||
domList: [],
|
||||
resolve: () => {},
|
||||
};
|
||||
|
||||
export const createContextMenu = function (options: CreateContextOptions) {
|
||||
const { event } = options || {};
|
||||
|
||||
event && event?.preventDefault();
|
||||
|
||||
if (!isClient) {
|
||||
return;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const body = document.body;
|
||||
|
||||
const container = document.createElement('div');
|
||||
const propsData: Partial<ContextMenuProps> = {
|
||||
getPopupContainer: () => container,
|
||||
};
|
||||
if (options.styles) {
|
||||
propsData.styles = options.styles;
|
||||
}
|
||||
|
||||
if (options.items) {
|
||||
propsData.items = options.items;
|
||||
}
|
||||
|
||||
if (options.event) {
|
||||
propsData.customEvent = event;
|
||||
propsData.axis = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
|
||||
const vm = createVNode(contextMenuVue, propsData);
|
||||
render(vm, container);
|
||||
|
||||
const handleClick = function () {
|
||||
menuManager.resolve('');
|
||||
};
|
||||
|
||||
menuManager.domList.push(container);
|
||||
|
||||
const remove = function () {
|
||||
menuManager.domList.forEach((dom: Element) => {
|
||||
try {
|
||||
dom && body.removeChild(dom);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
body.removeEventListener('click', handleClick);
|
||||
body.removeEventListener('scroll', handleClick);
|
||||
};
|
||||
|
||||
menuManager.resolve = function (arg) {
|
||||
vm.component?.exposed?.close();
|
||||
remove();
|
||||
resolve(arg);
|
||||
};
|
||||
remove();
|
||||
body.appendChild(container);
|
||||
body.addEventListener('click', handleClick);
|
||||
body.addEventListener('scroll', handleClick);
|
||||
});
|
||||
};
|
||||
|
||||
export const destroyContextMenu = function () {
|
||||
if (menuManager) {
|
||||
menuManager.resolve('');
|
||||
menuManager.domList = [];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { DropdownProps } from 'ant-design-vue/es/dropdown';
|
||||
|
||||
export interface Axis {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
icon?: string;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
handler?: Fn;
|
||||
divider?: boolean;
|
||||
children?: ContextMenuItem[];
|
||||
}
|
||||
export interface CreateContextOptions {
|
||||
event: MouseEvent;
|
||||
icon?: string;
|
||||
styles?: any;
|
||||
items?: ContextMenuItem[];
|
||||
}
|
||||
|
||||
export interface ContextMenuProps extends DropdownProps {
|
||||
event?: MouseEvent;
|
||||
styles?: any;
|
||||
items: ContextMenuItem[];
|
||||
customEvent?: MouseEvent;
|
||||
axis?: Axis;
|
||||
width?: number;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export interface ItemContentProps {
|
||||
showIcon: boolean | undefined;
|
||||
item: ContextMenuItem;
|
||||
handler: Fn;
|
||||
}
|
||||
8
apps/platform/src/components/basic/excel/index.ts
Normal file
8
apps/platform/src/components/basic/excel/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import impExcel from './src/ImportExcel.vue';
|
||||
import { withInstall } from '@/utils';
|
||||
export { useExportExcelModal } from './src/ExportExcelModal';
|
||||
|
||||
export const ImpExcel = withInstall(impExcel);
|
||||
// export const ExpExcelModal = withInstall(expExcelModal);
|
||||
export * from './src/typing';
|
||||
export * from './src/Export2Excel';
|
||||
59
apps/platform/src/components/basic/excel/src/Export2Excel.ts
Normal file
59
apps/platform/src/components/basic/excel/src/Export2Excel.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { utils, writeFile } from 'xlsx';
|
||||
import type { WorkBook } from 'xlsx';
|
||||
import type { JsonToSheet, AoAToSheet } from './typing';
|
||||
|
||||
const DEF_FILE_NAME = 'excel-list.xlsx';
|
||||
|
||||
export function jsonToSheetXlsx<T = any>({
|
||||
data,
|
||||
header,
|
||||
filename = DEF_FILE_NAME,
|
||||
json2sheetOpts = {},
|
||||
write2excelOpts = { bookType: 'xlsx' },
|
||||
}: JsonToSheet<T>) {
|
||||
let arrData = [...data];
|
||||
if (header) {
|
||||
arrData.unshift(header);
|
||||
const filterKeys = Object.keys(header);
|
||||
arrData = arrData.map((item) => filterKeys.reduce<any>((p, k) => ((p[k] = item[k]), p), {}));
|
||||
json2sheetOpts.skipHeader = true;
|
||||
}
|
||||
|
||||
const worksheet = utils.json_to_sheet(arrData, json2sheetOpts);
|
||||
|
||||
/* add worksheet to workbook */
|
||||
const workbook: WorkBook = {
|
||||
SheetNames: [filename],
|
||||
Sheets: {
|
||||
[filename]: worksheet,
|
||||
},
|
||||
};
|
||||
/* output format determined by filename */
|
||||
writeFile(workbook, filename, write2excelOpts);
|
||||
/* at this point, out.xlsb will have been downloaded */
|
||||
}
|
||||
|
||||
export function aoaToSheetXlsx<T = any>({
|
||||
data,
|
||||
header,
|
||||
filename = DEF_FILE_NAME,
|
||||
write2excelOpts = { bookType: 'xlsx' },
|
||||
}: AoAToSheet<T>) {
|
||||
const arrData = [...data];
|
||||
if (header) {
|
||||
arrData.unshift(header);
|
||||
}
|
||||
|
||||
const worksheet = utils.aoa_to_sheet(arrData);
|
||||
|
||||
/* add worksheet to workbook */
|
||||
const workbook: WorkBook = {
|
||||
SheetNames: [filename],
|
||||
Sheets: {
|
||||
[filename]: worksheet,
|
||||
},
|
||||
};
|
||||
/* output format determined by filename */
|
||||
writeFile(workbook, filename, write2excelOpts);
|
||||
/* at this point, out.xlsb will have been downloaded */
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ExportModalResult } from './typing';
|
||||
import type { FormSchema } from '@/components/core/schema-form/';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
import { useFormModal } from '@/hooks/useModal/';
|
||||
|
||||
export type OpenModalOptions = {
|
||||
onOk: (val: ExportModalResult) => any;
|
||||
};
|
||||
|
||||
const getSchemas = (t): FormSchema<ExportModalResult>[] => [
|
||||
{
|
||||
field: 'filename',
|
||||
component: 'Input',
|
||||
label: t('component.excel.fileName'),
|
||||
rules: [{ required: true }],
|
||||
},
|
||||
{
|
||||
field: 'bookType',
|
||||
component: 'Select',
|
||||
label: t('component.excel.fileType'),
|
||||
defaultValue: 'xlsx',
|
||||
rules: [{ required: true }],
|
||||
componentProps: {
|
||||
options: [
|
||||
{
|
||||
label: 'xlsx',
|
||||
value: 'xlsx',
|
||||
key: 'xlsx',
|
||||
},
|
||||
{
|
||||
label: 'html',
|
||||
value: 'html',
|
||||
key: 'html',
|
||||
},
|
||||
{
|
||||
label: 'csv',
|
||||
value: 'csv',
|
||||
key: 'csv',
|
||||
},
|
||||
{
|
||||
label: 'txt',
|
||||
value: 'txt',
|
||||
key: 'txt',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const useExportExcelModal = () => {
|
||||
const { t } = useI18n();
|
||||
const [showModal] = useFormModal();
|
||||
|
||||
const openModal = ({ onOk }: OpenModalOptions) => {
|
||||
showModal<ExportModalResult>({
|
||||
modalProps: {
|
||||
title: t('component.excel.exportModalTitle'),
|
||||
onFinish: async (values) => {
|
||||
const { filename, bookType } = values;
|
||||
|
||||
onOk({
|
||||
filename: `${filename.split('.').shift()}.${bookType}`,
|
||||
bookType,
|
||||
});
|
||||
},
|
||||
},
|
||||
formProps: {
|
||||
labelWidth: 100,
|
||||
schemas: getSchemas(t),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
openModal,
|
||||
};
|
||||
};
|
||||
159
apps/platform/src/components/basic/excel/src/ImportExcel.vue
Normal file
159
apps/platform/src/components/basic/excel/src/ImportExcel.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div>
|
||||
<input
|
||||
v-show="false"
|
||||
ref="inputRef"
|
||||
type="file"
|
||||
accept=".xlsx, .xls"
|
||||
@change="handleInputClick"
|
||||
/>
|
||||
<div @click="handleUpload">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, unref } from 'vue';
|
||||
import { read, utils } from 'xlsx';
|
||||
import type { WorkSheet, WorkBook } from 'xlsx';
|
||||
import type { ExcelData } from './typing';
|
||||
import { dateUtil } from '@/utils/dateUtil';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ImportExcel',
|
||||
props: {
|
||||
// 日期时间格式。如果不提供或者提供空值,将返回原始Date对象
|
||||
dateFormat: {
|
||||
type: String,
|
||||
},
|
||||
// 时区调整。实验性功能,仅为了解决读取日期时间值有偏差的问题。目前仅提供了+08:00时区的偏差修正值
|
||||
// https://github.com/SheetJS/sheetjs/issues/1470#issuecomment-501108554
|
||||
timeZone: {
|
||||
type: Number,
|
||||
default: 8,
|
||||
},
|
||||
},
|
||||
emits: ['success', 'error'],
|
||||
setup(props, { emit }) {
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const loadingRef = ref<Boolean>(false);
|
||||
|
||||
/**
|
||||
* @description: 第一行作为头部
|
||||
*/
|
||||
function getHeaderRow(sheet: WorkSheet) {
|
||||
if (!sheet || !sheet['!ref']) return [];
|
||||
const headers: string[] = [];
|
||||
// A3:B7=>{s:{c:0, r:2}, e:{c:1, r:6}}
|
||||
const range = utils.decode_range(sheet['!ref']);
|
||||
|
||||
const R = range.s.r;
|
||||
/* start in the first row */
|
||||
for (let C = range.s.c; C <= range.e.c; ++C) {
|
||||
/* walk every column in the range */
|
||||
const cell = sheet[utils.encode_cell({ c: C, r: R })];
|
||||
/* find the cell in the first row */
|
||||
let hdr = `UNKNOWN ${C}`; // <-- replace with your desired default
|
||||
if (cell && cell.t) hdr = utils.format_cell(cell);
|
||||
headers.push(hdr);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 获得excel数据
|
||||
*/
|
||||
function getExcelData(workbook: WorkBook) {
|
||||
const excelData: ExcelData[] = [];
|
||||
const { dateFormat, timeZone } = props;
|
||||
for (const sheetName of workbook.SheetNames) {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const header: string[] = getHeaderRow(worksheet);
|
||||
let results = utils.sheet_to_json(worksheet, {
|
||||
raw: true,
|
||||
dateNF: dateFormat, //Not worked
|
||||
}) as object[];
|
||||
results = results.map((row: object) => {
|
||||
for (const field in row) {
|
||||
if (row[field] instanceof Date) {
|
||||
if (timeZone === 8) {
|
||||
row[field].setSeconds(row[field].getSeconds() + 43);
|
||||
}
|
||||
if (dateFormat) {
|
||||
row[field] = dateUtil(row[field]).format(dateFormat);
|
||||
}
|
||||
}
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
excelData.push({
|
||||
header,
|
||||
results,
|
||||
meta: {
|
||||
sheetName,
|
||||
},
|
||||
});
|
||||
}
|
||||
return excelData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 读取excel数据
|
||||
*/
|
||||
function readerData(rawFile: File) {
|
||||
loadingRef.value = true;
|
||||
const { promise, resolve, reject } = Promise.withResolvers();
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const data = e.target && e.target.result;
|
||||
const workbook = read(data, { type: 'array', cellDates: true });
|
||||
// console.log(workbook);
|
||||
/* DO SOMETHING WITH workbook HERE */
|
||||
const excelData = getExcelData(workbook);
|
||||
emit('success', excelData);
|
||||
resolve('');
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
emit('error');
|
||||
} finally {
|
||||
loadingRef.value = false;
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(rawFile);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function upload(rawFile: File) {
|
||||
const inputRefDom = unref(inputRef);
|
||||
if (inputRefDom) {
|
||||
// fix can't select the same excel
|
||||
inputRefDom.value = '';
|
||||
}
|
||||
await readerData(rawFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 触发选择文件管理器
|
||||
*/
|
||||
function handleInputClick(e: Event) {
|
||||
const files = e && (e.target as HTMLInputElement).files;
|
||||
const rawFile = files && files[0]; // only setting files[0]
|
||||
if (!rawFile) return;
|
||||
upload(rawFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 点击上传按钮
|
||||
*/
|
||||
function handleUpload() {
|
||||
const inputRefDom = unref(inputRef);
|
||||
inputRefDom && inputRefDom.click();
|
||||
}
|
||||
|
||||
return { handleUpload, handleInputClick, inputRef };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
27
apps/platform/src/components/basic/excel/src/typing.ts
Normal file
27
apps/platform/src/components/basic/excel/src/typing.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { JSON2SheetOpts, WritingOptions, BookType } from 'xlsx';
|
||||
|
||||
export interface ExcelData<T = any> {
|
||||
header: string[];
|
||||
results: T[];
|
||||
meta: { sheetName: string };
|
||||
}
|
||||
|
||||
export interface JsonToSheet<T = any> {
|
||||
data: T[];
|
||||
header?: T;
|
||||
filename?: string;
|
||||
json2sheetOpts?: JSON2SheetOpts;
|
||||
write2excelOpts?: WritingOptions;
|
||||
}
|
||||
|
||||
export interface AoAToSheet<T = any> {
|
||||
data: T[][];
|
||||
header?: T[];
|
||||
filename?: string;
|
||||
write2excelOpts?: WritingOptions;
|
||||
}
|
||||
|
||||
export interface ExportModalResult {
|
||||
filename: string;
|
||||
bookType: BookType;
|
||||
}
|
||||
57
apps/platform/src/components/basic/icon/Icon.vue
Normal file
57
apps/platform/src/components/basic/icon/Icon.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, type CSSProperties, type VNode } from 'vue';
|
||||
import { useAttrs } from 'vue';
|
||||
import { Icon as IconifyIcon } from '@iconify/vue';
|
||||
import { isString, omit } from 'lodash-es';
|
||||
import SvgIcon from './src/SvgIcon.vue';
|
||||
import IconFont from './src/icon-font';
|
||||
import type { IconProps } from './src/props';
|
||||
|
||||
const props = withDefaults(defineProps<IconProps>(), {
|
||||
type: 'iconify',
|
||||
size: 16,
|
||||
});
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const getWrapStyle = computed((): CSSProperties => {
|
||||
const { size, color } = props;
|
||||
let fs = size;
|
||||
if (isString(size)) {
|
||||
fs = parseInt(size, 10);
|
||||
}
|
||||
|
||||
return {
|
||||
fontSize: `${fs}px`,
|
||||
color,
|
||||
display: 'inline-flex',
|
||||
};
|
||||
});
|
||||
|
||||
/** svg 不支持 title 属性,需要在其元素内部手动添加 title 标签 */
|
||||
const handleIconUpdated = (vnode: VNode) => {
|
||||
const title = attrs.title;
|
||||
if (vnode.el && title) {
|
||||
vnode.el.insertAdjacentHTML?.('afterbegin', `<title>${title}</title>`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="type === 'svg'">
|
||||
<SvgIcon v-bind="{ ...$attrs, ...props }" :name="icon" class="anticon" />
|
||||
</template>
|
||||
<template v-else-if="type === 'icon-font'">
|
||||
<IconFont v-bind="{ ...$attrs, ...props }" :type="icon" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<IconifyIcon
|
||||
v-bind="omit({ ...$attrs, ...props }, ['size', 'color'])"
|
||||
:style="getWrapStyle"
|
||||
class="anticon"
|
||||
@vue:updated="handleIconUpdated"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
8
apps/platform/src/components/basic/icon/index.ts
Normal file
8
apps/platform/src/components/basic/icon/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as IconPicker } from './src/IconPicker.vue';
|
||||
export { default as SvgIcon } from './src/SvgIcon.vue';
|
||||
export { default as Icon } from './Icon.vue';
|
||||
export { default as IconFont } from './src/icon-font';
|
||||
|
||||
export * from './src/props';
|
||||
|
||||
export { setupIcons } from './src/icons.data';
|
||||
120
apps/platform/src/components/basic/icon/src/IconPicker.vue
Normal file
120
apps/platform/src/components/basic/icon/src/IconPicker.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<a-form-item-rest>
|
||||
<a-popover
|
||||
v-model:open="visible"
|
||||
placement="bottomLeft"
|
||||
:overlay-inner-style="{ paddingTop: 0 }"
|
||||
trigger="click"
|
||||
>
|
||||
<template #title>
|
||||
<a-tabs
|
||||
v-model:activeKey="activeCateName"
|
||||
size="small"
|
||||
:tab-bar-style="{ marginBottom: '8px' }"
|
||||
>
|
||||
<a-tab-pane v-for="(_, cateName) in iconsMap" :key="cateName" :tab="cateName" />
|
||||
</a-tabs>
|
||||
<a-input
|
||||
autofocus
|
||||
allow-clear
|
||||
:placeholder="`从“${activeCateName}”中搜索图标`"
|
||||
@change="handleSearchChange"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<RecycleScroller
|
||||
class="select-box"
|
||||
:items="iconFilteredList"
|
||||
key-field="name"
|
||||
:item-size="38"
|
||||
:grid-items="9"
|
||||
:item-secondary-size="38"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div
|
||||
:key="item.name"
|
||||
:title="item.name"
|
||||
:class="{ active: modelValue == item.name }"
|
||||
class="select-box-item"
|
||||
@click="selectIcon(item.name)"
|
||||
>
|
||||
<Icon :icon="item.name" class="text-[20px]" />
|
||||
</div>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
</template>
|
||||
|
||||
<a-input v-bind="$attrs" v-model:value="modelValue" :placeholder="placeholder" allow-clear>
|
||||
<template v-if="modelValue" #prefix>
|
||||
<Icon :icon="modelValue" class="text-[20px]" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-popover>
|
||||
</a-form-item-rest>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { RecycleScroller } from 'vue-virtual-scroller';
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { setupIcons, icons } from './icons.data';
|
||||
import { iconPickerProps } from './props';
|
||||
|
||||
// 添加默认图标集合
|
||||
setupIcons();
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
defineProps(iconPickerProps);
|
||||
|
||||
const modelValue = defineModel<string>('value');
|
||||
|
||||
const iconsMap = Object.entries(icons).reduce(
|
||||
(prev, [cateName, curr]) => {
|
||||
prev[cateName] = Object.keys(curr.icons).map((name) => ({ name: `${curr.prefix}:${name}` }));
|
||||
return prev;
|
||||
},
|
||||
{
|
||||
全部: Object.values(icons).flatMap((item) =>
|
||||
Object.keys(item.icons).map((name) => ({ name: `${item.prefix}:${name}` })),
|
||||
),
|
||||
} as Recordable<{ name: string }[]>,
|
||||
);
|
||||
|
||||
const visible = ref(false);
|
||||
const activeCateName = ref('全部');
|
||||
const keyword = ref('');
|
||||
|
||||
const iconFilteredList = computed(() => {
|
||||
const list = iconsMap[activeCateName.value];
|
||||
return list.filter((item) => item.name.includes(keyword.value));
|
||||
});
|
||||
|
||||
const handleSearchChange = useDebounceFn((e: Event) => {
|
||||
keyword.value = (e.target as HTMLInputElement).value;
|
||||
}, 100);
|
||||
|
||||
const selectIcon = (name: string) => {
|
||||
modelValue.value = name;
|
||||
visible.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.select-box {
|
||||
@apply h-300px min-w-350px;
|
||||
|
||||
&-item {
|
||||
@apply flex m-2px p-6px;
|
||||
|
||||
border: 1px solid #e5e7eb;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
@apply border-blue-600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
33
apps/platform/src/components/basic/icon/src/SvgIcon.vue
Normal file
33
apps/platform/src/components/basic/icon/src/SvgIcon.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<svg v-bind="$attrs" class="svg-icon" :style="getStyle" aria-hidden="true">
|
||||
<use :xlink:href="symbolId" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, type CSSProperties } from 'vue';
|
||||
import { svgIconProps } from './props';
|
||||
|
||||
defineOptions({
|
||||
name: 'SvgIcon',
|
||||
});
|
||||
|
||||
const props = defineProps(svgIconProps);
|
||||
const symbolId = computed(() => `#${props.prefix}-${props.name}`);
|
||||
const getStyle = computed((): CSSProperties => {
|
||||
const { size } = props;
|
||||
const s = `${size}`.replace('px', '').concat('px');
|
||||
return {
|
||||
width: s,
|
||||
height: s,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.svg-icon {
|
||||
overflow: hidden;
|
||||
fill: currentcolor;
|
||||
vertical-align: -0.15em;
|
||||
}
|
||||
</style>
|
||||
74
apps/platform/src/components/basic/icon/src/icon-font.tsx
Normal file
74
apps/platform/src/components/basic/icon/src/icon-font.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { defineComponent, unref, computed } from 'vue';
|
||||
import { createFromIconfontCN } from '@ant-design/icons-vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { isString } from '@/utils/is';
|
||||
import { uniqueSlash } from '@/utils/urlUtils';
|
||||
|
||||
let scriptUrls = [uniqueSlash(`${import.meta.env.BASE_URL}/iconfont.js`)];
|
||||
|
||||
// 文档:https://antdv.com/components/icon-cn#components-icon-demo-iconfont
|
||||
let MyIconFont = createFromIconfontCN({
|
||||
// scriptUrl: '//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js',
|
||||
// scriptUrl: '//at.alicdn.com/t/font_2184398_zflo1kjcemp.js',
|
||||
// iconfont字体图标本地化,详见:/public/iconfont.js
|
||||
scriptUrl: scriptUrls,
|
||||
});
|
||||
|
||||
export default defineComponent({
|
||||
name: 'IconFont',
|
||||
props: {
|
||||
type: {
|
||||
type: String as PropType<string>,
|
||||
default: '',
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: 'icon-',
|
||||
},
|
||||
color: {
|
||||
type: String as PropType<string>,
|
||||
default: 'unset',
|
||||
},
|
||||
size: {
|
||||
type: [Number, String] as PropType<number | string>,
|
||||
default: 14,
|
||||
},
|
||||
scriptUrl: {
|
||||
// 阿里图库字体图标路径
|
||||
type: String as PropType<string | string[]>,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup(props, { attrs }) {
|
||||
// 如果外部传进来字体图标路径,则覆盖默认的
|
||||
if (props.scriptUrl) {
|
||||
scriptUrls = [...new Set(scriptUrls.concat(props.scriptUrl))];
|
||||
MyIconFont = createFromIconfontCN({
|
||||
scriptUrl: scriptUrls,
|
||||
});
|
||||
}
|
||||
|
||||
const wrapStyleRef = computed(() => {
|
||||
const { color, size } = props;
|
||||
|
||||
const fs = isString(size) ? parseFloat(size) : size;
|
||||
|
||||
return {
|
||||
color,
|
||||
fontSize: `${fs}px`,
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
const { type, prefix } = props;
|
||||
|
||||
return type ? (
|
||||
<MyIconFont
|
||||
type={type.startsWith(prefix) ? type : `${prefix}${type}`}
|
||||
{...attrs}
|
||||
style={unref(wrapStyleRef)}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
},
|
||||
});
|
||||
14
apps/platform/src/components/basic/icon/src/icons.data.ts
Normal file
14
apps/platform/src/components/basic/icon/src/icons.data.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { addCollection } from '@iconify/vue';
|
||||
|
||||
import ep from '@iconify-json/ep/icons.json';
|
||||
import antDesign from '@iconify-json/ant-design/icons.json';
|
||||
|
||||
export const icons = { 'Ant Design': antDesign, 'Element Plus': ep } as const;
|
||||
|
||||
export type DefaultIconsType =
|
||||
| `ep:${keyof typeof ep.icons}`
|
||||
| `ant-design:${keyof typeof antDesign.icons}`;
|
||||
|
||||
export const setupIcons = () => {
|
||||
Object.values(icons).forEach((item) => addCollection(item));
|
||||
};
|
||||
30
apps/platform/src/components/basic/icon/src/props.ts
Normal file
30
apps/platform/src/components/basic/icon/src/props.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { DefaultIconsType } from './icons.data';
|
||||
|
||||
export const svgIconProps = {
|
||||
prefix: {
|
||||
type: String,
|
||||
default: 'svg-icon',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: [Number, String],
|
||||
default: 16,
|
||||
},
|
||||
};
|
||||
|
||||
export const iconPickerProps = {
|
||||
value: {
|
||||
type: String as PropType<DefaultIconsType>,
|
||||
},
|
||||
placeholder: String,
|
||||
};
|
||||
|
||||
export type IconProps = {
|
||||
type?: 'svg' | 'iconify' | 'icon-font';
|
||||
icon: DefaultIconsType | string;
|
||||
color?: string;
|
||||
size?: string | number;
|
||||
};
|
||||
3
apps/platform/src/components/basic/iframe-page/index.ts
Normal file
3
apps/platform/src/components/basic/iframe-page/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import IFramePage from './index.vue';
|
||||
|
||||
export default IFramePage;
|
||||
37
apps/platform/src/components/basic/iframe-page/index.vue
Normal file
37
apps/platform/src/components/basic/iframe-page/index.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="iframe-box wh-full">
|
||||
<Spin :spinning="loading" size="large">
|
||||
<iframe class="wh-full" v-bind="$attrs" :src="src" @load="onFrameLoad" />
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { Spin } from 'ant-design-vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'IFramePage',
|
||||
});
|
||||
|
||||
defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const loading = ref(true);
|
||||
|
||||
const onFrameLoad = () => {
|
||||
loading.value = false;
|
||||
};
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.iframe-box {
|
||||
transform: translate(0);
|
||||
|
||||
:deep(div[class^='ant-spin']) {
|
||||
@apply wh-full;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as LocalePicker } from './index.vue';
|
||||
60
apps/platform/src/components/basic/locale-picker/index.vue
Normal file
60
apps/platform/src/components/basic/locale-picker/index.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<Dropdown placement="bottomRight">
|
||||
<SvgIcon name="locale" />
|
||||
<span v-if="showText" class="ml-1">{{ getLocaleText }}</span>
|
||||
<template #overlay>
|
||||
<Menu v-model:selectedKeys="selectedKeys" @click="handleMenuClick">
|
||||
<Menu.Item v-for="item in localeList" :key="item.lang">
|
||||
<a href="javascript:;">{{ item.icon }} {{ item.label }}</a>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, watchEffect, unref, computed } from 'vue';
|
||||
import { Dropdown, Menu } from 'ant-design-vue';
|
||||
import { useLocale } from '@/locales/useLocale';
|
||||
import { type LocaleType, localeList } from '@/locales/config';
|
||||
import { SvgIcon } from '@/components/basic/icon';
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Whether to display text
|
||||
*/
|
||||
showText: { type: Boolean, default: true },
|
||||
/**
|
||||
* Whether to refresh the interface when changing
|
||||
*/
|
||||
reload: { type: Boolean },
|
||||
});
|
||||
|
||||
const selectedKeys = ref<string[]>([]);
|
||||
|
||||
const { changeLocale, getLocale } = useLocale();
|
||||
|
||||
const getLocaleText = computed(() => {
|
||||
const key = selectedKeys.value[0];
|
||||
if (!key) {
|
||||
return '';
|
||||
}
|
||||
return localeList.find((item) => item.lang === key)?.label;
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
selectedKeys.value = [unref(getLocale)];
|
||||
});
|
||||
|
||||
async function toggleLocale(lang: LocaleType | string) {
|
||||
await changeLocale(lang as LocaleType);
|
||||
selectedKeys.value = [lang as string];
|
||||
props.reload && location.reload();
|
||||
}
|
||||
|
||||
function handleMenuClick({ key }) {
|
||||
if (unref(getLocale) === key) {
|
||||
return;
|
||||
}
|
||||
toggleLocale(key as string);
|
||||
}
|
||||
</script>
|
||||
194
apps/platform/src/components/basic/lockscreen/huawei-charge.vue
Normal file
194
apps/platform/src/components/basic/lockscreen/huawei-charge.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div class="huawei-charge">
|
||||
<div class="number">{{ battery.level.toFixed(0) }}%</div>
|
||||
<div class="contrast">
|
||||
<div class="circle" />
|
||||
<ul class="bubbles">
|
||||
<li v-for="i in 15" :key="i" />
|
||||
</ul>
|
||||
</div>
|
||||
<div class="charging">
|
||||
<div>{{ batteryStatus }}</div>
|
||||
<div v-show="Number.isFinite(battery.dischargingTime) && battery.dischargingTime != 0">
|
||||
剩余可使用时间:{{ calcDischargingTime }}
|
||||
</div>
|
||||
<span v-show="Number.isFinite(battery.chargingTime) && battery.chargingTime != 0">
|
||||
距离电池充满需要:{{ calcDischargingTime }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import type { Battery } from '@/hooks/useBattery';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'HuaweiCharge',
|
||||
// props: ['batteryStatus', 'battery', 'calcDischargingTime'],
|
||||
props: {
|
||||
battery: {
|
||||
// 电池对象
|
||||
type: Object as PropType<Battery>,
|
||||
default: () => ({}),
|
||||
},
|
||||
calcDischargingTime: {
|
||||
// 电池剩余时间可用时间
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
batteryStatus: {
|
||||
// 电池状态
|
||||
type: String,
|
||||
validator: (val: string) => ['充电中', '已充满', '已断开电源'].includes(val),
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.huawei-charge {
|
||||
.generate-columns(15);
|
||||
|
||||
// @for $i from 0 through 15 {
|
||||
// li:nth-child(#{$i}) {
|
||||
// $width: 15 + random(15) + px;
|
||||
|
||||
// top: 50%;
|
||||
// left: 15 + random(70) + px;
|
||||
// width: $width;
|
||||
// height: $width;
|
||||
// transform: translate(-50%, -50%);
|
||||
// animation: ~'moveToTop `Math.random(6) + 3`s ease-in-out -`Math.random(5000) / 1000`s infinite';
|
||||
// }
|
||||
// }
|
||||
|
||||
@keyframes trotate {
|
||||
50% {
|
||||
border-radius: 45% / 42% 38% 58% 49%;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-50%, -50%) rotate(720deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes move-to-top {
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-50%, -180px);
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hue-rotate {
|
||||
100% {
|
||||
filter: contrast(15) hue-rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
position: absolute;
|
||||
bottom: 20vh;
|
||||
left: 50vw;
|
||||
width: 300px;
|
||||
height: 400px;
|
||||
transform: translateX(-50%);
|
||||
.generate-columns(@n, @i: 0) when (@i =< @n) {
|
||||
.generate-columns(@n, (@i + 1));
|
||||
.column-@{i} {
|
||||
width: (@i * 100% / @n);
|
||||
}
|
||||
li:nth-child(@{i}) {
|
||||
@width: unit(~`Math.round(15 + Math.random() * 15) `, px);
|
||||
|
||||
top: 50%;
|
||||
left: unit(~`Math.round(Math.random() * 70) `, px);
|
||||
width: @width;
|
||||
height: @width;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: move-to-top unit(~`(Math.round(Math.random() * 6) + 3) `, s) ease-in-out
|
||||
unit(~`-(Math.random() * 5000 / 1000) `, s) infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.number {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 27%;
|
||||
width: 300px;
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.contrast {
|
||||
width: 300px;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
animation: hue-rotate 10s infinite linear;
|
||||
background-color: #000;
|
||||
filter: contrast(15) hue-rotate(0);
|
||||
|
||||
.circle {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
filter: blur(8px);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
left: 50%;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
transform: translate(-50%, -50%) rotate(0);
|
||||
animation: trotate 10s infinite linear;
|
||||
border-radius: 42% 38% 62% 49% / 45%;
|
||||
background-color: #00ff6f;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 40%;
|
||||
left: 50%;
|
||||
width: 176px;
|
||||
height: 176px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background-color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.bubbles {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
transform: translate(-50%, 0);
|
||||
border-radius: 100px 100px 0 0;
|
||||
background-color: #00ff6f;
|
||||
filter: blur(5px);
|
||||
|
||||
li {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: #00ff6f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.charging {
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
apps/platform/src/components/basic/lockscreen/index.ts
Normal file
3
apps/platform/src/components/basic/lockscreen/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import LockScreen from './index.vue';
|
||||
|
||||
export { LockScreen };
|
||||
42
apps/platform/src/components/basic/lockscreen/index.vue
Normal file
42
apps/platform/src/components/basic/lockscreen/index.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<transition name="slide-up">
|
||||
<LockScreenPage v-if="isLock && isMouted && $route.name != LOGIN_NAME" />
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import LockScreenPage from './lockscreen-page.vue';
|
||||
import { useLockscreenStore } from '@/store/modules/lockscreen';
|
||||
import { LOGIN_NAME } from '@/router/constant';
|
||||
|
||||
const lockscreenStore = useLockscreenStore();
|
||||
const isLock = computed(() => lockscreenStore.isLock);
|
||||
const isMouted = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
isMouted.value = true;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.slide-up-enter-active {
|
||||
animation: slide-up 0.5s;
|
||||
}
|
||||
|
||||
.slide-up-leave-active {
|
||||
animation: slide-up 0.5s reverse;
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
0% {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{ unLockLogin: isShowForm }"
|
||||
class="lockscreen"
|
||||
@keyup="isShowForm = true"
|
||||
@mousedown.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<template v-if="!isShowForm">
|
||||
<div class="lock-box">
|
||||
<div class="lock">
|
||||
<span class="lock-icon" title="解锁屏幕" @click="isShowForm = true">
|
||||
<Icon icon="ant-design:lock-outlined" size="30" />
|
||||
</span>
|
||||
</div>
|
||||
<h6 class="tips">点击解锁</h6>
|
||||
</div>
|
||||
<!-- 小米 / 华为 充电-->
|
||||
<component
|
||||
:is="BatteryComp"
|
||||
:battery="battery"
|
||||
:battery-status="batteryStatus"
|
||||
:calc-discharging-time="calcDischargingTime"
|
||||
/>
|
||||
<div class="local-time">
|
||||
<div class="time">{{ hour }}:{{ minute }}</div>
|
||||
<div class="date">{{ month }}月{{ day }}号,星期{{ week }}</div>
|
||||
</div>
|
||||
<div class="computer-status">
|
||||
<span :class="{ offline: !online }" class="network">
|
||||
<Icon icon="ant-design:wifi-outlined" size="30" class="network" />
|
||||
</span>
|
||||
<Icon icon="ant-design:api-outlined" size="30" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="login-box">
|
||||
<Avatar :size="80" :src="userStore.userInfo.avatar">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:user-outlined" size="50" />
|
||||
</template>
|
||||
</Avatar>
|
||||
<div class="username">{{ userStore.userInfo.username }}</div>
|
||||
<a-input-password v-model:value="password" autofocus :placeholder="pwdPlaceholder" />
|
||||
<div class="flex justify-between w-full">
|
||||
<template v-if="lockscreenStore.lockPwd">
|
||||
<a-button type="link" size="small" @click="hideLockForm">返回</a-button>
|
||||
<a-button type="link" size="small" @click="nav2login">返回登录</a-button>
|
||||
<a-button type="link" size="small" @click="onLogin">进入系统</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-button type="link" size="small" @click="cancelLock">取消锁屏</a-button>
|
||||
<a-button type="link" size="small" @click="lockScreen">确定锁屏</a-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, ref, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { Avatar, message } from 'ant-design-vue';
|
||||
import { useOnline } from '@/hooks/useOnline';
|
||||
import { useTime } from '@/hooks/useTime';
|
||||
import { useBattery } from '@/hooks/useBattery';
|
||||
import { useLockscreenStore } from '@/store/modules/lockscreen';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { LOGIN_NAME } from '@/router/constant';
|
||||
import { Icon } from '@/components/basic/icon';
|
||||
|
||||
const lockscreenStore = useLockscreenStore();
|
||||
const userStore = useUserStore();
|
||||
// const isLock = computed(() => lockscreenStore.isLock);
|
||||
// 获取本地时间
|
||||
const { month, day, hour, minute, week } = useTime();
|
||||
const { online } = useOnline();
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const { battery, batteryStatus, calcDischargingTime } = useBattery();
|
||||
|
||||
const BatteryComp = defineAsyncComponent(() => {
|
||||
return Math.random() > 0.49 ? import('./huawei-charge.vue') : import('./xiaomi-charge.vue');
|
||||
});
|
||||
|
||||
const isShowForm = ref(!lockscreenStore.lockPwd);
|
||||
const password = ref('');
|
||||
|
||||
const pwdPlaceholder = computed(() => {
|
||||
return lockscreenStore.lockPwd ? '请输入锁屏密码或用户密码' : '请输入锁屏密码(可选)';
|
||||
});
|
||||
|
||||
// 登录
|
||||
const onLogin = async () => {
|
||||
const pwd = password.value.trim();
|
||||
|
||||
if (pwd === '') return message.warn('密码不能为空');
|
||||
|
||||
if (lockscreenStore.verifyLockPwd(pwd)) {
|
||||
unlockScreen();
|
||||
} else {
|
||||
return message.warn('密码错误,请重新输入');
|
||||
}
|
||||
};
|
||||
|
||||
/** 隐藏锁屏输入表单 */
|
||||
const hideLockForm = () => {
|
||||
isShowForm.value = false;
|
||||
password.value = '';
|
||||
};
|
||||
|
||||
/** 取消锁屏 */
|
||||
const cancelLock = () => {
|
||||
isShowForm.value = false;
|
||||
lockscreenStore.setLock(false);
|
||||
};
|
||||
|
||||
// 确定锁屏
|
||||
const lockScreen = () => {
|
||||
const pwd = password.value.trim();
|
||||
lockscreenStore.setLockPwd(pwd);
|
||||
hideLockForm();
|
||||
};
|
||||
|
||||
// 取消锁屏/解锁锁屏
|
||||
const unlockScreen = () => {
|
||||
isShowForm.value = false;
|
||||
lockscreenStore.setLock(false);
|
||||
};
|
||||
// 输入密码 锁屏
|
||||
|
||||
const nav2login = () => {
|
||||
isShowForm.value = false;
|
||||
lockscreenStore.setLock(false);
|
||||
userStore.clearLoginStatus();
|
||||
router.replace({
|
||||
name: LOGIN_NAME,
|
||||
query: {
|
||||
redirect: route.fullPath,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.lockscreen {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: white;
|
||||
|
||||
&.unLockLogin {
|
||||
background-color: rgb(25 28 34 / 78%);
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
.setting-box,
|
||||
.login-box {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 45%;
|
||||
left: 50%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 260px;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
> * {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.lock-box {
|
||||
position: absolute;
|
||||
top: 12vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 34px;
|
||||
|
||||
.tips {
|
||||
color: white;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.lock {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.lock-icon {
|
||||
cursor: pointer;
|
||||
|
||||
.anticon-unlock {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover .anticon-unlock {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
&:hover .anticon-lock {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.local-time {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 60px;
|
||||
font-family: helvetica;
|
||||
|
||||
.time {
|
||||
font-size: 70px;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.computer-status {
|
||||
position: absolute;
|
||||
right: 60px;
|
||||
bottom: 60px;
|
||||
font-size: 24px;
|
||||
|
||||
> * {
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.network {
|
||||
position: relative;
|
||||
|
||||
&.offline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: 28px;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
background-color: red;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
272
apps/platform/src/components/basic/lockscreen/xiaomi-charge.vue
Normal file
272
apps/platform/src/components/basic/lockscreen/xiaomi-charge.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="xiaomi-charge">
|
||||
<div v-for="i in 3" :key="i" class="outer">
|
||||
<div class="circle" :style="{ transform: `scale(${1.01 - 0.04 * (i - 1)})` }" />
|
||||
</div>
|
||||
<div class="line-box">
|
||||
<div class="line-left" />
|
||||
<div class="line-left line-right" />
|
||||
<div class="line-center line-center-left-2" />
|
||||
<div class="line-center line-center-left-1" />
|
||||
<div class="line-center" />
|
||||
<div class="line-center line-center-right-1" />
|
||||
<div class="line-center line-center-right-2" />
|
||||
</div>
|
||||
<div class="outer" style="transform: scale(0.68)">
|
||||
<div class="circle circle-blur" style="padding: 30px" />
|
||||
</div>
|
||||
<div v-for="i in 4" :key="i" class="outer">
|
||||
<div
|
||||
class="circle-white"
|
||||
:style="{
|
||||
transform: `scale(${1 - 0.02 * (i - 1)})`,
|
||||
animationDuration: `${500 - 20 * (i - 1)}ms`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="outer">
|
||||
<div class="text">{{ battery.level.toFixed(0) }}<span class="sub">%</span></div>
|
||||
</div>
|
||||
<div class="light" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import type { Battery } from '@/hooks/useBattery';
|
||||
export default defineComponent({
|
||||
name: 'XiaomiCharge',
|
||||
props: {
|
||||
battery: {
|
||||
// 电池对象
|
||||
type: Object as PropType<Battery>,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.xiaomi-charge {
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes up {
|
||||
0% {
|
||||
transform: translateY(80px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(-400px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes light {
|
||||
0% {
|
||||
transform: scale(0.3);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.3);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50vw;
|
||||
justify-content: center;
|
||||
width: 300px;
|
||||
height: 400px;
|
||||
transform: translateX(-50%);
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
width: 286px;
|
||||
height: 286px;
|
||||
padding: 2px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(#c71ff1, #2554ea);
|
||||
}
|
||||
|
||||
.circle::after {
|
||||
content: ' ';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.circle-blur {
|
||||
filter: blur(5px);
|
||||
animation: rotate 5s linear infinite;
|
||||
}
|
||||
|
||||
.circle-white {
|
||||
position: absolute;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
animation: rotate 500ms linear infinite;
|
||||
border-top: solid 1px rgb(255 255 255 / 6%);
|
||||
border-bottom: solid 1px rgb(255 255 255 / 8%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.outer {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 400px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.line-box {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 80px;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.line-left {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: -15px;
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 267px;
|
||||
border-top: solid 2px #2554ea;
|
||||
border-right: solid 2px #2554ea;
|
||||
border-top-right-radius: 40px;
|
||||
}
|
||||
|
||||
.line-left::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
transform: scaleY(0.96);
|
||||
transform-origin: center top;
|
||||
border-top: solid 2px #2554ea;
|
||||
border-right: solid 2px #2554ea;
|
||||
border-top-right-radius: 50px;
|
||||
}
|
||||
|
||||
.line-left::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
transform: scaleY(0.92);
|
||||
transform-origin: center top;
|
||||
border-top: solid 2px #2554ea;
|
||||
border-right: solid 2px #2554ea;
|
||||
border-top-right-radius: 60px;
|
||||
}
|
||||
|
||||
.line-right {
|
||||
transform: scaleX(-1);
|
||||
transform-origin: 55px;
|
||||
}
|
||||
|
||||
.line-center {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 39px;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: #231779;
|
||||
}
|
||||
|
||||
.line-center::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
width: 2px;
|
||||
height: 80px;
|
||||
animation: up 700ms linear infinite;
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
background: linear-gradient(#79ccea, transparent);
|
||||
}
|
||||
|
||||
.line-center-left-1 {
|
||||
transform: translateX(-9px);
|
||||
}
|
||||
|
||||
.line-center-left-2 {
|
||||
transform: translateX(-18px);
|
||||
}
|
||||
|
||||
.line-center-right-1 {
|
||||
transform: translateX(9px);
|
||||
}
|
||||
|
||||
.line-center-right-2 {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.line-center-left-1::before {
|
||||
animation-delay: -200ms;
|
||||
}
|
||||
|
||||
.line-center-left-2::before {
|
||||
animation-delay: -400ms;
|
||||
}
|
||||
|
||||
.line-center-right-1::before {
|
||||
animation-delay: -300ms;
|
||||
}
|
||||
|
||||
.line-center-right-2::before {
|
||||
animation-delay: -500ms;
|
||||
}
|
||||
|
||||
.text {
|
||||
position: absolute;
|
||||
width: 200px;
|
||||
height: 80px;
|
||||
color: turquoise;
|
||||
font-size: 70px;
|
||||
line-height: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.light {
|
||||
position: absolute;
|
||||
bottom: -150px;
|
||||
width: 300px;
|
||||
height: 350px;
|
||||
animation: light 1.2s linear 1 forwards;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(#2554ea, transparent 60%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<object :key="url" :data="url" :type="type" width="100%" height="100%" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'preview-resource',
|
||||
});
|
||||
|
||||
defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
},
|
||||
});
|
||||
|
||||
// const allowTypes = [
|
||||
// 'image/',
|
||||
// 'video/',
|
||||
// 'audio/',
|
||||
// 'text/',
|
||||
// '/xml',
|
||||
// '/json',
|
||||
// '/javascript',
|
||||
// '/pdf',
|
||||
// ];
|
||||
|
||||
// const sandbox = computed(() => {
|
||||
// const isAllowType = allowTypes.some((n) => props.type?.includes(n));
|
||||
// if (isAllowType) {
|
||||
// return '';
|
||||
// }
|
||||
// return 'allow-downloads: false';
|
||||
// });
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { configProviderProps } from 'ant-design-vue/es/config-provider/context';
|
||||
import { merge } from 'lodash-es';
|
||||
import { ConfigProvider } from 'ant-design-vue';
|
||||
import { useLocale } from '@/locales/useLocale';
|
||||
import { useLayoutSettingStore } from '@/store/modules/layoutSetting';
|
||||
|
||||
defineOptions({
|
||||
name: 'ProConfigProvider',
|
||||
});
|
||||
|
||||
const props = defineProps(configProviderProps());
|
||||
|
||||
const layoutSetting = useLayoutSettingStore();
|
||||
const { getAntdLocale } = useLocale();
|
||||
|
||||
const theme = computed(() => {
|
||||
return merge({}, layoutSetting.themeConfig, props.theme);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfigProvider v-bind="$props" :locale="getAntdLocale" :theme="theme">
|
||||
<slot />
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
34
apps/platform/src/components/basic/progress/index.vue
Normal file
34
apps/platform/src/components/basic/progress/index.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Progress v-bind="myProps" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { progressProps, type ProgressProps } from 'ant-design-vue/es/progress/props';
|
||||
import { Progress } from 'ant-design-vue';
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
type StrokeColorType = ProgressProps['strokeColor'];
|
||||
type StrokeColorFn = (percent) => StrokeColorType;
|
||||
|
||||
const props = defineProps({
|
||||
...progressProps(),
|
||||
strokeColor: {
|
||||
type: [String, Object, Function] as PropType<StrokeColorType | StrokeColorFn>,
|
||||
},
|
||||
});
|
||||
|
||||
const myProps = computed(() => {
|
||||
if (typeof props.strokeColor === 'function') {
|
||||
return {
|
||||
...props,
|
||||
strokeColor: props.strokeColor(props.percent),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...props,
|
||||
strokeColor: props.strokeColor as StrokeColorType,
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
3
apps/platform/src/components/basic/split-panel/index.ts
Normal file
3
apps/platform/src/components/basic/split-panel/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import SplitPanel from './index.vue';
|
||||
|
||||
export { SplitPanel };
|
||||
104
apps/platform/src/components/basic/split-panel/index.vue
Normal file
104
apps/platform/src/components/basic/split-panel/index.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="split-wrapper">
|
||||
<div ref="scalable" class="scalable">
|
||||
<div class="left-content">
|
||||
<slot name="left-content"> 右边内容区 </slot>
|
||||
</div>
|
||||
<div ref="separator" class="separator" @mousedown="startDrag"><i /><i /></div>
|
||||
</div>
|
||||
<div class="right-content">
|
||||
<slot name="right-content"> 右边内容区 </slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { throttle } from 'lodash-es';
|
||||
|
||||
const scalable = ref<HTMLDivElement>();
|
||||
|
||||
let startX: number;
|
||||
let startWidth: number;
|
||||
|
||||
// 拖拽中
|
||||
// @throttle(20)
|
||||
const onDrag = throttle(function (e: MouseEvent) {
|
||||
scalable.value && (scalable.value.style.width = `${startWidth + e.clientX - startX}px`);
|
||||
}, 20);
|
||||
|
||||
// 拖拽结束
|
||||
const dragEnd = () => {
|
||||
document.documentElement.style.userSelect = 'unset';
|
||||
document.documentElement.removeEventListener('mousemove', onDrag);
|
||||
document.documentElement.removeEventListener('mouseup', dragEnd);
|
||||
};
|
||||
|
||||
// 鼠标按下
|
||||
const startDrag = (e: MouseEvent) => {
|
||||
startX = e.clientX;
|
||||
scalable.value && (startWidth = parseInt(window.getComputedStyle(scalable.value).width, 10));
|
||||
|
||||
document.documentElement.style.userSelect = 'none';
|
||||
document.documentElement.addEventListener('mousemove', onDrag);
|
||||
document.documentElement.addEventListener('mouseup', dragEnd);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@import '@/styles/theme.less';
|
||||
|
||||
@classNames: split-wrapper, separator;
|
||||
.themeBgColor(@classNames);
|
||||
|
||||
.split-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.scalable {
|
||||
position: relative;
|
||||
width: 240px;
|
||||
min-width: 100px;
|
||||
max-width: 50vw;
|
||||
overflow: auto;
|
||||
|
||||
.left-content {
|
||||
height: 100%;
|
||||
padding: 12px 20px 12px 12px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
box-shadow:
|
||||
-4px -2px 4px -5px rgb(0 0 0 / 35%),
|
||||
4px 3px 4px -5px rgb(0 0 0 / 35%);
|
||||
cursor: col-resize;
|
||||
|
||||
i {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
margin: 0 1px;
|
||||
background-color: #e9e9e9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.left-content,
|
||||
.right-content {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
apps/platform/src/components/basic/title-i18n/index.ts
Normal file
1
apps/platform/src/components/basic/title-i18n/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as TitleI18n } from './index.vue';
|
||||
26
apps/platform/src/components/basic/title-i18n/index.vue
Normal file
26
apps/platform/src/components/basic/title-i18n/index.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<i18n-t tag="span" :keypath="getTitle" scope="global" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type PropType, computed } from 'vue';
|
||||
import { useLocaleStore } from '@/store/modules/locale';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: [String, Object] as PropType<string | Title18n>,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const localeStore = useLocaleStore();
|
||||
|
||||
const getTitle = computed(() => {
|
||||
const { title = '' } = props;
|
||||
if (typeof title === 'object') {
|
||||
return title?.[localeStore.locale] ?? title;
|
||||
}
|
||||
return title;
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user