refactor: 悦码项目重构

This commit is contained in:
wangxuefeng
2025-02-19 13:42:56 +08:00
parent c8c9406fd5
commit eab709f94f
494 changed files with 50986 additions and 27639 deletions

View File

@@ -0,0 +1,6 @@
### 基础组件(目录说明)
| 组件名称 | 描述 | 是否全局组件 | 使用建议 |
| --- | --- | --- | --- |
| button | `按钮组件`基于 a-button 二次封装,主要扩展了按钮的颜色,基本使用方式与 antdv 的 a-button 保持一致 | 是 | -- |
| check-box | `复选框`基于 a-checkbox 二次封装,基本使用方式与 antdv 的 a-checkbox 保持一致 | 否 | -- |

View File

@@ -0,0 +1 @@
export { default as BasicArrow } from './index.vue';

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

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

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

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

View File

@@ -0,0 +1,9 @@
import AButton from './button.vue';
export default AButton;
export const Button = AButton;
export * from './button';
export { AButton };

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

View File

@@ -0,0 +1,3 @@
export { createContextMenu, destroyContextMenu } from './src/createContextMenu';
export * from './src/typing';

View File

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

View File

@@ -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 = [];
}
};

View File

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

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

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

View File

@@ -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,
};
};

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
import IFramePage from './index.vue';
export default IFramePage;

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

View File

@@ -0,0 +1 @@
export { default as LocalePicker } from './index.vue';

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

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

View File

@@ -0,0 +1,3 @@
import LockScreen from './index.vue';
export { LockScreen };

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

View File

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

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

View File

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

View File

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

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

View File

@@ -0,0 +1,3 @@
import SplitPanel from './index.vue';
export { SplitPanel };

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

View File

@@ -0,0 +1 @@
export { default as TitleI18n } from './index.vue';

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