im 管理后台

This commit is contained in:
richjohnrambo
2026-08-11 23:13:51 +08:00
parent 0397fe5f22
commit 331a55751d
1407 changed files with 162069 additions and 2 deletions
@@ -0,0 +1,40 @@
<template>
<n-loading-bar-provider>
<n-dialog-provider>
<DialogContent />
<n-notification-provider>
<n-message-provider>
<MessageContent />
<slot name="default"></slot>
</n-message-provider>
</n-notification-provider>
</n-dialog-provider>
</n-loading-bar-provider>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import {
NDialogProvider,
NNotificationProvider,
NMessageProvider,
NLoadingBarProvider,
} from 'naive-ui';
import { MessageContent } from '@/components/MessageContent';
import { DialogContent } from '@/components/DialogContent';
export default defineComponent({
name: 'Application',
components: {
NDialogProvider,
NNotificationProvider,
NMessageProvider,
NLoadingBarProvider,
MessageContent,
DialogContent,
},
setup() {
return {};
},
});
</script>
+3
View File
@@ -0,0 +1,3 @@
import AppProvider from './Application.vue';
export { AppProvider };
@@ -0,0 +1,105 @@
<template>
<n-cascader
v-bind="$props"
:value="valueLabel"
:options="dataOptions"
:placeholder="placeholder"
:check-strategy="checkStrategy"
clearable
cascade
:on-update:value="onValueChange"
:on-load="handleLoad"
:on-focus="focusLoad"
remote
/>
</template>
<script lang="ts" setup>
import { GetCityLabel, ProvincesSelect } from '@/api/apply/provinces';
import { computed, ref, watch } from 'vue';
import type { CascaderOption } from 'naive-ui';
const emits = defineEmits(['update:value', 'update:label']);
import { basicProps } from './props';
const props = defineProps({
...basicProps,
});
const valueLabel = ref<string | null>(null);
const dataOptions = ref([]);
const placeholder = computed(() => {
if (props.dataType === 'p') {
return '请选择省份';
} else if (props.dataType === 'pc') {
return '请选择省市';
} else {
return '请选择省市区';
}
});
function onValueChange(
value: string | number | Array<string | number> | null,
option: CascaderOption | Array<CascaderOption | null> | null,
pathValues: Array<CascaderOption | null>
) {
const tempPathValues = pathValues
? pathValues.map((it: CascaderOption | null) => ({
label: it?.label,
value: it?.value,
level: it?.level,
}))
: null;
emits('update:value', value);
valueLabel.value = getLabel(tempPathValues);
}
function getLabel(values): string | null {
if (values === null || values === undefined) {
return null;
}
let label = '';
const length = values.length;
for (let i = 0; i < length; i++) {
const item = values[i];
label += item.label;
if (i + 1 < length) {
label += props.separator;
}
}
return label;
}
watch(
() => props.value,
async () => {
if (!props.value || props.value === 0) {
valueLabel.value = null;
return;
}
if (valueLabel.value === null) {
valueLabel.value = await GetCityLabel({ id: props.value, spilt: props.separator });
}
},
{
immediate: true,
deep: true,
}
);
async function load(option) {
const data = await ProvincesSelect({ dataType: props.dataType, ...option });
return data.list;
}
async function handleLoad(option: CascaderOption) {
option.children = await load({ dataType: props.dataType, ...option });
return;
}
async function focusLoad() {
if (dataOptions.value.length === 0) {
dataOptions.value = await load({ dataType: props.dataType });
}
}
</script>
+22
View File
@@ -0,0 +1,22 @@
import type { PropType } from 'vue';
import { NCascader } from 'naive-ui';
export const basicProps = {
...NCascader.props,
defaultValue: {
type: [Number, String, Array],
default: null,
},
value: {
type: [Number, String, Array],
default: null,
},
dataType: {
type: String as PropType<'p' | 'pc' | 'pca'>,
default: 'pca',
},
checkStrategy: {
type: String as PropType<'child' | 'all'>,
default: 'child',
},
};
@@ -0,0 +1,144 @@
<template>
<n-input-group>
<n-select
v-bind="$props"
:style="{ width: '68%' }"
v-model:value="memberId"
:options="memberOption"
:render-label="renderLabel"
:filter="handleReceiverFilter"
@update:value="handleChangeMemberId"
clearable
filterable
/>
<n-select
:consistent-menu-width="false"
:style="{ width: '32%', 'min-width': '90px' }"
:options="selectOptions"
v-model:value="optVal"
@update:value="handleUpdateOptions"
/>
</n-input-group>
</template>
<script setup lang="ts">
import { h, onMounted, ref, watch } from 'vue';
import { GetMemberOption } from '@/api/org/user';
import { useUserStore } from '@/store/modules/user';
import { NText, SelectRenderLabel } from 'naive-ui';
import { basicProps } from './props';
const props = defineProps({
...basicProps,
});
const emit = defineEmits(['update:value']);
const userStore = useUserStore();
const memberId = ref(null);
const optVal = ref('-1');
const memberOption = ref([]);
const selectOptions = [
{
label: '查全部',
value: '-1',
},
{
label: '查本人',
value: '1',
},
{
label: '查下级',
value: '2',
},
];
const renderLabel: SelectRenderLabel = (option: { username: ''; label: '' }) => {
return h(
'div',
{
style: {
display: 'flex',
alignItems: 'center',
},
},
[
h(
'div',
{
style: {
padding: '4px 0',
display: 'flex',
},
},
[
h(
'div',
{
style: {
marginRight: '4px',
},
},
[option.username as string]
),
h(
NText,
{ depth: 3, tag: 'div' },
{
default: () => option.label as string,
}
),
]
),
]
);
};
function handleReceiverFilter(pattern: string, option: object): boolean {
const isPatternInLabel = option.label.includes(pattern);
const isPatternInUsername = option.username.includes(pattern);
const isValueEqual = option.value.toString() === pattern;
return isPatternInLabel || isPatternInUsername || isValueEqual;
}
function handleChangeMemberId(v: string) {
memberId.value = v;
handleUpdateValue();
}
function handleUpdateOptions(value: string) {
optVal.value = value;
handleUpdateValue();
}
function handleUpdateValue() {
if (memberId.value) {
emit('update:value', [memberId.value, optVal.value]);
} else {
emit('update:value', null);
}
}
function resetFields() {
memberId.value = null;
optVal.value = '-1';
}
watch(
() => props.value,
(v) => {
if (!v) {
resetFields();
}
},
{ deep: true }
);
onMounted(() => {
GetMemberOption().then((res) => {
if (res) {
memberOption.value = res;
}
});
});
</script>
<style scoped lang="less"></style>
@@ -0,0 +1,13 @@
import { NSelect } from 'naive-ui';
export const basicProps = {
...NSelect.props,
defaultValue: {
type: [Array],
default: null,
},
value: {
type: [Array],
default: null,
},
};
+111
View File
@@ -0,0 +1,111 @@
<template>
<span :style="{ color }" v-if="endVal > 0">
{{ value }}
</span>
<span :style="{ color }" v-else> 0 </span>
</template>
<script lang="ts">
import { defineComponent, ref, computed, watchEffect, unref, onMounted, watch } from 'vue';
import { useTransition, TransitionPresets } from '@vueuse/core';
import { isNumber } from '@/utils/is';
const props = {
startVal: { type: Number, default: 0 },
endVal: { type: Number, default: 2021 },
duration: { type: Number, default: 1500 },
autoplay: { type: Boolean, default: true },
decimals: {
type: Number,
default: 0,
validator(value: number) {
return value >= 0;
},
},
prefix: { type: String, default: '' },
suffix: { type: String, default: '' },
separator: { type: String, default: ',' },
decimal: { type: String, default: '.' },
/**
* font color
*/
color: { type: String },
/**
* Turn on digital animation
*/
useEasing: { type: Boolean, default: true },
/**
* Digital animation
*/
transition: { type: String, default: 'linear' },
};
export default defineComponent({
name: 'CountTo',
props,
emits: ['onStarted', 'onFinished'],
setup(props, { emit }) {
const source = ref(props.startVal);
const disabled = ref(false);
let outputValue = useTransition(source);
const value = computed(() => formatNumber(unref(outputValue)));
watchEffect(() => {
source.value = props.startVal;
});
watch([() => props.startVal, () => props.endVal], () => {
if (props.autoplay) {
start();
}
});
onMounted(() => {
props.autoplay && start();
});
function start() {
run();
source.value = props.endVal;
}
function reset() {
source.value = props.startVal;
run();
}
function run() {
outputValue = useTransition(source, {
disabled,
duration: props.duration,
onFinished: () => emit('onFinished'),
onStarted: () => emit('onStarted'),
...(props.useEasing ? { transition: TransitionPresets[props.transition] } : {}),
});
}
function formatNumber(num: number | string) {
if (!num) {
return '';
}
const { decimals, decimal, separator, suffix, prefix } = props;
num = Number(num).toFixed(decimals);
num += '';
const x = num.split('.');
let x1 = x[0];
const x2 = x.length > 1 ? decimal + x[1] : '';
const rgx = /(\d+)(\d{3})/;
if (separator && !isNumber(separator)) {
while (rgx.test(x1)) {
x1 = x1.replace(rgx, '$1' + separator + '$2');
}
}
return prefix + x1 + x2 + suffix;
}
return { value, start, reset };
},
});
</script>
+4
View File
@@ -0,0 +1,4 @@
import { withInstall } from '@/utils';
import countTo from './CountTo.vue';
export const CountTo = withInstall(countTo);
@@ -0,0 +1,99 @@
<template>
<n-date-picker
v-bind="$props"
v-model:value="modelValue"
:shortcuts="showShortcuts ? shortcuts : undefined"
:clearable="true"
style="width: 100%"
/>
</template>
<script lang="ts">
import { computed, defineComponent, onMounted, ref } from 'vue';
import {
dateToTimestamp,
defRangeShortcuts,
defShortcuts,
formatToDate,
formatToDateTime,
timestampToTime,
} from '@/utils/dateUtil';
import { basicProps } from './props';
export default defineComponent({
name: 'DatePicker',
props: {
...basicProps,
},
emits: ['update:formValue', 'update:startValue', 'update:endValue'],
setup(props, { emit }) {
const shortcuts = ref<any>({});
function getTimestamp(value) {
let t = dateToTimestamp(value);
console.log('getTimestamp t:' + t);
if (t === 0) {
return undefined;
}
return t;
}
function setTimestamp(value) {
if (value === undefined) {
return undefined;
}
if (!isTimeType()) {
return formatToDate(new Date(Number(value)).toDateString());
} else {
return formatToDateTime(timestampToTime(Number(value / 1000)));
}
}
function isRangeType() {
return props.type.indexOf('range') != -1;
}
function isTimeType() {
return props.type.indexOf('time') != -1;
}
const modelValue = computed({
get() {
if (!isRangeType()) {
return getTimestamp(props.formValue);
} else {
const value = [getTimestamp(props.startValue), getTimestamp(props.endValue)];
if (!value[0] && !value[1]) {
return null;
}
return value;
}
},
set(value) {
if (!isRangeType()) {
emit('update:formValue', setTimestamp(value));
} else {
emit('update:startValue', setTimestamp(value[0]));
emit('update:endValue', setTimestamp(value[1]));
}
},
});
onMounted(async () => {
if (!isRangeType()) {
shortcuts.value = defShortcuts();
} else {
shortcuts.value = defRangeShortcuts();
}
});
return {
modelValue,
shortcuts,
showShortcuts: props.showShortcuts,
};
},
});
</script>
<style lang="less"></style>
+22
View File
@@ -0,0 +1,22 @@
import type { PropType } from 'vue';
import { NDatePicker } from 'naive-ui';
export const basicProps = {
...NDatePicker.props,
formValue: {
type: String as PropType<string> | undefined | Date,
default: () => '',
},
startValue: {
type: String as PropType<string> | undefined | Date,
default: () => '',
},
endValue: {
type: String as PropType<string> | undefined | Date,
default: () => '',
},
showShortcuts: {
type: Boolean as PropType<boolean>,
default: () => true,
},
};
@@ -0,0 +1,3 @@
import DialogContent from './index.vue';
export { DialogContent };
@@ -0,0 +1,12 @@
<template></template>
<script lang="ts">
import { useDialog } from 'naive-ui';
export default {
name: 'DialogContent',
setup() {
//挂载在 window 方便与在js中使用
window['$dialog'] = useDialog();
},
};
</script>
+150
View File
@@ -0,0 +1,150 @@
<template>
<QuillEditor
ref="quillEditorRef"
toolbar="full"
v-model:content="content"
@ready="readyQuill"
class="quillEditor"
:id="id"
:modules="modules"
@focus="onEditorFocus"
@blur="onEditorBlur"
@update:content="onUpdateContent"
/>
</template>
<script lang="ts" setup>
import { ref, watch, onMounted } from 'vue';
import { QuillEditor } from '@vueup/vue-quill';
import '@vueup/vue-quill/dist/vue-quill.snow.css';
import ImageUploader from 'quill-image-uploader';
import MagicUrl from 'quill-magic-url';
import { getRandomString } from '@/utils/charset';
import { UploadImage } from '@/api/base';
import componentSetting from '@/settings/componentSetting';
import { isNullOrUnDef } from '@/utils/is';
import { useMessage } from 'naive-ui';
export interface Props {
value: string;
id?: string;
}
const emit = defineEmits(['update:value']);
const message = useMessage();
const initFinish = ref(false);
const quillEditorRef = ref();
const content = ref();
const props = withDefaults(defineProps<Props>(), {
value: '',
id: 'quillEditorId-' + getRandomString(16, true),
});
function readyQuill() {
quillEditorRef.value.setHTML(props.value);
}
watch(
() => props.value,
(newValue) => {
if (!initFinish.value) {
quillEditorRef.value?.setHTML(newValue);
}
},
{
immediate: true,
deep: true,
}
);
function onEditorFocus(val) {
initFinish.value = true;
console.log(val);
}
function onEditorBlur(val) {
console.log(val);
}
function onUpdateContent() {
emit('update:value', quillEditorRef.value.getHTML());
}
function checkFileType(map: string[], fileType: string) {
if (isNullOrUnDef(map)) {
return true;
}
return map.includes(fileType);
}
onMounted(async () => {
// 兼容表单分组 n-form-item-blank
let dom = document.getElementById(props.id);
if (dom && dom.parentNode) {
const parent = dom.parentNode as Element;
if ('n-form-item-blank' === parent.className) {
parent.setAttribute('style', 'display: block;');
}
}
});
const modules = [
{
name: 'imageUploader',
module: ImageUploader,
options: {
upload: (file) => {
return new Promise((resolve, reject) => {
if (!checkFileType(componentSetting.upload.imageType, file.type)) {
message.error(`只能上传图片类型为${componentSetting.upload.imageType.join(',')}`);
reject('Upload failed');
return;
}
const formData = new FormData();
formData.append('file', file);
UploadImage(formData)
.then((res) => {
console.log(res);
resolve(res.fileUrl);
})
.catch((err) => {
reject('Upload failed');
console.error('Error:', err);
});
});
},
},
},
{
name: 'magicUrl',
module: MagicUrl,
},
];
</script>
<style lang="less" scoped>
:deep(.ql-container) {
height: auto;
}
:deep(.ql-container.ql-snow) {
border: none;
}
:deep(.ql-toolbar.ql-snow) {
border: none;
border-bottom: 1px solid #ccc;
}
:deep(.ql-editor.ql-blank::before) {
color: #afb4bd;
font-size: 14px;
font-style: normal;
}
.dark .priview-content {
background: #5a5a5a;
color: #fff;
}
.light .priview-content {
background: #fff;
color: #333;
}
</style>
+366
View File
@@ -0,0 +1,366 @@
<template>
<n-space vertical>
<n-button @click="showModal = true" size="small">
<template #icon
><n-icon><PlusOutlined /></n-icon> </template
>选择{{ buttonText }}
</n-button>
<div class="w-full">
<div class="upload">
<div class="upload-card" v-if="fileList.length > 0">
<div
class="upload-card-item"
:style="getCSSProperties"
v-for="(item, index) in fileList"
:key="`img_${index}`"
>
<div class="upload-card-item-info">
<div class="img-box">
<template v-if="fileType === 'image'">
<img :src="item" @error="errorImg($event)" alt="" />
</template>
<template v-else>
<n-avatar :style="fileAvatarCSS">{{ getFileExt(item) }}</n-avatar>
</template>
</div>
<div class="img-box-actions">
<template v-if="fileType === 'image'">
<n-icon size="18" class="mx-2 action-icon" @click="handlePreview(item)">
<EyeOutlined />
</n-icon>
</template>
<template v-else>
<n-icon size="18" class="mx-2 action-icon" @click="handleDownload(item)">
<CloudDownloadOutlined />
</n-icon>
</template>
<n-icon size="18" class="mx-2 action-icon" @click="handleRemove(index)">
<DeleteOutlined />
</n-icon>
</div>
</div>
</div> </div></div
></div>
</n-space>
<n-modal
v-model:show="showModal"
:on-after-leave="handleCancel"
:style="{
width: dialogWidth,
}"
>
<n-card title="文件选择">
<template #header-extra>
<n-space>
<n-button @click="handleUpload" ghost>
<template #icon>
<n-icon :component="UploadOutlined" />
</template>
上传文件
</n-button>
</n-space>
</template>
<n-card style="overflow: auto" content-style="padding: 0;">
<Chooser
ref="chooserRef"
:file-type="fileType"
:maxNumber="maxNumber"
:fileList="fileList"
@saveChange="saveChange"
/>
</n-card>
<template #footer>
<n-space justify="end">
<n-button @click="handleCancel"> 取消 </n-button>
<n-button type="primary" @click="handleSelectFile"> 确定 </n-button>
</n-space>
</template>
</n-card>
</n-modal>
<FileUpload
ref="fileUploadRef"
:width="dialogWidth"
:finish-call="handleFinishCall"
:max-upload="20"
/>
<Preview ref="previewRef" />
</template>
<script lang="ts" setup>
import { NButton, NSpace, NCard, NModal, NIcon, useDialog } from 'naive-ui';
import { cloneDeep } from 'lodash-es';
import FileUpload from '@/components/FileChooser/src/Upload.vue';
import Chooser from '@/components/FileChooser/src/Chooser.vue';
import Preview from '@/components/FileChooser/src/Preview.vue';
import { computed, onMounted, ref, watch } from 'vue';
import { adaModalWidth, errorImg } from '@/utils/hotgo';
import { getFileExt } from '@/utils/urlUtils';
import {
UploadOutlined,
PlusOutlined,
CloudDownloadOutlined,
DeleteOutlined,
EyeOutlined,
} from '@vicons/antd';
import { Attachment, FileType, getFileType } from '@/components/FileChooser/src/model';
import { isArrayString, isString } from '@/utils/is';
export interface Props {
value: string | string[] | null;
maxNumber?: number;
fileType?: FileType;
width?: number;
height?: number;
}
const props = withDefaults(defineProps<Props>(), {
value: '',
maxNumber: 1,
fileType: 'default',
width: 100,
height: 100,
});
const emit = defineEmits(['update:value']);
const fileUploadRef = ref();
const dialog = useDialog();
const showModal = ref(false);
const chooserRef = ref();
const previewRef = ref();
const fileList = ref<string[]>([]);
const dialogWidth = computed(() => {
return adaModalWidth(1080);
});
const getCSSProperties = computed(() => {
return {
width: `${props.width}px`,
height: `${props.height}px`,
};
});
const fileAvatarCSS = computed(() => {
return {
'--n-merged-size': `var(--n-avatar-size-override, ${props.width * 0.8}px)`,
'--n-font-size': `18px`,
};
});
const buttonText = computed(() => {
return getFileType(props.fileType);
});
// 预览
function handlePreview(url: string) {
previewRef.value.openPreview(url);
}
// 下载
function handleDownload(url: string) {
window.open(url);
}
// 删除
function handleRemove(index: number) {
dialog.info({
title: '提示',
content: '你确定要删除吗?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
fileList.value.splice(index, 1);
if (props.maxNumber === 1) {
emit('update:value', '');
} else {
emit('update:value', fileList.value);
}
},
onNegativeClick: () => {},
});
}
function handleSelectFile() {
showModal.value = false;
if (props.maxNumber === 1) {
emit('update:value', fileList.value.length > 0 ? fileList.value[0] : '');
} else {
emit('update:value', fileList.value);
}
}
function handleCancel() {
showModal.value = false;
loadImage();
}
function handleUpload() {
fileUploadRef.value.openModal();
}
function handleFinishCall(result: Attachment, success: boolean) {
if (success) {
chooserRef.value.reloadTable();
}
}
function saveChange(list: string[]) {
fileList.value = list;
}
function loadImage() {
const value = cloneDeep(props.value);
if (props.maxNumber === 1) {
fileList.value = [];
if (value !== '') {
if (!isString(value)) {
console.warn(
'When the file picker is currently in single-file mode, but the passed value is not of type string, there may be potential issues.'
);
}
fileList.value.push(value as string);
}
} else {
if (!isArrayString(value)) {
console.warn(
'When the file picker is currently in multiple-file mode, but the passed value is not of type string array, there may be potential issues.'
);
}
if (!value) {
fileList.value = [];
} else {
fileList.value = value as string[];
}
}
}
watch(
() => props.value,
() => {
loadImage();
},
{
immediate: true,
deep: true,
}
);
onMounted(async () => {
loadImage();
});
</script>
<style lang="less">
.n-upload {
width: auto; /** 居中 */
}
.upload {
width: 100%;
overflow: hidden;
&-card {
width: auto;
height: auto;
display: flex;
flex-wrap: wrap;
align-items: center;
&-item {
margin: 0 8px 8px 0;
position: relative;
padding: 8px;
border: 1px solid #d9d9d9;
border-radius: 2px;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
&:hover {
background: 0 0;
.upload-card-item-info::before {
opacity: 1;
}
&-info::before {
opacity: 1;
}
}
&-info {
position: relative;
height: 100%;
padding: 0;
overflow: hidden;
&:hover {
.img-box-actions {
opacity: 1;
}
}
&::before {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: all 0.3s;
content: ' ';
}
.img-box {
position: relative;
//padding: 8px;
//border: 1px solid #d9d9d9;
border-radius: 2px;
}
.img-box-actions {
position: absolute;
top: 50%;
left: 50%;
z-index: 10;
white-space: nowrap;
transform: translate(-50%, -50%);
opacity: 0;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: space-between;
&:hover {
background: 0 0;
}
.action-icon {
color: rgba(255, 255, 255, 0.85);
&:hover {
cursor: pointer;
color: #fff;
}
}
}
}
}
&-item-select-picture {
border: 1px dashed #d9d9d9;
border-radius: 2px;
cursor: pointer;
background: #fafafa;
color: #666;
.upload-title {
color: #666;
}
}
}
}
</style>
@@ -0,0 +1,413 @@
<template>
<n-grid :x-gap="5" :y-gap="5" :cols="pageGridCols" responsive="screen">
<n-gi :span="2">
<n-menu
:options="generateKindOptions()"
style="width: 100%"
:default-value="defaultKindValue"
:on-update:value="handleUpdateKind"
/>
</n-gi>
<n-gi :span="8">
<n-spin style="height: 100%" :show="loading">
<n-layout style="height: 100%" content-style="display:flex;flex-direction: column;">
<n-layout-header>
<BasicForm
style="padding-top: 10px; box-sizing: border-box"
@register="register"
@submit="reloadTable"
@reset="reloadTable"
@keyup.enter="reloadTable"
ref="searchFormRef"
>
<template #statusSlot="{ model, field }">
<n-input v-model:value="model[field]" />
</template>
</BasicForm>
</n-layout-header>
<n-empty v-if="isEmptyDataSource()" description="无数据" />
<n-layout-content style="padding: 5px; box-sizing: border-box">
<n-grid :cols="imageGridCols" x-gap="15" y-gap="15" responsive="screen">
<n-grid-item
v-for="item in dataSource"
:key="item.id"
:class="{ imageActive: isSelected(item) }"
>
<n-card
size="small"
hoverable
content-style="padding: 3px;"
footer-style="padding: 0"
style="overflow: hidden"
bordered
>
<div @click="handleSelect(item)">
<n-image
v-if="item.kind === 'image'"
preview-disabled
class="image-size"
:src="item.fileUrl"
:on-error="errorImg"
/>
<n-avatar v-else class="image-size">
<span style="font-size: 24px"> {{ getFileExt(item.fileUrl) }}</span>
</n-avatar>
</div>
<template #footer>
<n-ellipsis style="padding-left: 5px">
{{ item.name }}
</n-ellipsis>
</template>
<template #action>
<n-space justify="center" style="padding: 5px">
<n-button
strong
secondary
size="tiny"
type="primary"
@click="item.kind === 'image' ? handlePreview(item) : handleDown(item)"
>
<template #icon>
<n-icon>
<EyeOutlined v-if="item.kind === 'image'" />
<DownloadOutlined v-else />
</n-icon>
</template>
{{ item.kind === 'image' ? '预览' : '下载' }}
</n-button>
<n-button
strong
secondary
size="tiny"
type="error"
@click="handleDelete(item)"
>
<template #icon>
<n-icon>
<DeleteOutlined />
</n-icon>
</template>
删除
</n-button>
</n-space>
</template>
</n-card>
</n-grid-item>
</n-grid>
</n-layout-content>
<n-space
v-if="!isEmptyDataSource()"
justify="end"
align="center"
style="box-sizing: border-box; padding: 5px; margin: 0"
>
<n-pagination
v-model:page="params.page"
v-model:page-size="params.pageSize"
:page-count="params.pageCount"
:page-slot="pageSlot"
:page-sizes="[10, 20, 30, 40]"
:on-update:page="onUpdatePage"
:on-update:page-size="onUpdatePageSize"
show-size-picker
show-quick-jumper
/>
</n-space>
</n-layout>
</n-spin>
</n-gi>
</n-grid>
<Preview ref="previewRef" />
</template>
<script lang="ts" setup>
import { NSpace, NInput, NButton, useMessage, NEllipsis, useDialog } from 'naive-ui';
import { onMounted, ref, h, computed } from 'vue';
import { BasicForm, FormSchema, useForm } from '@/components/Form';
import { defRangeShortcuts } from '@/utils/dateUtil';
import { EyeOutlined, DeleteOutlined, DownloadOutlined, ClearOutlined } from '@vicons/antd';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { ClearKind, Delete, List } from '@/api/apply/attachment';
import { constantRouterIcon } from '@/router/router-icons';
import { errorImg } from '@/utils/hotgo';
import { getFileExt } from '@/utils/urlUtils';
import { renderIcon } from '@/utils';
import { Attachment, FileType, KindOption } from './model';
import Preview from './Preview.vue';
import { VNode } from 'vue';
import { Option, useDictStore } from '@/store/modules/dict';
export interface Props {
fileList: string[] | null;
maxNumber?: number;
fileType?: FileType;
}
const props = withDefaults(defineProps<Props>(), {
fileList: null,
maxNumber: 1,
fileType: 'default',
});
const emit = defineEmits(['saveChange']);
const dict = useDictStore();
const settingStore = useProjectSettingStore();
const message = useMessage();
const dialog = useDialog();
const selectList = ref(props.fileList);
const loading = ref(false);
const previewRef = ref();
const searchFormRef = ref<any>();
const dataSource = ref<Attachment[]>([]);
const defaultKindValue = computed(() => {
if (props.fileType === 'default') {
return '';
}
return props.fileType;
});
const pageSlot = computed(() => (settingStore.isMobile ? 3 : 10));
const imageGridCols = computed(() =>
settingStore.isMobile ? '2 s:1 m:2 l:2 xl:2 2xl:3' : '5 s:3 m:4 l:5 xl:5 2xl:6'
);
const pageGridCols = computed(() =>
settingStore.isMobile ? '1' : '10 s:1 m:1 l:10 xl:10 2xl:10'
);
const options = ref({
drive: [],
kind: [],
});
const schemas = ref<FormSchema[]>([
{
field: 'drive',
component: 'NSelect',
label: '',
defaultValue: null,
componentProps: {
placeholder: '请选择上传驱动',
options: dict.getOption('config_upload_drive'),
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
{
field: 'name',
component: 'NInput',
label: '',
defaultValue: null,
componentProps: {
placeholder: '请输入文件名称',
options: options.value.drive,
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
{
field: 'updatedAt',
component: 'NDatePicker',
label: '',
componentProps: {
type: 'datetimerange',
clearable: true,
shortcuts: defRangeShortcuts(),
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
]);
const [register, {}] = useForm({
gridProps: { cols: '1 s:1 m:2 l:2 xl:3 2xl:3' },
labelWidth: 80,
schemas,
});
const params = ref({
kind: defaultKindValue.value,
page: 1,
pageSize: 10,
pageCount: 0,
});
function handleDelete(item: Attachment) {
dialog.warning({
title: '警告',
content: '你确定要删除?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
Delete({ id: item.id }).then((_res) => {
message.success('操作成功');
reloadTable();
});
},
});
}
function handlePreview(item: Attachment) {
previewRef.value.openPreview(item.fileUrl);
}
function handleDown(item: Attachment) {
window.open(item.fileUrl);
}
function handleSelect(item: Attachment) {
if (!selectList.value || props.maxNumber == 1) {
selectList.value = [];
}
const index = selectList.value.findIndex((selected) => selected === item.fileUrl);
if (index === -1) {
if (selectList.value.length >= props.maxNumber) {
message.error('已达最大允许选择上限' + props.maxNumber + '个');
return;
}
selectList.value.push(item.fileUrl);
} else {
selectList.value.splice(index, 1);
}
emit('saveChange', selectList.value);
}
function handleUpdateKind(value: string) {
params.value.page = 1;
params.value.kind = value;
loadList();
}
function isSelected(item: Attachment) {
if (!selectList.value) {
return false;
}
return selectList.value.some((selected) => selected === item.fileUrl);
}
function generateKindOptions(): any {
const option: KindOption[] = [];
dict.getOptionUnRef('AttachmentKindOption').forEach((item: Option) => {
const data: KindOption = {
label: () => h(NEllipsis, null, { default: () => item.label }),
key: item.key.toString(),
extra: () => (item.key !== '' ? createExtraContent(item) : null),
icon: constantRouterIcon[item.extra] || null,
disabled: isDisabledKindOption(item),
};
option.push(data);
});
return option;
}
function isDisabledKindOption(item: Option): boolean {
if (props.fileType === 'default') {
return false;
}
return item.key !== props.fileType;
}
function createExtraContent(item: Option): VNode {
return h(
NButton,
{
quaternary: true,
type: 'default',
size: 'tiny',
style: 'position: absolute; right: 15px;',
onClick: (event: MouseEvent) => {
event.stopPropagation();
dialog.warning({
title: '警告',
content: '你确定要清空 [' + item.label + '] 分类?该操作不可恢复!',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
ClearKind({ kind: item.key }).then((_res) => {
message.success('操作成功');
reloadTable();
});
},
});
},
},
{ icon: renderIcon(ClearOutlined) }
);
}
function loadList() {
loading.value = true;
List({ ...params.value, ...searchFormRef.value?.formModel }).then((res) => {
dataSource.value = res.list;
params.value.page = res.page;
params.value.pageSize = res.pageSize;
params.value.pageCount = res.pageCount;
loading.value = false;
});
}
function isEmptyDataSource(): boolean {
return !dataSource.value || dataSource.value.length === 0;
}
function onUpdatePage(page: number) {
params.value.page = page;
loadList();
}
function onUpdatePageSize(pageSize: number) {
params.value.pageSize = pageSize;
loadList();
}
function reloadTable() {
params.value.page = 1;
loadList();
}
function loadOptions() {
dict.loadOptions(['AttachmentKindOption', 'config_upload_drive']);
}
onMounted(async () => {
loadOptions();
loadList();
});
defineExpose({
reloadTable,
});
</script>
<style lang="less" scoped>
.base-list .n-spin-content {
height: 100% !important;
}
:deep(.n-card__action) {
padding: 5px;
}
.imageActive {
border: 2px solid #3086ff;
}
:deep(img, video) {
max-width: 100%;
aspect-ratio: 1/1;
}
:deep(.image-size) {
aspect-ratio: 1/1;
width: 100%;
height: auto;
}
</style>
@@ -0,0 +1,29 @@
<template>
<n-modal
v-model:show="showModal"
preset="card"
title="预览"
:bordered="false"
:style="{ width: '520px' }"
>
<n-image preview-disabled :src="previewUrl" :on-error="errorImg" />
</n-modal>
</template>
<script setup lang="ts">
import { errorImg } from '@/utils/hotgo';
import { ref } from 'vue';
const showModal = ref(false);
const previewUrl = ref('');
function openPreview(url) {
showModal.value = true;
previewUrl.value = url;
}
defineExpose({
openPreview,
});
</script>
<style scoped lang="less"></style>
@@ -0,0 +1,114 @@
<template>
<n-modal
v-model:show="showFileModal"
:show-icon="false"
:mask-closable="false"
preset="dialog"
:style="{
width: width,
}"
:title="'上传' + typeTag"
>
<n-upload
multiple
directory-dnd
:action="`${uploadUrl}${urlPrefix}/upload/file`"
:headers="uploadHeaders"
:data="{ type: 0 }"
@finish="finish"
name="file"
:max="maxUpload"
:default-file-list="fileList"
list-type="image"
>
<n-upload-dragger>
<div style="margin-bottom: 12px">
<n-icon size="48" :depth="3">
<FileAddOutlined />
</n-icon>
</div>
<n-text style="font-size: 16px"> 点击或者拖动{{ typeTag }}到该区域来上传</n-text>
<n-p depth="3" style="margin: 8px 0 0 0"> 单次最多允许{{ maxUpload }}个{{ typeTag }}</n-p>
</n-upload-dragger>
</n-upload>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue';
import { FileAddOutlined } from '@vicons/antd';
import { useUserStoreWidthOut } from '@/store/modules/user';
import { useGlobSetting } from '@/hooks/setting';
import { NModal, UploadFileInfo, useMessage } from 'naive-ui';
import componentSetting from '@/settings/componentSetting';
import { ResultEnum } from '@/enums/httpEnum';
import { Attachment, FileType, getFileType } from '@/components/FileChooser/src/model';
export interface Props {
width?: string;
maxUpload?: number;
finishCall?: Function | null;
uploadType?: FileType;
}
const props = withDefaults(defineProps<Props>(), {
width: '60%',
maxUpload: 20,
finishCall: null,
uploadType: 'default',
});
const fileList = ref<UploadFileInfo[]>([]);
const showFileModal = ref(false);
const message = useMessage();
const useUserStore = useUserStoreWidthOut();
const globSetting = useGlobSetting();
const { uploadUrl } = globSetting;
const urlPrefix = globSetting.urlPrefix || '';
const uploadHeaders = reactive({
Authorization: useUserStore.token,
uploadType: props.uploadType,
});
const typeTag = computed(() => {
return getFileType(props.uploadType);
});
//上传结束
function finish({ event: Event }) {
const res = JSON.parse(Event.target.response);
const infoField = componentSetting.upload.apiSetting.infoField;
const { code } = res;
const msg = res.msg || res.message || '上传失败';
const result = res[infoField] as Attachment;
//成功
if (code === ResultEnum.SUCCESS) {
fileList.value.push({
id: result.md5,
name: result.name,
status: 'finished',
type: result.naiveType,
});
message.success('上传' + result.name + '成功');
if (props.finishCall !== null) {
props.finishCall(result, true);
}
} else {
message.error(msg);
if (props.finishCall !== null) {
props.finishCall(result, false);
}
}
}
function openModal() {
showFileModal.value = true;
fileList.value = [];
}
defineExpose({
openModal,
});
</script>
@@ -0,0 +1,51 @@
import { VNode } from '@vue/runtime-core';
export type Attachment = {
id: number;
appId: string;
memberId: number;
cateId: number;
drive: string;
name: string;
kind: string;
metaType: string;
naiveType: string;
path: string;
fileUrl: string;
size: number;
ext: string;
md5: string;
status: number;
createdAt: string;
updatedAt: string;
sizeFormat: string;
};
export interface KindOption {
key: string;
label: any;
icon: VNode;
extra: any;
disabled: boolean;
}
export type FileType = 'image' | 'doc' | 'audio' | 'video' | 'zip' | 'other' | 'default';
export function getFileType(fileType: string): string {
switch (fileType) {
case 'image':
return '图片';
case 'doc':
return '文档';
case 'audio':
return '音频';
case 'video':
return '视频';
case 'zip':
return '压缩包';
case 'other':
case 'default':
return '文件';
}
return '文件';
}
+4
View File
@@ -0,0 +1,4 @@
export { default as BasicForm } from './src/BasicForm.vue';
export { useForm } from './src/hooks/useForm';
export * from './src/types/form';
export * from './src/types/index';
+378
View File
@@ -0,0 +1,378 @@
<template>
<n-form v-bind="getBindValue" :model="formModel" ref="formElRef">
<n-grid v-bind="getGrid">
<n-gi v-bind="schema.giProps" v-for="schema in getSchema" :key="schema.field">
<n-form-item :label="schema.label" :path="schema.field">
<!--标签名右侧温馨提示-->
<template #label v-if="schema.labelMessage">
{{ schema.label }}
<n-tooltip trigger="hover" :style="schema.labelMessageStyle">
<template #trigger>
<n-icon size="18" class="text-gray-400 cursor-pointer">
<QuestionCircleOutlined />
</n-icon>
</template>
{{ schema.labelMessage }}
</n-tooltip>
</template>
<!--判断插槽-->
<template v-if="schema.slot">
<slot
:name="schema.slot"
:model="formModel"
:field="schema.field"
:value="formModel[schema.field]"
></slot>
</template>
<!--NCheckbox-->
<template v-else-if="schema.component === 'NCheckbox'">
<n-checkbox-group v-model:value="formModel[schema.field]">
<n-space>
<n-checkbox
v-for="item in schema.componentProps.options"
:key="item.value"
:value="item.value"
:label="item.label"
/>
</n-space>
</n-checkbox-group>
</template>
<!--NRadioGroup-->
<template v-else-if="schema.component === 'NRadioGroup'">
<n-radio-group v-model:value="formModel[schema.field]">
<n-radio-button
v-for="item in schema.componentProps.options"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</n-radio-button>
</n-radio-group>
</template>
<template v-else-if="schema.component === 'NTreeSelect'">
<n-tree-select
:class="{ isFull: schema.isFull !== false && getProps.isFull }"
v-model:value="formModel[schema.field]"
v-bind="getComponentProps(schema)"
/>
</template>
<template v-else-if="schema.component === 'NCascader'">
<n-cascader
:class="{ isFull: schema.isFull !== false && getProps.isFull }"
v-model:value="formModel[schema.field]"
v-bind="getComponentProps(schema)"
/>
</template>
<template v-else-if="schema.component === 'ComplexMemberPicker'">
<ComplexMemberPicker
:class="{ isFull: schema.isFull !== false && getProps.isFull }"
v-model:value="formModel[schema.field]"
v-bind="getComponentProps(schema)"
/>
</template>
<!--动态渲染表单组件-->
<component
v-else
v-bind="getComponentProps(schema)"
:is="schema.component"
v-model:value="formModel[schema.field]"
:class="{ isFull: schema.isFull !== false && getProps.isFull }"
/>
<!--组件后面的内容-->
<template v-if="schema.suffix">
<slot
:name="schema.suffix"
:model="formModel"
:field="schema.field"
:value="formModel[schema.field]"
></slot>
</template>
</n-form-item>
</n-gi>
<!--提交 重置 展开 收起 按钮-->
<n-gi
:span="isInline ? 1 : 24"
:suffix="!!isInline"
#="{ overflow }"
v-if="getProps.showActionButtonGroup"
>
<n-space
align="center"
:justify="isInline ? 'end' : 'start'"
:style="{ 'margin-left': `${isInline ? 12 : getProps.labelWidth}px` }"
>
<n-button
v-if="getProps.showSubmitButton"
v-bind="getSubmitBtnOptions"
@click="handleSubmit"
:loading="loadingSub"
>{{ getProps.submitButtonText }}</n-button
>
<n-button
v-if="getProps.showResetButton"
v-bind="getResetBtnOptions"
@click="resetFields"
>{{ getProps.resetButtonText }}</n-button
>
<n-button
type="primary"
text
icon-placement="right"
v-if="isInline && getProps.showAdvancedButton"
@click="unfoldToggle"
>
<template #icon>
<n-icon size="14" class="unfold-icon" v-if="overflow">
<DownOutlined />
</n-icon>
<n-icon size="14" class="unfold-icon" v-else>
<UpOutlined />
</n-icon>
</template>
{{ overflow ? '展开' : '收起' }}
</n-button>
</n-space>
</n-gi>
</n-grid>
</n-form>
</template>
<script lang="ts">
import {
defineComponent,
reactive,
ref,
computed,
unref,
onMounted,
watch,
defineExpose,
} from 'vue';
import { createPlaceholderMessage } from './helper';
import { useFormEvents } from './hooks/useFormEvents';
import { useFormValues } from './hooks/useFormValues';
import ComplexMemberPicker from '../../ComplexMemberPicker/index.vue';
import { basicProps } from './props';
import { DownOutlined, UpOutlined, QuestionCircleOutlined } from '@vicons/antd';
import type { Ref } from 'vue';
import type { GridProps } from 'naive-ui/lib/grid';
import type { FormSchema, FormProps, FormActionType } from './types/form';
import { isArray, isBoolean, isFunction } from '@/utils/is';
import { deepMerge } from '@/utils';
import { usePermission } from '@/hooks/web/usePermission';
import { ActionItem } from '@/components/Table';
export default defineComponent({
name: 'BasicForm',
components: { DownOutlined, UpOutlined, QuestionCircleOutlined },
props: {
...basicProps,
},
emits: ['reset', 'submit', 'register'],
setup(props, { emit, attrs }) {
const defaultFormModel = ref<Recordable>({});
const formModel = reactive<Recordable>({});
const propsRef = ref<Partial<FormProps>>({});
const schemaRef = ref<Nullable<FormSchema[]>>(null);
const formElRef = ref<Nullable<FormActionType>>(null);
const gridCollapsed = ref(true);
const loadingSub = ref(false);
const isUpdateDefaultRef = ref(false);
const { hasPermission } = usePermission();
const getSubmitBtnOptions = computed(() => {
return Object.assign(
{
size: props.size,
type: 'primary',
},
props.submitButtonOptions
);
});
const getResetBtnOptions = computed(() => {
return Object.assign(
{
size: props.size,
type: 'default',
},
props.resetButtonOptions
);
});
function getComponentProps(schema) {
const compProps = schema.componentProps ?? {};
const component = schema.component;
return {
clearable: true,
placeholder: createPlaceholderMessage(unref(component)),
...compProps,
};
}
const getProps = computed((): FormProps => {
const formProps = { ...props, ...unref(propsRef) } as FormProps;
const rulesObj: any = {
rules: {},
};
const schemas: any = formProps.schemas || [];
schemas.forEach((item) => {
if (item.rules && isArray(item.rules)) {
rulesObj.rules[item.field] = item.rules;
}
});
return { ...formProps, ...unref(rulesObj) };
});
const isInline = computed(() => {
const { layout } = unref(getProps);
return layout === 'inline';
});
const getGrid = computed((): GridProps => {
const { gridProps } = unref(getProps);
return {
...gridProps,
collapsed: isInline.value ? gridCollapsed.value : false,
responsive: 'screen',
};
});
const getBindValue = computed(
() => ({ ...attrs, ...props, ...unref(getProps) }) as Recordable
);
const getSchema = computed((): FormSchema[] => {
const rawSchemas: FormSchema[] = unref(schemaRef) || (unref(getProps).schemas as any);
const schemas = rawSchemas.filter((schema) => {
return hasPermission(schema.auth as string[]) && isIfShow(schema);
});
for (const schema of schemas) {
const { defaultValue } = schema;
// handle date type
// dateItemType.includes(component as string)
if (defaultValue) {
schema.defaultValue = defaultValue;
}
}
return schemas as FormSchema[];
});
const { handleFormValues, initDefault } = useFormValues({
defaultFormModel,
getSchema,
formModel,
});
function isIfShow(action: ActionItem): boolean {
const ifShow = action.ifShow;
let isIfShow = true;
if (isBoolean(ifShow)) {
isIfShow = ifShow;
}
if (isFunction(ifShow)) {
isIfShow = ifShow(action);
}
return isIfShow;
}
const { handleSubmit, validate, resetFields, getFieldsValue, clearValidate, setFieldsValue } =
useFormEvents({
emit,
getProps,
formModel,
getSchema,
formElRef: formElRef as Ref<FormActionType>,
defaultFormModel,
loadingSub,
handleFormValues,
});
function unfoldToggle() {
gridCollapsed.value = !gridCollapsed.value;
}
async function setProps(formProps: Partial<FormProps>): Promise<void> {
propsRef.value = deepMerge(unref(propsRef) || {}, formProps);
}
const formActionType: Partial<FormActionType> = {
getFieldsValue,
setFieldsValue,
resetFields,
validate,
clearValidate,
setProps,
submit: handleSubmit,
};
// 当showAdvancedButton为false时所有列表项应为全部显示
watch(
() => getProps.value.showAdvancedButton,
(show) => {
if (!show) gridCollapsed.value = false;
},
{
immediate: true,
}
);
watch(
() => getSchema.value,
(schema) => {
if (unref(isUpdateDefaultRef)) {
return;
}
if (schema?.length) {
initDefault();
isUpdateDefaultRef.value = true;
}
}
);
onMounted(() => {
initDefault();
emit('register', formActionType);
});
return {
formElRef,
formModel,
getGrid,
getProps,
getBindValue,
getSchema,
getSubmitBtnOptions,
getResetBtnOptions,
handleSubmit,
resetFields,
loadingSub,
isInline,
getComponentProps,
unfoldToggle,
setFieldsValue,
};
},
});
</script>
<style lang="less" scoped>
.isFull {
width: 100%;
justify-content: flex-start;
}
.unfold-icon {
display: flex;
align-items: center;
height: 100%;
margin-left: -3px;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
import { ComponentType } from './types/index';
/**
* @description: 生成placeholder
*/
export function createPlaceholderMessage(component: ComponentType) {
if (component === 'NInput') return '请输入';
if (
['NPicker', 'NSelect', 'NCheckbox', 'NRadio', 'NSwitch', 'NDatePicker', 'NTimePicker'].includes(
component
)
)
return '请选择';
return '';
}
const DATE_TYPE = ['DatePicker', 'MonthPicker', 'WeekPicker', 'TimePicker'];
function genType() {
return [...DATE_TYPE, 'RangePicker'];
}
/**
* 时间字段
*/
export const dateItemType = genType();
export function defaultType(component) {
if (component === 'NInput') return '';
if (component === 'NInputNumber') return null;
return [
'NPicker',
'NSelect',
'NCheckbox',
'NRadio',
'NSwitch',
'NDatePicker',
'NTimePicker',
].includes(component)
? ''
: undefined;
}
@@ -0,0 +1,86 @@
import type { FormProps, FormActionType, UseFormReturnType } from '../types/form';
import type { DynamicProps } from '/#/utils';
import { ref, onUnmounted, unref, nextTick, watch } from 'vue';
import { isProdMode } from '@/utils/env';
import { getDynamicProps } from '@/utils';
type Props = Partial<DynamicProps<FormProps>>;
export function useForm(props?: Props): UseFormReturnType {
const formRef = ref<Nullable<FormActionType>>(null);
const loadedRef = ref<Nullable<boolean>>(false);
async function getForm() {
const form = unref(formRef);
if (!form) {
console.error(
'The form instance has not been obtained, please make sure that the form has been rendered when performing the form operation!'
);
}
await nextTick();
return form as FormActionType;
}
function register(instance: FormActionType) {
isProdMode() &&
onUnmounted(() => {
formRef.value = null;
loadedRef.value = null;
});
if (unref(loadedRef) && isProdMode() && instance === unref(formRef)) return;
formRef.value = instance;
loadedRef.value = true;
watch(
() => props,
() => {
props && instance.setProps(getDynamicProps(props));
},
{
immediate: true,
deep: true,
}
);
}
const methods: FormActionType = {
setProps: async (formProps: Partial<FormProps>) => {
const form = await getForm();
await form.setProps(formProps);
},
resetFields: async () => {
getForm().then(async (form) => {
await form.resetFields();
});
},
clearValidate: async (name?: string | string[]) => {
const form = await getForm();
await form.clearValidate(name);
},
getFieldsValue: <T>() => {
return unref(formRef)?.getFieldsValue() as T;
},
setFieldsValue: async <T>(values: T) => {
const form = await getForm();
await form.setFieldsValue<T>(values);
},
submit: async (): Promise<any> => {
const form = await getForm();
return form.submit();
},
validate: async (nameList?: any[]): Promise<Recordable> => {
const form = await getForm();
return form.validate(nameList);
},
};
return [register, methods];
}
@@ -0,0 +1,11 @@
import { provide, inject } from 'vue';
const key = Symbol('formElRef');
export function createFormContext(instance) {
provide(key, instance);
}
export function useFormContext() {
return inject(key);
}
@@ -0,0 +1,107 @@
import type { ComputedRef, Ref } from 'vue';
import type { FormProps, FormSchema, FormActionType } from '../types/form';
import { unref, toRaw } from 'vue';
import { isFunction } from '@/utils/is';
declare type EmitType = (event: string, ...args: any[]) => void;
interface UseFormActionContext {
emit: EmitType;
getProps: ComputedRef<FormProps>;
getSchema: ComputedRef<FormSchema[]>;
formModel: Recordable;
formElRef: Ref<FormActionType>;
defaultFormModel: Recordable;
loadingSub: Ref<boolean>;
handleFormValues: Function;
}
export function useFormEvents({
emit,
getProps,
formModel,
getSchema,
formElRef,
defaultFormModel,
loadingSub,
handleFormValues,
}: UseFormActionContext) {
// 验证
async function validate() {
return unref(formElRef)?.validate();
}
// 提交
async function handleSubmit(e?: Event): Promise<void> {
e && e.preventDefault();
loadingSub.value = true;
const { submitFunc } = unref(getProps);
if (submitFunc && isFunction(submitFunc)) {
await submitFunc();
return;
}
const formEl = unref(formElRef);
if (!formEl) return;
try {
await validate();
loadingSub.value = false;
emit('submit', formModel);
return;
} catch (error) {
loadingSub.value = false;
return;
}
}
//清空校验
async function clearValidate() {
// @ts-ignore
await unref(formElRef)?.restoreValidation();
}
//重置
async function resetFields(): Promise<void> {
const { resetFunc, submitOnReset } = unref(getProps);
resetFunc && isFunction(resetFunc) && (await resetFunc());
const formEl = unref(formElRef);
if (!formEl) return;
Object.keys(formModel).forEach((key) => {
formModel[key] = unref(defaultFormModel)[key] || null;
});
await clearValidate();
const fromValues = handleFormValues(toRaw(unref(formModel)));
emit('reset', fromValues);
submitOnReset && (await handleSubmit());
}
//获取表单值
function getFieldsValue(): Recordable {
const formEl = unref(formElRef);
if (!formEl) return {};
return handleFormValues(toRaw(unref(formModel)));
}
//设置表单字段值
async function setFieldsValue(values: Recordable): Promise<void> {
const fields = unref(getSchema)
.map((item) => item.field)
.filter(Boolean);
Object.keys(values).forEach((key) => {
const value = values[key];
if (fields.includes(key)) {
formModel[key] = value;
}
});
}
return {
handleSubmit,
validate,
resetFields,
getFieldsValue,
clearValidate,
setFieldsValue,
};
}
@@ -0,0 +1,54 @@
import { isArray, isFunction, isObject, isString, isNullOrUnDef } from '@/utils/is';
import { unref } from 'vue';
import type { Ref, ComputedRef } from 'vue';
import type { FormSchema } from '../types/form';
import { set } from 'lodash-es';
interface UseFormValuesContext {
defaultFormModel: Ref<any>;
getSchema: ComputedRef<FormSchema[]>;
formModel: Recordable;
}
export function useFormValues({ defaultFormModel, getSchema, formModel }: UseFormValuesContext) {
// 加工 form values
function handleFormValues(values: Recordable) {
if (!isObject(values)) {
return {};
}
const res: Recordable = {};
for (const item of Object.entries(values)) {
let [, value] = item;
const [key] = item;
if (
!key ||
(isArray(value) && value.length === 0) ||
isFunction(value) ||
isNullOrUnDef(value)
) {
continue;
}
// 删除空格
if (isString(value)) {
value = value.trim();
}
set(res, key, value);
}
return res;
}
//初始化默认值
function initDefault() {
const schemas = unref(getSchema);
const obj: Recordable = {};
schemas.forEach((item) => {
const { defaultValue } = item;
if (!isNullOrUnDef(defaultValue)) {
obj[item.field] = defaultValue;
formModel[item.field] = defaultValue;
}
});
defaultFormModel.value = obj;
}
return { handleFormValues, initDefault };
}
+82
View File
@@ -0,0 +1,82 @@
import type { CSSProperties, PropType } from 'vue';
import { FormSchema } from './types/form';
import type { GridProps, GridItemProps } from 'naive-ui/lib/grid';
import type { ButtonProps } from 'naive-ui/lib/button';
import { propTypes } from '@/utils/propTypes';
export const basicProps = {
// 标签宽度 固定宽度
labelWidth: {
type: [Number, String] as PropType<number | string>,
default: 80,
},
// 表单配置规则
schemas: {
type: [Array] as PropType<FormSchema[]>,
default: () => [],
},
//布局方式
layout: {
type: String,
default: 'inline',
},
//是否展示为行内表单
inline: {
type: Boolean,
default: false,
},
//大小
size: {
type: String,
default: 'medium',
},
//标签位置
labelPlacement: {
type: String,
default: 'left',
},
//组件是否width 100%
isFull: {
type: Boolean,
default: true,
},
//是否显示操作按钮(查询/重置)
showActionButtonGroup: propTypes.bool.def(true),
// 显示重置按钮
showResetButton: propTypes.bool.def(true),
//重置按钮配置
resetButtonOptions: Object as PropType<Partial<ButtonProps>>,
// 显示确认按钮
showSubmitButton: propTypes.bool.def(true),
// 确认按钮配置
submitButtonOptions: Object as PropType<Partial<ButtonProps>>,
//展开收起按钮
showAdvancedButton: propTypes.bool.def(true),
// 确认按钮文字
submitButtonText: {
type: String,
default: '查询',
},
//重置按钮文字
resetButtonText: {
type: String,
default: '重置',
},
//grid 配置
gridProps: Object as PropType<GridProps>,
//gi配置
giProps: Object as PropType<GridItemProps>,
//grid 样式
baseGridStyle: {
type: Object as PropType<CSSProperties>,
},
//是否折叠
collapsed: {
type: Boolean,
default: false,
},
//默认展示的行数
collapsedRows: {
type: Number,
default: 1,
},
};
+63
View File
@@ -0,0 +1,63 @@
import { ComponentType } from './index';
import type { CSSProperties } from 'vue';
import type { GridProps, GridItemProps } from 'naive-ui/lib/grid';
import type { ButtonProps } from 'naive-ui/lib/button';
import {PermissionsEnum} from "@/enums/permissionsEnum";
import { ActionItem } from '@/components/Table';
export interface FormSchema {
field: string;
label: string;
labelMessage?: string;
labelMessageStyle?: object | string;
defaultValue?: any;
component?: ComponentType;
componentProps?: object | any;
slot?: string;
rules?: object | object[];
giProps?: GridItemProps;
isFull?: boolean;
suffix?: string;
auth?: PermissionsEnum | PermissionsEnum[] | string | string[];
ifShow?: boolean | ((action: ActionItem) => boolean);
}
export interface FormProps {
model?: Recordable;
labelWidth?: number | string;
schemas?: FormSchema[];
inline: boolean;
layout?: string;
size: string;
labelPlacement: string;
isFull: boolean;
showActionButtonGroup?: boolean;
showResetButton?: boolean;
resetButtonOptions?: Partial<ButtonProps>;
showSubmitButton?: boolean;
showAdvancedButton?: boolean;
submitButtonOptions?: Partial<ButtonProps>;
submitButtonText?: string;
resetButtonText?: string;
gridProps?: GridProps;
giProps?: GridItemProps;
resetFunc?: () => Promise<void>;
submitFunc?: () => Promise<void>;
submitOnReset?: boolean;
baseGridStyle?: CSSProperties;
collapsedRows?: number;
}
export interface FormActionType {
submit: () => Promise<any>;
setProps: (formProps: Partial<FormProps>) => Promise<void>;
setFieldsValue: <T>(values: T) => Promise<void>;
clearValidate: (name?: string | string[]) => Promise<void>;
getFieldsValue: () => Recordable;
resetFields: () => Promise<void>;
validate: (nameList?: any[]) => Promise<any>;
}
export type RegisterFn = (formInstance: FormActionType) => void;
export type UseFormReturnType = [RegisterFn, FormActionType];
@@ -0,0 +1,29 @@
export type ComponentType =
| 'NInput'
| 'NInputGroup'
| 'NInputPassword'
| 'NInputSearch'
| 'NInputTextArea'
| 'NInputNumber'
| 'NInputCountDown'
| 'NSelect'
| 'NTreeSelect'
| 'NRadioButtonGroup'
| 'NRadioGroup'
| 'NCheckbox'
| 'NCheckboxGroup'
| 'NAutoComplete'
| 'NCascader'
| 'NDatePicker'
| 'NMonthPicker'
| 'NRangePicker'
| 'NWeekPicker'
| 'NTimePicker'
| 'NSwitch'
| 'NStrengthMeter'
| 'NUpload'
| 'NIconPicker'
| 'NRender'
| 'NSlider'
| 'NRate'
| 'ComplexMemberPicker';
@@ -0,0 +1,97 @@
<template>
<n-popover trigger="click" placement="bottom" width="400">
<template #trigger>
<n-button>
<template #icon>
<n-icon size="20">
<component :is="formValue !== '' ? formValue : AntDesignOutlined" />
</n-icon>
</template>
</n-button>
</template>
<n-scrollbar class="grid-wrapper">
<n-grid :cols="8" :collapsed="false" responsive="screen" style="height: 300px">
<n-grid-item v-for="(item, index) of icons" :key="index">
<div
class="flex flex-col items-center justify-center p-3 icon-wrapper"
@click="onIconClick(item)"
>
<n-icon size="20">
<component :is="item" />
</n-icon>
</div>
</n-grid-item>
</n-grid>
</n-scrollbar>
<div class="flex justify-end mt-2 mb-2">
<n-pagination
:page="currentPage"
:page-size="pageSize"
:page-slot="8"
:item-count="itemCount"
@update-page="onUpdatePage"
/>
</div>
</n-popover>
</template>
<script lang="ts">
import { computed, defineComponent, ref, shallowReactive } from 'vue';
import * as AntdIcons from '@vicons/antd';
import { AntDesignOutlined } from '@vicons/antd';
export default defineComponent({
name: 'AntdSelector',
components: AntdIcons,
props: {
value: String,
option: String,
},
emits: ['update:value'],
setup(props, { emit }) {
const formValue = computed({
get() {
return props.value;
},
set(value) {
emit('update:value', value);
},
});
const iconArray = Object.keys(AntdIcons);
const pageSize = 40;
const icons = shallowReactive(iconArray.slice(0, 40));
const currentPage = ref(1);
const itemCount = computed(() => iconArray.length);
function onUpdatePage(page: number) {
currentPage.value = page;
icons.length = 0;
const start = (currentPage.value - 1) * pageSize;
icons.push(...iconArray.slice(start, start + pageSize));
}
function onIconClick(item: any) {
formValue.value = item;
}
return {
icons,
currentPage,
pageSize,
itemCount,
onUpdatePage,
onIconClick,
formValue,
AntDesignOutlined,
};
},
});
</script>
<style lang="less" scoped>
.grid-wrapper {
.icon-wrapper {
cursor: pointer;
border: 1px solid #f5f5f5;
}
}
</style>
@@ -0,0 +1,94 @@
<template>
<n-popover trigger="click" placement="bottom" width="400">
<template #trigger>
<n-button>
<template #icon>
<n-icon size="20">
<component :is="formValue !== '' ? formValue : 'LogoIonic'" />
</n-icon>
</template>
</n-button>
</template>
<n-scrollbar class="grid-wrapper">
<n-grid :cols="8" :collapsed="false" responsive="screen" style="height: 300px">
<n-grid-item v-for="(item, index) of icons" :key="index">
<div
class="flex flex-col items-center justify-center p-3 icon-wrapper"
@click="onIconClick(item)"
>
<n-icon size="20">
<component :is="item" />
</n-icon>
</div>
</n-grid-item>
</n-grid>
</n-scrollbar>
<div class="flex justify-end mt-2 mb-2">
<n-pagination
:page="currentPage"
:page-size="pageSize"
:page-slot="8"
:item-count="itemCount"
@update-page="onUpdatePage"
/>
</div>
</n-popover>
</template>
<script lang="ts">
import { computed, defineComponent, ref, shallowReactive } from 'vue';
import * as Ionicons5Icons from '@vicons/ionicons5';
export default defineComponent({
name: 'Ionicons5Selector',
components: Ionicons5Icons,
props: {
value: String,
option: String,
},
emits: ['update:value'],
setup(props, { emit }) {
const formValue = computed({
get() {
return props.value;
},
set(value) {
emit('update:value', value);
},
});
const iconArray = Object.keys(Ionicons5Icons);
const pageSize = 40;
const icons = shallowReactive(iconArray.slice(0, 40));
const currentPage = ref(1);
const itemCount = computed(() => iconArray.length);
function onUpdatePage(page: number) {
currentPage.value = page;
icons.length = 0;
const start = (currentPage.value - 1) * pageSize;
icons.push(...iconArray.slice(start, start + pageSize));
}
function onIconClick(item: any) {
formValue.value = item;
}
return {
icons,
currentPage,
pageSize,
itemCount,
onUpdatePage,
onIconClick,
formValue,
};
},
});
</script>
<style lang="less" scoped>
.grid-wrapper {
.icon-wrapper {
cursor: pointer;
border: 1px solid #f5f5f5;
}
}
</style>
+44
View File
@@ -0,0 +1,44 @@
<template>
<div>
<n-input-group>
<template v-if="option === 'ionicons5'">
<Ionicons5Selector v-model:value="formValue" />
</template>
<template v-else>
<AntdSelector v-model:value="formValue" />
</template>
<n-input v-bind="$props" :value="formValue" placeholder="请选择图标" />
</n-input-group>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
import { basicProps } from '@/components/IconSelector/props';
import Ionicons5Selector from '@/components/IconSelector/Ionicons5Selector.vue';
import AntdSelector from '@/components/IconSelector/AntdSelector.vue';
export default defineComponent({
name: 'BasicUpload',
components: { Ionicons5Selector, AntdSelector },
props: {
...basicProps,
},
emits: ['update:value'],
setup(props, { emit }) {
const formValue = computed({
get() {
return props.value;
},
set(value) {
emit('update:value', value);
},
});
return {
formValue,
};
},
});
</script>
<style lang="less"></style>
+14
View File
@@ -0,0 +1,14 @@
import type { PropType } from 'vue';
import { NInput } from 'naive-ui';
export const basicProps = {
...NInput.props,
option: {
type: String as PropType<string>,
default: 'antd', // ionicons5 | antd
},
value: {
type: String as PropType<string>,
default: () => '',
},
};
@@ -0,0 +1,3 @@
import LoadingContent from './index.vue';
export { LoadingContent };
@@ -0,0 +1,12 @@
<template></template>
<script lang="ts">
import { useLoadingBar } from 'naive-ui';
export default {
name: 'LoadingContent',
setup() {
//挂载在 window 方便与在js中使用
window['$loading'] = useLoadingBar();
},
};
</script>
@@ -0,0 +1,315 @@
<template>
<div
:class="{ onLockLogin: showLogin }"
class="lockscreen"
@keyup="onLockLogin(true)"
@mousedown.stop
@contextmenu.prevent
>
<template v-if="!showLogin">
<div class="lock-box">
<div class="lock">
<span class="lock-icon" title="解锁屏幕" @click="onLockLogin(true)">
<n-icon>
<lock-outlined />
</n-icon>
</span>
</div>
</div>
<!--充电-->
<recharge
:battery="battery"
:battery-status="batteryStatus"
:calc-discharging-time="calcDischargingTime"
:calc-charging-time="calcChargingTime"
/>
<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">
<wifi-outlined class="network" />
</span>
<api-outlined />
</div>
</template>
<!--登录-->
<template v-if="showLogin">
<div class="login-box">
<n-avatar :size="128">
<n-icon>
<user-outlined />
</n-icon>
</n-avatar>
<div class="username">{{ loginParams.username }}</div>
<n-input
type="password"
autofocus
v-model:value="loginParams.password"
@keyup.enter="onLogin"
placeholder="请输入登录密码"
>
<template #suffix>
<n-icon @click="onLogin" style="cursor: pointer">
<LoadingOutlined v-if="loginLoading" />
<arrow-right-outlined v-else />
</n-icon>
</template>
</n-input>
<div class="w-full flex" v-if="isLoginError">
<span class="text-red-500">{{ errorMsg }}</span>
</div>
<div class="w-full mt-1 flex justify-around">
<div><a @click="showLogin = false">返回</a></div>
<div><a @click="goLogin">重新登录</a></div>
<div><a @click="onLogin">进入系统</a></div>
</div>
</div>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs } from 'vue';
import { ResultEnum } from '@/enums/httpEnum';
import recharge from './Recharge.vue';
import {
LockOutlined,
LoadingOutlined,
UserOutlined,
ApiOutlined,
ArrowRightOutlined,
WifiOutlined,
} from '@vicons/antd';
import { useRouter, useRoute } from 'vue-router';
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 { aesEcb } from '@/utils/encrypt';
import { TABS_ROUTES } from '@/store/mutation-types';
export default defineComponent({
name: 'Lockscreen',
components: {
LockOutlined,
LoadingOutlined,
UserOutlined,
ArrowRightOutlined,
ApiOutlined,
WifiOutlined,
recharge,
},
setup() {
const useLockscreen = useLockscreenStore();
const userStore = useUserStore();
// 获取时间
const { month, day, hour, minute, second, week } = useTime();
const { online } = useOnline();
const router = useRouter();
const route = useRoute();
const { battery, batteryStatus, calcDischargingTime, calcChargingTime } = useBattery();
const userInfo: object = userStore.getUserInfo || {};
const username = userInfo['username'] || '';
const state = reactive({
showLogin: false,
loginLoading: false, // 正在登录
isLoginError: false, //密码错误
errorMsg: '密码错误',
loginParams: {
username: username || '',
password: '',
},
});
// 解锁登录
const onLockLogin = (value: boolean) => (state.showLogin = value);
// 登录
const onLogin = async () => {
if (!state.loginParams.password.trim()) {
return;
}
const params = {
isLock: true,
username: state.loginParams.username,
password: aesEcb.encrypt(state.loginParams.password),
};
state.loginLoading = true;
const { code, message } = await userStore.login(params);
if (code === ResultEnum.SUCCESS) {
onLockLogin(false);
useLockscreen.setLock(false);
window.location.reload();
} else {
state.errorMsg = message;
state.isLoginError = true;
}
state.loginLoading = false;
};
//重新登录
const goLogin = () => {
onLockLogin(false);
useLockscreen.setLock(false);
userStore.logout().then(() => {
// 移除标签页
localStorage.removeItem(TABS_ROUTES);
router
.replace({
name: 'Login',
query: {
redirect: route.fullPath,
},
})
.finally(() => location.reload());
});
};
return {
...toRefs(state),
online,
month,
day,
hour,
minute,
second,
week,
battery,
batteryStatus,
calcDischargingTime,
calcChargingTime,
onLockLogin,
onLogin,
goLogin,
};
},
});
</script>
<style lang="less" scoped>
.lockscreen {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
display: flex;
background: #000;
color: white;
overflow: hidden;
z-index: 9999;
&.onLockLogin {
background-color: rgba(25, 28, 34, 0.88);
backdrop-filter: blur(7px);
}
.login-box {
position: absolute;
top: 45%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
> * {
margin-bottom: 14px;
}
.username {
font-size: 30px;
}
}
.lock-box {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
font-size: 34px;
z-index: 100;
.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;
bottom: 60px;
right: 60px;
font-size: 24px;
> * {
margin-left: 14px;
}
.network {
position: relative;
&.offline::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 2px;
height: 28px;
transform: translate(-50%, -50%) rotate(45deg);
background-color: red;
z-index: 10;
}
}
}
}
</style>
+164
View File
@@ -0,0 +1,164 @@
<template>
<div class="container">
<div class="number">{{ battery.level }}%</div>
<div class="contrast">
<div class="circle"></div>
<ul class="bubbles">
<li v-for="i in 15" :key="i"></li>
</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">
距离电池充满需要:{{ calcChargingTime }}
</span>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'HuaweiCharge',
// props: ['batteryStatus', 'battery', 'calcDischargingTime'],
props: {
battery: {
// 电池对象
type: Object,
default: () => ({}),
},
calcDischargingTime: {
// 电池剩余时间可用时间
type: String,
default: '',
},
calcChargingTime: {
type: String,
default: '',
},
batteryStatus: {
// 电池状态
type: String,
validator: (val: string) => ['充电中', '已充满', '已断开电源'].includes(val),
},
},
});
</script>
<style lang="less" scoped>
.container {
position: absolute;
bottom: 20vh;
left: 50vw;
width: 300px;
height: 500px;
transform: translateX(-50%);
.number {
position: absolute;
top: 20%;
z-index: 10;
width: 300px;
font-size: 32px;
color: #fff;
text-align: center;
}
.contrast {
width: 300px;
height: 400px;
overflow: hidden;
background-color: #000;
filter: contrast(15) hue-rotate(0);
animation: hueRotate 10s infinite linear;
.circle {
position: relative;
width: 300px;
height: 300px;
filter: blur(8px);
box-sizing: border-box;
&::after {
position: absolute;
top: 40%;
left: 50%;
width: 200px;
height: 200px;
background-color: #00ff6f;
border-radius: 42% 38% 62% 49% / 45%;
content: '';
transform: translate(-50%, -50%) rotate(0);
animation: rotate 10s infinite linear;
}
&::before {
position: absolute;
top: 40%;
left: 50%;
z-index: 10;
width: 176px;
height: 176px;
background-color: #000;
border-radius: 50%;
content: '';
transform: translate(-50%, -50%);
}
}
.bubbles {
position: absolute;
bottom: 0;
left: 50%;
width: 100px;
height: 40px;
background-color: #00ff6f;
border-radius: 100px 100px 0 0;
filter: blur(5px);
transform: translate(-50%, 0);
li {
position: absolute;
background: #00ff6f;
border-radius: 50%;
}
}
}
.charging {
font-size: 20px;
text-align: center;
}
}
@keyframes rotate {
50% {
border-radius: 45% / 42% 38% 58% 49%;
}
100% {
transform: translate(-50%, -50%) rotate(720deg);
}
}
@keyframes moveToTop {
90% {
opacity: 1;
}
100% {
opacity: 0.1;
transform: translate(-50%, -180px);
}
}
@keyframes hueRotate {
100% {
filter: contrast(15) hue-rotate(360deg);
}
}
</style>
+3
View File
@@ -0,0 +1,3 @@
import LockScreen from './Lockscreen.vue';
export { LockScreen };
@@ -0,0 +1,3 @@
import MessageContent from './index.vue';
export { MessageContent };
@@ -0,0 +1,12 @@
<template></template>
<script lang="ts">
import { useMessage } from 'naive-ui';
export default {
name: 'MessageContent',
setup() {
//挂载在 window 方便与在js中使用
window['$message'] = useMessage();
},
};
</script>
+3
View File
@@ -0,0 +1,3 @@
export { default as basicModal } from './src/basicModal.vue';
export { useModal } from './src/hooks/useModal';
export * from './src/type';
+108
View File
@@ -0,0 +1,108 @@
<template>
<n-modal id="basic-modal" v-bind="getBindValue" v-model:show="isModal" @close="onCloseModal">
<template #header>
<div class="w-full cursor-move" id="basic-modal-bar">{{ getBindValue.title }}</div>
</template>
<template #default>
<slot name="default"></slot>
</template>
<template #action v-if="!$slots.action">
<n-space>
<n-button @click="closeModal">取消</n-button>
<n-button type="primary" :loading="subLoading" @click="handleSubmit">{{
subBtuText
}}</n-button>
</n-space>
</template>
<template v-else #action>
<slot name="action"></slot>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { getCurrentInstance, ref, nextTick, unref, computed, useAttrs } from 'vue';
import { basicProps } from './props';
import startDrag from '@/utils/Drag';
import { deepMerge } from '@/utils';
import { FormProps } from '@/components/Form';
import { ModalProps, ModalMethods } from './type';
const attrs = useAttrs();
const props = defineProps({ ...basicProps });
const emit = defineEmits(['on-close', 'on-ok', 'register']);
const propsRef = ref<Partial<ModalProps> | null>(null);
const isModal = ref(false);
const subLoading = ref(false);
const getProps = computed((): FormProps => {
return { ...props, ...(unref(propsRef) as any) };
});
const subBtuText = computed(() => {
const { subBtuText } = propsRef.value as any;
return subBtuText || props.subBtuText;
});
async function setProps(modalProps: Partial<ModalProps>): Promise<void> {
propsRef.value = deepMerge(unref(propsRef) || ({} as any), modalProps);
}
const getBindValue = computed(() => {
return {
...attrs,
...unref(getProps),
...unref(propsRef),
};
});
function setSubLoading(status: boolean) {
subLoading.value = status;
}
function openModal() {
isModal.value = true;
nextTick(() => {
const oBox = document.getElementById('basic-modal');
const oBar = document.getElementById('basic-modal-bar');
startDrag(oBar, oBox);
});
}
function closeModal() {
isModal.value = false;
subLoading.value = false;
emit('on-close');
}
function onCloseModal() {
isModal.value = false;
emit('on-close');
}
function handleSubmit() {
subLoading.value = true;
console.log(subLoading.value);
emit('on-ok');
}
const modalMethods: ModalMethods = {
setProps,
openModal,
closeModal,
setSubLoading,
};
const instance = getCurrentInstance();
if (instance) {
emit('register', modalMethods);
}
</script>
<style lang="less">
.cursor-move {
cursor: move;
}
</style>
@@ -0,0 +1,54 @@
import { ref, unref, getCurrentInstance, watch } from 'vue';
import { isProdMode } from '@/utils/env';
import { ModalMethods, UseModalReturnType } from '../type';
import { getDynamicProps } from '@/utils';
import { tryOnUnmounted } from '@vueuse/core';
export function useModal(props): UseModalReturnType {
const modalRef = ref<Nullable<ModalMethods>>(null);
const currentInstance = getCurrentInstance();
const getInstance = () => {
const instance = unref(modalRef.value);
if (!instance) {
console.error('useModal instance is undefined!');
}
return instance;
};
const register = (modalInstance: ModalMethods) => {
isProdMode() &&
tryOnUnmounted(() => {
modalRef.value = null;
});
modalRef.value = modalInstance;
currentInstance?.emit('register', modalInstance);
watch(
() => props,
() => {
props && modalInstance.setProps(getDynamicProps(props));
},
{
immediate: true,
deep: true,
}
);
};
const methods: ModalMethods = {
setProps: (props): void => {
getInstance()?.setProps(props);
},
openModal: () => {
getInstance()?.openModal();
},
closeModal: () => {
getInstance()?.closeModal();
},
setSubLoading: (status) => {
getInstance()?.setSubLoading(status);
},
};
return [register, methods];
}
+30
View File
@@ -0,0 +1,30 @@
import { NModal } from 'naive-ui';
export const basicProps = {
...NModal.props,
// 确认按钮文字
subBtuText: {
type: String,
default: '确认',
},
showIcon: {
type: Boolean,
default: false,
},
width: {
type: Number,
default: 446,
},
title: {
type: String,
default: '',
},
maskClosable: {
type: Boolean,
default: false,
},
preset: {
type: String,
default: 'dialog',
},
};
@@ -0,0 +1,19 @@
import type { DialogOptions } from 'naive-ui/lib/dialog';
/**
* @description: 弹窗对外暴露的方法
*/
export interface ModalMethods {
setProps: (props) => void;
openModal: () => void;
closeModal: () => void;
setSubLoading: (status) => void;
}
/**
* 支持修改,DialogOptions 參數
*/
export type ModalProps = DialogOptions;
export type RegisterFn = (ModalInstance: ModalMethods) => void;
export type UseModalReturnType = [RegisterFn, ModalMethods];
+59
View File
@@ -0,0 +1,59 @@
<template>
<component :is="component" :class="className" aria-hidden="true">
<use :href="iconName" />
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'SvgIcon',
props: {
prefix: {
type: String,
default: 'icon',
},
name: {
type: String,
required: true,
},
},
computed: {
component(): string {
return this.prefix === 'icon' ? 'svg' : 'i';
},
iconName(): string {
return `#${this.prefix}-${this.name}`;
},
className(): string {
if (this.prefix === 'icon') {
return 'svg-icon';
} else if (this.prefix === 'iconfont') {
return 'iconfont icon-' + this.name;
} else {
return '';
}
},
},
});
</script>
<style scoped>
.svg-icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
.svg-icon:hover {
fill: var(--primary-color-hover);
}
.svg-external-icon {
background-color: currentColor;
mask-size: cover !important;
display: inline-block;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
export { default as BasicTable } from './src/Table.vue';
export { default as TableAction } from './src/components/TableAction.vue';
export * from './src/types/table';
export * from './src/types/tableAction';
+353
View File
@@ -0,0 +1,353 @@
<template>
<div class="table-toolbar">
<!--顶部左侧区域-->
<div class="flex items-center table-toolbar-left">
<template v-if="title">
<div class="table-toolbar-left-title">
{{ title }}
<n-tooltip trigger="hover" v-if="titleTooltip">
<template #trigger>
<n-icon size="18" class="ml-1 text-gray-400 cursor-pointer">
<QuestionCircleOutlined />
</n-icon>
</template>
{{ titleTooltip }}
</n-tooltip>
</div>
</template>
<slot name="tableTitle"></slot>
</div>
<div class="flex items-center table-toolbar-right" v-show="showTopRight">
<!--顶部右侧区域-->
<slot name="toolbar"></slot>
<!--斑马纹-->
<n-tooltip trigger="hover">
<template #trigger>
<div class="mr-2 table-toolbar-right-icon">
<n-switch v-model:value="isStriped" @update:value="setStriped" />
</div>
</template>
<span>表格斑马纹</span>
</n-tooltip>
<n-divider vertical />
<!--刷新-->
<n-tooltip trigger="hover">
<template #trigger>
<div class="table-toolbar-right-icon" @click="reload">
<n-icon size="18">
<ReloadOutlined />
</n-icon>
</div>
</template>
<span>刷新</span>
</n-tooltip>
<!--密度-->
<n-tooltip trigger="hover">
<template #trigger>
<div class="table-toolbar-right-icon">
<n-dropdown
@select="densitySelect"
trigger="click"
:options="densityOptions"
v-model:value="tableSize"
>
<n-icon size="18">
<ColumnHeightOutlined />
</n-icon>
</n-dropdown>
</div>
</template>
<span>密度</span>
</n-tooltip>
<!--表格设置单独抽离成组件-->
<ColumnSetting :openChecked="openChecked" />
</div>
</div>
<div class="s-table">
<n-data-table
ref="tableElRef"
v-bind="getBindValues"
:striped="isStriped"
:pagination="pagination"
@update:page="updatePage"
@update:page-size="updatePageSize"
>
<template #[item]="data" v-for="item in Object.keys($slots)" :key="item">
<slot :name="item" v-bind="data"></slot>
</template>
</n-data-table>
</div>
</template>
<script lang="ts">
import {
ref,
defineComponent,
reactive,
unref,
toRaw,
computed,
toRefs,
onMounted,
nextTick,
} from 'vue';
import { ReloadOutlined, ColumnHeightOutlined, QuestionCircleOutlined } from '@vicons/antd';
import { createTableContext } from './hooks/useTableContext';
import ColumnSetting from './components/settings/ColumnSetting.vue';
import { useLoading } from './hooks/useLoading';
import { useColumns } from './hooks/useColumns';
import { useDataSource } from './hooks/useDataSource';
import { usePagination } from './hooks/usePagination';
import { basicProps } from './props';
import { BasicTableProps } from './types/table';
import { getViewportOffset } from '@/utils/domUtils';
import { useWindowSizeFn } from '@/hooks/event/useWindowSizeFn';
import { isBoolean } from '@/utils/is';
const densityOptions = [
{
type: 'menu',
label: '紧凑',
key: 'small',
},
{
type: 'menu',
label: '默认',
key: 'medium',
},
{
type: 'menu',
label: '宽松',
key: 'large',
},
];
export default defineComponent({
components: {
ReloadOutlined,
ColumnHeightOutlined,
ColumnSetting,
QuestionCircleOutlined,
},
props: {
...basicProps,
},
emits: [
'fetch-success',
'fetch-error',
'update:checked-row-keys',
'edit-end',
'edit-cancel',
'edit-row-end',
'edit-change',
],
setup(props, { emit }) {
const deviceHeight = ref(150);
const tableElRef = ref<ComponentRef>(null);
const wrapRef = ref<Nullable<HTMLDivElement>>(null);
let paginationEl: HTMLElement | null;
const isStriped = ref(false);
const tableData = ref<Recordable[]>([]);
const innerPropsRef = ref<Partial<BasicTableProps>>();
const getProps = computed(() => {
return { ...props, ...unref(innerPropsRef) } as BasicTableProps;
});
const { getLoading, setLoading } = useLoading(getProps);
const { getPaginationInfo, setPagination } = usePagination(getProps);
const { getDataSourceRef, getDataSource, getRowKey, reload } = useDataSource(
getProps,
{
getPaginationInfo,
setPagination,
tableData,
setLoading,
},
emit
);
const { getPageColumns, setColumns, getColumns, getCacheColumns, setCacheColumnsField } =
useColumns(getProps);
const state = reactive({
tableSize: unref(getProps as any).size || 'medium',
isColumnSetting: false,
});
//页码切换
function updatePage(page) {
setPagination({ page: page });
reload();
}
//分页数量切换
function updatePageSize(size) {
setPagination({ page: 1, pageSize: size });
reload();
}
//密度切换
function densitySelect(e) {
state.tableSize = e;
}
//选中行
function updateCheckedRowKeys(rowKeys) {
emit('update:checked-row-keys', rowKeys);
}
//获取表格大小
const getTableSize = computed(() => state.tableSize);
//组装表格信息
const getBindValues = computed(() => {
const tableData = unref(getDataSourceRef);
const maxHeight = tableData.length ? `${unref(deviceHeight)}px` : 'auto';
return {
...unref(getProps),
loading: unref(getLoading),
columns: toRaw(unref(getPageColumns)),
rowKey: unref(getRowKey),
data: tableData,
size: unref(getTableSize),
remote: true,
'max-height': maxHeight,
};
});
//获取分页信息
const pagination = computed(() => toRaw(unref(getPaginationInfo)));
function setProps(props: Partial<BasicTableProps>) {
innerPropsRef.value = { ...unref(innerPropsRef), ...props };
}
const setStriped = (value: boolean) => (isStriped.value = value);
const tableAction = {
reload,
setColumns,
setLoading,
setProps,
getColumns,
getPageColumns,
getCacheColumns,
setCacheColumnsField,
emit,
};
const getCanResize = computed(() => {
const { canResize } = unref(getProps);
return canResize;
});
async function computeTableHeight() {
const table = unref(tableElRef);
if (!table) return;
if (!unref(getCanResize)) return;
const tableEl: any = table?.$el;
const headEl = tableEl.querySelector('.n-data-table-thead ');
const { bottomIncludeBody } = getViewportOffset(headEl);
const headerH = 64;
let paginationH = 2;
let marginH = 24;
if (!isBoolean(unref(pagination))) {
paginationEl = tableEl.querySelector('.n-data-table__pagination') as HTMLElement;
if (paginationEl) {
const offsetHeight = paginationEl.offsetHeight;
paginationH += offsetHeight || 0;
} else {
paginationH += 28;
}
}
let height =
bottomIncludeBody - (headerH + paginationH + marginH + (props.resizeHeightOffset || 0));
const maxHeight = props.maxHeight;
height = maxHeight && maxHeight < height ? maxHeight : height;
deviceHeight.value = height;
}
useWindowSizeFn(computeTableHeight, 280);
onMounted(() => {
nextTick(() => {
computeTableHeight();
});
});
createTableContext({ ...tableAction, wrapRef, getBindValues });
return {
...toRefs(state),
tableElRef,
getBindValues,
getDataSource,
densityOptions,
reload,
densitySelect,
updatePage,
updatePageSize,
pagination,
tableAction,
setStriped,
isStriped,
};
},
});
</script>
<style lang="less" scoped>
.table-toolbar {
display: flex;
justify-content: space-between;
padding: 0 0 16px 0;
&-left {
display: flex;
align-items: center;
justify-content: flex-start;
flex: 1;
&-title {
display: flex;
align-items: center;
justify-content: flex-start;
font-size: 16px;
font-weight: 600;
}
}
&-right {
display: flex;
justify-content: flex-end;
flex: 1;
&-icon {
margin-left: 12px;
font-size: 16px;
cursor: pointer;
color: var(--text-color);
:hover {
color: #1890ff;
}
}
}
}
.table-toolbar-inner-popover-title {
padding: 2px 0;
}
</style>
@@ -0,0 +1,41 @@
import type { Component } from 'vue';
import {
NInput,
NSelect,
NCheckbox,
NInputNumber,
NSwitch,
NDatePicker,
NTimePicker,
} from 'naive-ui';
import type { ComponentType } from './types/componentType';
export enum EventEnum {
NInput = 'on-input',
NInputNumber = 'on-input',
NSelect = 'on-update:value',
NSwitch = 'on-update:value',
NCheckbox = 'on-update:value',
NDatePicker = 'on-update:value',
NTimePicker = 'on-update:value',
}
const componentMap = new Map<ComponentType, Component>();
componentMap.set('NInput', NInput);
componentMap.set('NInputNumber', NInputNumber);
componentMap.set('NSelect', NSelect);
componentMap.set('NSwitch', NSwitch);
componentMap.set('NCheckbox', NCheckbox);
componentMap.set('NDatePicker', NDatePicker);
componentMap.set('NTimePicker', NTimePicker);
export function add(compName: ComponentType, component: Component) {
componentMap.set(compName, component);
}
export function del(compName: ComponentType) {
componentMap.delete(compName);
}
export { componentMap };
@@ -0,0 +1,161 @@
<template>
<div class="tableAction">
<div class="flex items-center justify-center">
<template v-for="(action, index) in getActions" :key="`${index}-${action.label}`">
<n-button v-bind="action" class="mx-1">
{{ action.label }}
<template #icon v-if="action.hasOwnProperty('icon')">
<n-icon :component="action.icon" />
</template>
</n-button>
</template>
<n-dropdown
v-if="dropDownActions && getDropdownList.length"
trigger="hover"
:options="getDropdownList"
@select="select"
>
<slot name="more"></slot>
<n-button v-bind="getMoreProps" class="mx-1" v-if="!$slots.more" icon-placement="right">
<div class="flex items-center">
<span>更多</span>
<n-icon size="14" class="ml-1">
<DownOutlined />
</n-icon>
</div>
<!-- <template #icon> </template>-->
</n-button>
</n-dropdown>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType, toRaw } from 'vue';
import { ActionItem } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { isBoolean, isFunction } from '@/utils/is';
import { DownOutlined } from '@vicons/antd';
export default defineComponent({
name: 'TableAction',
components: { DownOutlined },
props: {
actions: {
type: Array as PropType<ActionItem[]>,
default: null,
required: true,
},
dropDownActions: {
type: Array as PropType<ActionItem[]>,
default: null,
},
style: {
type: String as PropType<String>,
default: 'button',
},
select: {
type: Function as PropType<Function>,
default: () => {},
},
},
setup(props) {
const { hasPermission } = usePermission();
const actionType =
props.style === 'button' ? 'default' : props.style === 'text' ? 'primary' : 'default';
const actionText =
props.style === 'button' ? undefined : props.style === 'text' ? true : undefined;
const getMoreProps = computed(() => {
return {
text: actionText,
type: actionType,
size: 'small',
};
});
const getDropdownList = computed(() => {
return (toRaw(props.dropDownActions) || [])
.filter((action) => {
return hasPermission(action.auth as string[]) && isIfShow(action);
})
.map((action) => {
const { popConfirm } = action;
return {
size: 'small',
text: actionText,
type: getBtnType(action), //actionType,
...action,
...popConfirm,
onConfirm: popConfirm?.confirm,
onCancel: popConfirm?.cancel,
};
});
});
function isIfShow(action: ActionItem): boolean {
const ifShow = action.ifShow;
let isIfShow = true;
if (isBoolean(ifShow)) {
isIfShow = ifShow;
}
if (isFunction(ifShow)) {
isIfShow = ifShow(action);
}
return isIfShow;
}
function getBtnType(action) {
if (action.type !== undefined && action.type !== '') {
return action.type;
}
switch (action.label) {
case '编辑':
return 'primary';
case '启用':
case '已禁用':
return 'warning';
case '已启用':
case '禁用':
return 'success';
case '删除':
return 'error';
case '查看详情':
return 'default';
default:
return 'primary';
}
}
const getActions = computed(() => {
return (toRaw(props.actions) || [])
.filter((action) => {
return hasPermission(action.auth as string[]) && isIfShow(action);
})
.map((action) => {
const { popConfirm } = action;
//需要展示什么风格,自己修改一下参数
return {
size: 'small',
text: actionText,
type: getBtnType(action), //actionType,
...action,
...(popConfirm || {}),
onConfirm: popConfirm?.confirm,
onCancel: popConfirm?.cancel,
enable: !!popConfirm,
};
});
});
return {
getActions,
getDropdownList,
getMoreProps,
};
},
});
</script>
@@ -0,0 +1,47 @@
import type { FunctionalComponent, defineComponent } from 'vue';
import type { ComponentType } from '../../types/componentType';
import { componentMap } from '@/components/Table/src/componentMap';
import { h } from 'vue';
import { NPopover } from 'naive-ui';
export interface ComponentProps {
component: ComponentType;
rule: boolean;
popoverVisible: boolean;
ruleMessage: string;
}
export const CellComponent: FunctionalComponent = (
{ component = 'NInput', rule = true, ruleMessage, popoverVisible }: ComponentProps,
{ attrs }
) => {
const Comp = componentMap.get(component) as typeof defineComponent;
const DefaultComp = h(Comp, attrs);
if (!rule) {
return DefaultComp;
}
return h(
NPopover,
{ 'display-directive': 'show', show: !!popoverVisible, manual: 'manual' },
{
trigger: () => DefaultComp,
default: () =>
h(
'span',
{
style: {
color: 'red',
width: '90px',
display: 'inline-block',
},
},
{
default: () => ruleMessage,
}
),
}
);
};
@@ -0,0 +1,418 @@
<template>
<div class="editable-cell">
<div v-show="!isEdit" class="editable-cell-content" @click="handleEdit">
{{ getValues }}
<n-icon class="edit-icon" v-if="!column.editRow">
<FormOutlined />
</n-icon>
</div>
<div class="flex editable-cell-content" v-show="isEdit" v-click-outside="onClickOutside">
<div class="editable-cell-content-comp">
<CellComponent
v-bind="getComponentProps"
:component="getComponent"
:popoverVisible="getRuleVisible"
:ruleMessage="ruleMessage"
:rule="getRule"
:class="getWrapperClass"
ref="elRef"
@options-change="handleOptionsChange"
@pressEnter="handleEnter"
/>
</div>
<div class="editable-cell-action" v-if="!getRowEditable">
<n-icon class="mx-2 cursor-pointer">
<CheckOutlined @click="handleSubmit" />
</n-icon>
<n-icon class="mx-2 cursor-pointer">
<CloseOutlined @click="handleCancel" />
</n-icon>
</div>
</div>
</div>
</template>
<script lang="ts">
import type { PropType } from 'vue';
import type { BasicColumn } from '../../types/table';
import type { EditRecordRow } from './index';
import { defineComponent, ref, unref, nextTick, computed, watchEffect, toRaw } from 'vue';
import { FormOutlined, CloseOutlined, CheckOutlined } from '@vicons/antd';
import { CellComponent } from './CellComponent';
import { useTableContext } from '../../hooks/useTableContext';
import clickOutside from '@/directives/clickOutside';
import { propTypes } from '@/utils/propTypes';
import { isString, isBoolean, isFunction, isNumber, isArray } from '@/utils/is';
import { createPlaceholderMessage } from './helper';
import { set, omit } from 'lodash-es';
import { EventEnum } from '@/components/Table/src/componentMap';
import { parseISO, format } from 'date-fns';
export default defineComponent({
name: 'EditableCell',
components: { FormOutlined, CloseOutlined, CheckOutlined, CellComponent },
directives: {
clickOutside,
},
props: {
value: {
type: [String, Number, Boolean, Object] as PropType<string | number | boolean | Recordable>,
default: '',
},
record: {
type: Object as PropType<EditRecordRow>,
},
column: {
type: Object as PropType<BasicColumn>,
default: () => ({}),
},
index: propTypes.number,
},
setup(props) {
const table = useTableContext();
const isEdit = ref(false);
const elRef = ref();
const ruleVisible = ref(false);
const ruleMessage = ref('');
const optionsRef = ref<LabelValueOptions>([]);
const currentValueRef = ref<any>(props.value);
const defaultValueRef = ref<any>(props.value);
// const { prefixCls } = useDesign('editable-cell');
const getComponent = computed(() => props.column?.editComponent || 'NInput');
const getRule = computed(() => props.column?.editRule);
const getRuleVisible = computed(() => {
return unref(ruleMessage) && unref(ruleVisible);
});
const getIsCheckComp = computed(() => {
const component = unref(getComponent);
return ['NCheckbox', 'NRadio'].includes(component);
});
const getComponentProps = computed(() => {
const compProps = props.column?.editComponentProps ?? {};
const editComponent = props.column?.editComponent ?? null;
const component = unref(getComponent);
const apiSelectProps: Recordable = {};
const isCheckValue = unref(getIsCheckComp);
let valueField = isCheckValue ? 'checked' : 'value';
const val = unref(currentValueRef);
let value = isCheckValue ? (isNumber(val) && isBoolean(val) ? val : !!val) : val;
//TODO 特殊处理 NDatePicker 可能要根据项目 规范自行调整代码
if (component === 'NDatePicker') {
if (isString(value)) {
if (compProps.valueFormat) {
valueField = 'formatted-value';
} else {
value = parseISO(value as any).getTime();
}
} else if (isArray(value)) {
if (compProps.valueFormat) {
valueField = 'formatted-value';
} else {
value = value.map((item) => parseISO(item).getTime());
}
}
}
const onEvent: any = editComponent ? EventEnum[editComponent] : undefined;
return {
placeholder: createPlaceholderMessage(unref(getComponent)),
...apiSelectProps,
...omit(compProps, 'onChange'),
[onEvent]: handleChange,
[valueField]: value,
};
});
const getValues = computed(() => {
const { editComponentProps, editValueMap } = props.column;
const value = unref(currentValueRef);
if (editValueMap && isFunction(editValueMap)) {
return editValueMap(value);
}
const component = unref(getComponent);
if (!component.includes('NSelect')) {
return value;
}
const options: LabelValueOptions = editComponentProps?.options ?? (unref(optionsRef) || []);
const option = options.find((item) => `${item.value}` === `${value}`);
return option?.label ?? value;
});
const getWrapperClass = computed(() => {
const { align = 'center' } = props.column;
return `edit-cell-align-${align}`;
});
const getRowEditable = computed(() => {
const { editable } = props.record || {};
return !!editable;
});
watchEffect(() => {
defaultValueRef.value = props.value;
});
watchEffect(() => {
const { editable } = props.column;
if (isBoolean(editable) || isBoolean(unref(getRowEditable))) {
isEdit.value = !!editable || unref(getRowEditable);
}
});
function handleEdit() {
if (unref(getRowEditable) || unref(props.column?.editRow)) return;
ruleMessage.value = '';
isEdit.value = true;
nextTick(() => {
const el = unref(elRef);
el?.focus?.();
});
}
async function handleChange(e: any) {
const component = unref(getComponent);
const compProps = props.column?.editComponentProps ?? {};
if (!e) {
currentValueRef.value = e;
} else if (e?.target && Reflect.has(e.target, 'value')) {
currentValueRef.value = (e as ChangeEvent).target.value;
} else if (component === 'NCheckbox') {
currentValueRef.value = (e as ChangeEvent).target.checked;
} else if (isString(e) || isBoolean(e) || isNumber(e)) {
currentValueRef.value = e;
}
//TODO 特殊处理 NDatePicker 可能要根据项目 规范自行调整代码
if (component === 'NDatePicker') {
if (isNumber(currentValueRef.value)) {
if (compProps.valueFormat) {
currentValueRef.value = format(currentValueRef.value, compProps.valueFormat);
}
} else if (isArray(currentValueRef.value)) {
if (compProps.valueFormat) {
currentValueRef.value = currentValueRef.value.map((item) => {
format(item, compProps.valueFormat);
});
}
}
}
const onChange = props.column?.editComponentProps?.onChange;
if (onChange && isFunction(onChange)) onChange(...arguments);
table.emit?.('edit-change', {
column: props.column,
value: unref(currentValueRef),
record: toRaw(props.record),
});
await handleSubmiRule();
}
async function handleSubmiRule() {
const { column, record } = props;
const { editRule } = column;
const currentValue = unref(currentValueRef);
if (editRule) {
if (isBoolean(editRule) && !currentValue && !isNumber(currentValue)) {
ruleVisible.value = true;
const component = unref(getComponent);
ruleMessage.value = createPlaceholderMessage(component);
return false;
}
if (isFunction(editRule)) {
const res = await editRule(currentValue, record as Recordable);
if (!!res) {
ruleMessage.value = res;
ruleVisible.value = true;
return false;
} else {
ruleMessage.value = '';
return true;
}
}
}
ruleMessage.value = '';
return true;
}
async function handleSubmit(needEmit = true, valid = true) {
if (valid) {
const isPass = await handleSubmiRule();
if (!isPass) return false;
}
const { column, index, record } = props;
if (!record) return false;
const { key } = column;
const value = unref(currentValueRef);
if (!key) return;
const dataKey = key as string;
set(record, dataKey, value);
//const record = await table.updateTableData(index, dataKey, value);
needEmit && table.emit?.('edit-end', { record, index, key, value });
isEdit.value = false;
}
async function handleEnter() {
if (props.column?.editRow) {
return;
}
await handleSubmit();
}
function handleCancel() {
isEdit.value = false;
currentValueRef.value = defaultValueRef.value;
const { column, index, record } = props;
const { key } = column;
ruleVisible.value = true;
ruleMessage.value = '';
table.emit?.('edit-cancel', {
record,
index,
key: key,
value: unref(currentValueRef),
});
}
function onClickOutside() {
if (props.column?.editable || unref(getRowEditable)) {
return;
}
const component = unref(getComponent);
if (component.includes('NInput')) {
handleCancel();
}
}
// only ApiSelect
function handleOptionsChange(options: LabelValueOptions) {
optionsRef.value = options;
}
function initCbs(cbs: 'submitCbs' | 'validCbs' | 'cancelCbs', handle: Fn) {
if (props.record) {
/* eslint-disable */
isArray(props.record[cbs])
? props.record[cbs]?.push(handle)
: (props.record[cbs] = [handle]);
}
}
if (props.record) {
initCbs('submitCbs', handleSubmit);
initCbs('validCbs', handleSubmiRule);
initCbs('cancelCbs', handleCancel);
if (props.column.key) {
if (!props.record.editValueRefs) props.record.editValueRefs = {};
props.record.editValueRefs[props.column.key] = currentValueRef;
}
/* eslint-disable */
props.record.onCancelEdit = () => {
isArray(props.record?.cancelCbs) && props.record?.cancelCbs.forEach((fn) => fn());
};
/* eslint-disable */
props.record.onSubmitEdit = async () => {
if (isArray(props.record?.submitCbs)) {
const validFns = (props.record?.validCbs || []).map((fn) => fn());
const res = await Promise.all(validFns);
const pass = res.every((item) => !!item);
if (!pass) return;
const submitFns = props.record?.submitCbs || [];
submitFns.forEach((fn) => fn(false, false));
table.emit?.('edit-row-end');
return true;
}
};
}
return {
isEdit,
handleEdit,
currentValueRef,
handleSubmit,
handleChange,
handleCancel,
elRef,
getComponent,
getRule,
onClickOutside,
ruleMessage,
getRuleVisible,
getComponentProps,
handleOptionsChange,
getWrapperClass,
getRowEditable,
getValues,
handleEnter,
// getSize,
};
},
});
</script>
<style lang="less">
.editable-cell {
&-content {
position: relative;
overflow-wrap: break-word;
word-break: break-word;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
&-comp {
flex: 1;
}
.edit-icon {
font-size: 14px;
//position: absolute;
//top: 4px;
//right: 0;
display: none;
width: 20px;
cursor: pointer;
}
&:hover {
.edit-icon {
display: inline-block;
}
}
}
&-action {
display: flex;
align-items: center;
justify-content: center;
}
}
</style>
@@ -0,0 +1,15 @@
import { ComponentType } from '../../types/componentType';
/**
* @description: 生成placeholder
*/
export function createPlaceholderMessage(component: ComponentType) {
if (component === 'NInput') return '请输入';
if (
['NPicker', 'NSelect', 'NCheckbox', 'NRadio', 'NSwitch', 'NDatePicker', 'NTimePicker'].includes(
component
)
)
return '请选择';
return '';
}
@@ -0,0 +1,49 @@
import type { BasicColumn } from '@/components/Table/src/types/table';
import { h, Ref } from 'vue';
import EditableCell from './EditableCell.vue';
export function renderEditCell(column: BasicColumn) {
return (record, index) => {
const _key = column.key;
const value = record[_key];
record.onEdit = async (edit: boolean, submit = false) => {
if (!submit) {
record.editable = edit;
}
if (!edit && submit) {
const res = await record.onSubmitEdit?.();
if (res) {
record.editable = false;
return true;
}
return false;
}
// cancel
if (!edit && !submit) {
record.onCancelEdit?.();
}
return true;
};
return h(EditableCell, {
value,
record,
column,
index,
});
};
}
export type EditRecordRow<T = Recordable> = Partial<
{
onEdit: (editable: boolean, submit?: boolean) => Promise<boolean>;
editable: boolean;
onCancel: Fn;
onSubmit: Fn;
submitCbs: Fn[];
cancelCbs: Fn[];
validCbs: Fn[];
editValueRefs: Recordable<Ref>;
} & T
>;
@@ -0,0 +1,318 @@
<template>
<n-tooltip trigger="hover">
<template #trigger>
<div class="cursor-pointer table-toolbar-right-icon">
<n-popover trigger="click" :width="230" class="toolbar-popover" placement="bottom-end">
<template #trigger>
<n-icon size="18">
<SettingOutlined />
</n-icon>
</template>
<template #header>
<div class="table-toolbar-inner-popover-title">
<n-space>
<n-checkbox v-model:checked="checkAll" @update:checked="onCheckAll"
>列展示</n-checkbox
>
<n-checkbox v-model:checked="selection" @update:checked="onSelection"
>勾选列</n-checkbox
>
<n-button text type="info" size="small" class="mt-1" @click="resetColumns"
>重置</n-button
>
</n-space>
</div>
</template>
<div class="table-toolbar-inner">
<n-checkbox-group v-model:value="checkList" @update:value="onChange">
<Draggable
v-model="columnsList"
animation="300"
item-key="key"
filter=".no-draggable"
:move="onMove"
@end="draggableEnd"
>
<template #item="{ element }">
<div
class="table-toolbar-inner-checkbox"
:class="{
'table-toolbar-inner-checkbox-dark': getDarkTheme === true,
'no-draggable': element.draggable === false,
}"
>
<span
class="drag-icon"
:class="{ 'drag-icon-hidden': element.draggable === false }"
>
<n-icon size="18">
<DragOutlined />
</n-icon>
</span>
<n-checkbox :value="element.key" :label="element.title" />
<div class="fixed-item">
<n-tooltip trigger="hover" placement="bottom">
<template #trigger>
<n-icon
size="18"
:color="element.fixed === 'left' ? '#2080f0' : undefined"
class="cursor-pointer"
@click="fixedColumn(element, 'left')"
>
<VerticalRightOutlined />
</n-icon>
</template>
<span>固定到左侧</span>
</n-tooltip>
<n-divider vertical />
<n-tooltip trigger="hover" placement="bottom">
<template #trigger>
<n-icon
size="18"
:color="element.fixed === 'right' ? '#2080f0' : undefined"
class="cursor-pointer"
@click="fixedColumn(element, 'right')"
>
<VerticalLeftOutlined />
</n-icon>
</template>
<span>固定到右侧</span>
</n-tooltip>
</div>
</div>
</template>
</Draggable>
</n-checkbox-group>
</div>
</n-popover>
</div>
</template>
<span>列设置</span>
</n-tooltip>
</template>
<script lang="ts">
import { ref, defineComponent, reactive, unref, toRaw, computed, toRefs, watchEffect } from 'vue';
import { useTableContext } from '../../hooks/useTableContext';
import { cloneDeep } from 'lodash-es';
import {
SettingOutlined,
DragOutlined,
VerticalRightOutlined,
VerticalLeftOutlined,
} from '@vicons/antd';
import Draggable from 'vuedraggable';
import { useDesignSetting } from '@/hooks/setting/useDesignSetting';
interface Options {
title: string;
key: string;
fixed?: boolean | 'left' | 'right';
}
export default defineComponent({
name: 'ColumnSetting',
components: {
SettingOutlined,
DragOutlined,
Draggable,
VerticalRightOutlined,
VerticalLeftOutlined,
},
props: {
openChecked: {
type: Boolean as PropType<Boolean>,
default: false,
},
},
setup(props) {
const { getDarkTheme } = useDesignSetting();
const table: any = useTableContext();
const columnsList = ref<Options[]>([]);
const cacheColumnsList = ref<Options[]>([]);
const state = reactive({
selection: false,
checkAll: true,
checkList: [],
defaultCheckList: [],
});
const getSelection = computed(() => {
return state.selection;
});
watchEffect(() => {
const columns = table.getColumns();
if (columns.length) {
init();
}
});
//初始化
function init() {
const columns: any[] = getColumns();
const checkList: any = columns.map((item) => item.key);
state.checkList = checkList;
state.defaultCheckList = checkList;
const newColumns = columns.filter((item) => item.key != 'action' && item.title != '操作');
if (!columnsList.value.length) {
columnsList.value = cloneDeep(newColumns);
cacheColumnsList.value = cloneDeep(newColumns);
// 只有首次加载时需要执行,重复执行会导致生成多个选项
if (props.openChecked) {
state.selection = true;
if (newColumns[0].type != 'selection' && newColumns[0].key != 'selection') {
onSelection(true);
}
}
}
}
//切换
function onChange(checkList) {
if (state.selection) {
checkList.unshift('selection');
}
setColumns(checkList);
}
//设置
function setColumns(columns) {
table.setColumns(columns);
}
//获取
function getColumns() {
let newRet: any[] = [];
table.getColumns().forEach((item) => {
newRet.push({ ...item });
});
return newRet;
}
//重置
function resetColumns() {
state.checkList = [...state.defaultCheckList];
state.checkAll = true;
let cacheColumnsKeys: any[] = table.getCacheColumns();
let newColumns = cacheColumnsKeys.map((item) => {
return {
...item,
fixed: undefined,
};
});
setColumns(newColumns);
columnsList.value = newColumns;
}
//全选
function onCheckAll(e) {
let checkList = table.getCacheColumns(true);
if (e) {
setColumns(checkList);
state.checkList = checkList;
} else {
setColumns([]);
state.checkList = [];
}
}
//拖拽排序
function draggableEnd() {
const newColumns = toRaw(unref(columnsList));
columnsList.value = newColumns;
setColumns(newColumns);
}
//勾选列
function onSelection(e) {
let checkList = table.getCacheColumns();
if (e) {
checkList.unshift({ type: 'selection', key: 'selection' });
setColumns(checkList);
} else {
checkList.splice(0, 1);
setColumns(checkList);
}
}
function onMove(e) {
if (e.draggedContext.element.draggable === false) return false;
return true;
}
//固定
function fixedColumn(item, fixed) {
if (!state.checkList.includes(item.key)) return;
let columns = getColumns();
const isFixed = item.fixed === fixed ? undefined : fixed;
let index = columns.findIndex((res) => res.key === item.key);
if (index !== -1) {
columns[index].fixed = isFixed;
}
table.setCacheColumnsField(item.key, { fixed: isFixed });
columnsList.value[index].fixed = isFixed;
setColumns(columns);
}
return {
...toRefs(state),
columnsList,
getDarkTheme,
onChange,
onCheckAll,
onSelection,
onMove,
resetColumns,
fixedColumn,
draggableEnd,
getSelection,
};
},
});
</script>
<style lang="less">
.table-toolbar {
&-inner-popover-title {
padding: 3px 0;
}
&-right {
&-icon {
margin-left: 12px;
font-size: 16px;
color: var(--text-color);
cursor: pointer;
:hover {
color: #1890ff;
}
}
}
}
.table-toolbar-inner {
&-checkbox {
display: flex;
align-items: center;
padding: 10px 14px;
&:hover {
background: #e6f7ff;
}
.drag-icon {
display: inline-flex;
margin-right: 8px;
cursor: move;
&-hidden {
visibility: hidden;
cursor: default;
}
}
.fixed-item {
display: flex;
align-items: center;
justify-content: flex-end;
margin-left: auto;
}
.ant-checkbox-wrapper {
flex: 1;
&:hover {
color: #1890ff !important;
}
}
}
&-checkbox-dark {
&:hover {
background: hsla(0, 0%, 100%, 0.08);
}
}
}
.toolbar-popover {
.n-popover__content {
padding: 0;
}
}
</style>
+11
View File
@@ -0,0 +1,11 @@
import componentSetting from '@/settings/componentSetting';
const { table } = componentSetting;
const { apiSetting, defaultPageSize, pageSizes } = table;
export const DEFAULTPAGESIZE = defaultPageSize;
export const APISETTING = apiSetting;
export const PAGESIZES = pageSizes;
@@ -0,0 +1,171 @@
import { ref, Ref, ComputedRef, unref, computed, watch, toRaw, h } from 'vue';
import type { BasicColumn, BasicTableProps } from '../types/table';
import { isEqual, cloneDeep } from 'lodash-es';
import { isArray, isString, isBoolean, isFunction } from '@/utils/is';
import { usePermission } from '@/hooks/web/usePermission';
import { ActionItem } from '@/components/Table';
import { renderEditCell } from '../components/editable';
import { NTooltip, NIcon } from 'naive-ui';
import { FormOutlined } from '@vicons/antd';
export function useColumns(propsRef: ComputedRef<BasicTableProps>) {
const columnsRef = ref(unref(propsRef).columns) as unknown as Ref<BasicColumn[]>;
let cacheColumns = unref(propsRef).columns;
const getColumnsRef = computed(() => {
const columns = cloneDeep(unref(columnsRef));
handleActionColumn(propsRef, columns);
if (!columns) return [];
return columns;
});
const { hasPermission } = usePermission();
function isIfShow(action: ActionItem): boolean {
const ifShow = action.ifShow;
let isIfShow = true;
if (isBoolean(ifShow)) {
isIfShow = ifShow;
}
if (isFunction(ifShow)) {
isIfShow = ifShow(action);
}
return isIfShow;
}
const renderTooltip = (trigger, content) => {
return h(NTooltip, null, {
trigger: () => trigger,
default: () => content,
});
};
const getPageColumns = computed(() => {
const pageColumns = unref(getColumnsRef);
const columns = cloneDeep(pageColumns);
return columns
.filter((column) => {
return hasPermission(column.auth as string[]) && isIfShow(column);
})
.map((column) => {
//默认 ellipsis 为true
column.ellipsis = typeof column.ellipsis === 'undefined' ? { tooltip: true } : false;
const { edit } = column;
if (edit) {
column.render = renderEditCell(column);
if (edit) {
const title: any = column.title;
column.title = () => {
return renderTooltip(
h('span', {}, [
h('span', { style: { 'margin-right': '5px' } }, title),
h(
NIcon,
{
size: 14,
},
{
default: () => h(FormOutlined),
}
),
]),
'该列可编辑'
);
};
}
}
return column;
});
});
watch(
() => unref(propsRef).columns,
(columns) => {
columnsRef.value = columns;
cacheColumns = columns;
}
);
function handleActionColumn(propsRef: ComputedRef<BasicTableProps>, columns: BasicColumn[]) {
const { actionColumn } = unref(propsRef);
if (!actionColumn) return;
if (!columns.find((col) => col.key === 'action')) {
if ((actionColumn as any).fixed && (actionColumn as any).fixed === 'left') {
columns.unshift({
...(actionColumn as any),
});
} else {
columns.push({
...(actionColumn as any),
});
}
}
}
//设置
function setColumns(columnList: string[]) {
const columns: any[] = cloneDeep(columnList);
if (!isArray(columns)) return;
if (!columns.length) {
columnsRef.value = [];
return;
}
const cacheKeys = cacheColumns.map((item) => item.key);
//针对拖拽排序
if (!isString(columns[0])) {
columnsRef.value = columns;
} else {
const newColumns: any[] = [];
cacheColumns.forEach((item) => {
if (columnList.includes(item.key)) {
newColumns.push({ ...item });
}
});
if (!isEqual(cacheKeys, columns)) {
newColumns.sort((prev, next) => {
return cacheKeys.indexOf(prev.key) - cacheKeys.indexOf(next.key);
});
}
columnsRef.value = newColumns;
}
}
//获取
function getColumns(): BasicColumn[] {
const columns = toRaw(unref(getColumnsRef));
return columns.map((item) => {
return { ...item, title: item.title, key: item.key, fixed: item.fixed || undefined };
});
}
//获取原始
function getCacheColumns(isKey?: boolean): any[] {
return isKey ? cacheColumns.map((item) => item.key) : cacheColumns;
}
//更新原始数据单个字段
function setCacheColumnsField(key: string | undefined, value: Partial<BasicColumn>) {
if (!key || !value) {
return;
}
cacheColumns.forEach((item) => {
if (item.key === key) {
Object.assign(item, value);
return;
}
});
}
return {
getColumnsRef,
getCacheColumns,
setCacheColumnsField,
setColumns,
getColumns,
getPageColumns,
};
}
@@ -0,0 +1,179 @@
import { ref, ComputedRef, unref, computed, onMounted, watchEffect, watch } from 'vue';
import type { BasicTableProps } from '../types/table';
import type { PaginationProps } from '../types/pagination';
import { isBoolean, isFunction } from '@/utils/is';
import { APISETTING } from '../const';
export function useDataSource(
propsRef: ComputedRef<BasicTableProps>,
{ getPaginationInfo, setPagination, setLoading, tableData },
emit
) {
const dataSourceRef = ref<Recordable[]>([]);
watchEffect(() => {
tableData.value = unref(dataSourceRef);
});
watch(
() => unref(propsRef).dataSource,
() => {
const { dataSource }: any = unref(propsRef);
dataSource && (dataSourceRef.value = dataSource);
},
{
immediate: true,
}
);
const getRowKey = computed(() => {
const { rowKey }: any = unref(propsRef);
return rowKey
? rowKey
: () => {
return 'key';
};
});
const getDataSourceRef = computed(() => {
const dataSource = unref(dataSourceRef);
if (!dataSource || dataSource.length === 0) {
return unref(dataSourceRef);
}
return unref(dataSourceRef);
});
async function fetch(opt?) {
try {
setLoading(true);
const { request, pagination, beforeRequest, afterRequest }: any = unref(propsRef);
if (!request) return;
//组装分页信息
const pageField = APISETTING.pageField;
const sizeField = APISETTING.sizeField;
const totalField = APISETTING.totalField;
const listField = APISETTING.listField;
const itemCountField = APISETTING.itemCountField;
let pageParams = {};
const { page = 1, pageSize = 10 } = unref(getPaginationInfo) as PaginationProps;
if ((isBoolean(pagination) && !pagination) || isBoolean(getPaginationInfo)) {
pageParams = {};
} else {
pageParams[pageField] = (opt && opt[pageField]) || page;
pageParams[sizeField] = pageSize;
}
let params = {
...pageParams,
};
if (beforeRequest && isFunction(beforeRequest)) {
// The params parameter can be modified by outsiders
params = (await beforeRequest(params)) || params;
}
const res = await request(params);
const resultTotal = res[totalField] || 0;
const currentPage = res[pageField];
const itemCount = res[itemCountField] || 0;
// 如果数据异常,需获取正确的页码再次执行
if (resultTotal) {
if (page > resultTotal) {
setPagination({
[pageField]: resultTotal,
});
fetch(opt);
}
}
let resultInfo = res[listField] ? res[listField] : [];
if (afterRequest && isFunction(afterRequest)) {
// can modify the data returned by the interface for processing
resultInfo = (await afterRequest(resultInfo)) || resultInfo;
}
// 表数据加载
setTimeout(function () {
const once = 20;
if (resultInfo.length < once) {
// once = resultInfo.length;
// 直接加载
dataSourceRef.value = resultInfo;
} else {
// 分次加载
dataSourceRef.value = [];
insert(resultInfo, once, resultInfo.length, 0);
}
}, 0);
setPagination({
[pageField]: currentPage,
[totalField]: resultTotal,
prefix: () => {
return `共 ${itemCount} 条`;
},
});
if (opt && opt[pageField]) {
setPagination({
[pageField]: opt[pageField] || 1,
});
}
emit('fetch-success', {
items: unref(resultInfo),
resultTotal,
});
} catch (error) {
console.error(error);
emit('fetch-error', error);
dataSourceRef.value = [];
// setPagination({
// pageCount: 0,
// });
} finally {
setLoading(false);
}
}
onMounted(() => {
setTimeout(() => {
fetch();
}, 16);
});
function insert(resultInfo, once, curTotal, curIndex) {
if (curTotal <= 0) {
return;
}
if (once > curTotal) {
once = curTotal;
}
window.requestAnimationFrame(() => {
for (let i = 0; i < once; i++) {
dataSourceRef.value.push(resultInfo[curIndex + i]);
}
insert(resultInfo, once, curTotal - once, curIndex + once);
});
}
function setTableData(values) {
dataSourceRef.value = values;
}
function getDataSource(): any[] {
return getDataSourceRef.value;
}
async function reload(opt?) {
await fetch(opt);
}
return {
fetch,
getRowKey,
getDataSourceRef,
getDataSource,
setTableData,
reload,
};
}
@@ -0,0 +1,21 @@
import { ref, ComputedRef, unref, computed, watch } from 'vue';
import type { BasicTableProps } from '../types/table';
export function useLoading(props: ComputedRef<BasicTableProps>) {
const loadingRef = ref(unref(props).loading);
watch(
() => unref(props).loading,
(loading) => {
loadingRef.value = loading;
}
);
const getLoading = computed(() => unref(loadingRef));
function setLoading(loading: boolean) {
loadingRef.value = loading;
}
return { getLoading, setLoading };
}
@@ -0,0 +1,50 @@
import type { PaginationProps } from '../types/pagination';
import type { BasicTableProps } from '../types/table';
import { computed, unref, ref, ComputedRef } from 'vue';
import { isBoolean } from '@/utils/is';
import { APISETTING, DEFAULTPAGESIZE, PAGESIZES } from '../const';
export function usePagination(refProps: ComputedRef<BasicTableProps>) {
const configRef = ref<PaginationProps>({});
const show = ref(true);
const getPaginationInfo = computed((): PaginationProps | boolean => {
const { pagination } = unref(refProps);
if (!unref(show) || (isBoolean(pagination) && !pagination)) {
return false;
}
const { totalField } = APISETTING;
return {
pageSize: DEFAULTPAGESIZE,
pageSizes: PAGESIZES,
showSizePicker: true,
showQuickJumper: true,
...(isBoolean(pagination) ? {} : pagination),
...unref(configRef),
pageCount: unref(configRef)[totalField],
};
});
function setPagination(info: Partial<PaginationProps>) {
const paginationInfo = unref(getPaginationInfo);
configRef.value = {
...(!isBoolean(paginationInfo) ? paginationInfo : {}),
...info,
};
}
function getPagination() {
return unref(getPaginationInfo);
}
function getShowPagination() {
return unref(show);
}
async function setShowPagination(flag: boolean) {
show.value = flag;
}
return { getPagination, getPaginationInfo, setShowPagination, getShowPagination, setPagination };
}
@@ -0,0 +1,22 @@
import type { Ref } from 'vue';
import type { BasicTableProps, TableActionType } from '../types/table';
import { provide, inject, ComputedRef } from 'vue';
const key = Symbol('s-table');
type Instance = TableActionType & {
wrapRef: Ref<Nullable<HTMLElement>>;
getBindValues: ComputedRef<Recordable>;
};
type RetInstance = Omit<Instance, 'getBindValues'> & {
getBindValues: ComputedRef<BasicTableProps>;
};
export function createTableContext(instance: Instance) {
provide(key, instance);
}
export function useTableContext(): RetInstance {
return inject(key) as RetInstance;
}
+67
View File
@@ -0,0 +1,67 @@
import type { PropType } from 'vue';
import { propTypes } from '@/utils/propTypes';
import { BasicColumn } from './types/table';
import { NDataTable } from 'naive-ui';
export const basicProps = {
...NDataTable.props, // 这里继承原 UI 组件的 props
title: {
type: String,
default: null,
},
titleTooltip: {
type: String,
default: null,
},
showTopRight: {
type: Boolean,
default: true,
},
openChecked: {
type: Boolean,
default: false,
},
size: {
type: String,
default: 'medium',
},
dataSource: {
type: [Object],
default: () => [],
},
columns: {
type: [Array] as PropType<BasicColumn[]>,
default: () => [],
required: true,
},
beforeRequest: {
type: Function as PropType<(...arg: any[]) => void | Promise<any>>,
default: null,
},
request: {
type: Function as PropType<(...arg: any[]) => Promise<any>>,
default: null,
},
afterRequest: {
type: Function as PropType<(...arg: any[]) => void | Promise<any>>,
default: null,
},
rowKey: {
type: [String, Function] as PropType<string | ((record) => string)>,
default: undefined,
},
pagination: {
type: [Object, Boolean],
default: () => {},
},
//废弃
showPagination: {
type: [String, Boolean],
default: 'auto',
},
actionColumn: {
type: Object as PropType<BasicColumn>,
default: null,
},
canResize: propTypes.bool.def(true),
resizeHeightOffset: propTypes.number.def(0),
};
@@ -0,0 +1,8 @@
export type ComponentType =
| 'NInput'
| 'NInputNumber'
| 'NSelect'
| 'NCheckbox'
| 'NSwitch'
| 'NDatePicker'
| 'NTimePicker';
@@ -0,0 +1,8 @@
export interface PaginationProps {
page?: number;
pageCount?: number;
pageSize?: number;
pageSizes?: number[];
showSizePicker?: boolean;
showQuickJumper?: boolean;
}
@@ -0,0 +1,38 @@
import type { TableBaseColumn } from 'naive-ui/lib/data-table/src/interface';
import { ComponentType } from './componentType';
export interface BasicColumn extends TableBaseColumn {
//编辑表格
edit?: boolean;
editRow?: boolean;
editable?: boolean;
editComponent?: ComponentType;
editComponentProps?: Recordable;
editRule?: boolean | ((text: string, record: Recordable) => Promise<string>);
editValueMap?: (value: any) => string;
onEditRow?: () => void;
// 权限编码控制是否显示
auth?: string[];
// 业务控制是否显示
ifShow?: boolean | ((column: BasicColumn) => boolean);
// 控制是否支持拖拽,默认支持
draggable?: boolean;
}
export interface TableActionType {
reload: (opt) => Promise<void>;
emit?: any;
getColumns: (opt?) => BasicColumn[];
setColumns: (columns: BasicColumn[] | string[]) => void;
}
export interface BasicTableProps {
title?: string;
dataSource: Function;
columns: any[];
pagination: object;
showPagination: boolean;
actionColumn: any[];
canResize: boolean;
resizeHeightOffset: number;
loading: boolean;
}
@@ -0,0 +1,27 @@
import { NButton } from 'naive-ui';
import type { Component } from 'vue';
import { PermissionsEnum } from '@/enums/permissionsEnum';
export interface ActionItem extends Partial<InstanceType<typeof NButton>> {
onClick?: Fn;
label?: string;
type?: 'success' | 'error' | 'warning' | 'info' | 'primary' | 'default';
// 设定 color 后会覆盖 type 的样式
color?: string;
icon?: Component;
popConfirm?: PopConfirm;
disabled?: boolean;
divider?: boolean;
// 权限编码控制是否显示
auth?: PermissionsEnum | PermissionsEnum[] | string | string[];
// 业务控制是否显示
ifShow?: boolean | ((action: ActionItem) => boolean);
}
export interface PopConfirm {
title: string;
okText?: string;
cancelText?: string;
confirm: Fn;
cancel?: Fn;
icon?: Component;
}
+1
View File
@@ -0,0 +1 @@
export { default as BasicUpload } from './src/BasicUpload.vue';
@@ -0,0 +1,197 @@
<template>
<div>
<n-modal
v-model:show="showModal"
:mask-closable="false"
:show-icon="false"
preset="dialog"
:on-after-leave="handleRemove"
:style="{
width: width,
}"
title="上传大文件"
>
<n-upload
directory-dnd
:custom-request="handleUpload"
:on-remove="handleRemove"
name="file"
:disabled="uploadStatus != 0 && uploadStatus != 3"
>
<n-upload-dragger>
<div style="margin-bottom: 12px">
<n-icon size="48" :depth="3">
<FileAddOutlined />
</n-icon>
</div>
<template v-if="uploadStatus == 0 || uploadStatus == 3">
<n-text style="font-size: 16px">点击或者拖动{{ typeTag }}到该区域来上传</n-text>
<n-p depth="3" style="margin: 8px 0 0 0">支持大文件分片上传,支持断点续传</n-p>
</template>
<template v-else-if="uploadStatus == 1">
<span style="font-weight: 600">解析中,请稍候...</span>
</template>
<template v-else-if="uploadStatus == 2">
<span style="font-weight: 600">正在上传({{ progress }}%)...</span>
<n-p depth="3" style="margin: 8px 0 0 0">文件大小:{{ sizeFormat }}</n-p>
</template>
</n-upload-dragger>
</n-upload>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { NModal, UploadCustomRequestOptions, useMessage, useDialog } from 'naive-ui';
import { FileAddOutlined } from '@vicons/antd';
import SparkMD5 from 'spark-md5';
import { Attachment, FileType, getFileType } from '@/components/FileChooser/src/model';
import { CheckMultipart, UploadPart } from '@/api/base';
import type { UploadFileParams } from '@/utils/http/axios/types';
export interface Props {
width?: string;
uploadType?: FileType;
}
const props = withDefaults(defineProps<Props>(), {
width: '60%',
uploadType: 'default',
});
const emit = defineEmits(['onFinish']);
const message = useMessage();
const dialog = useDialog();
const showModal = ref(false);
const chunkSize = 2 * 1024 * 1024; // 每个分片大小限制,默认2M
const uploadStatus = ref(0); // 上传状态 0等待上传 1解析中 2上传中 3已取消
const progress = ref(0);
const sizeFormat = ref('0B');
const typeTag = computed(() => {
return getFileType(props.uploadType);
});
// 取消上传
function handleRemove() {
if (uploadStatus.value == 1 || uploadStatus.value == 2) {
uploadStatus.value = 3;
dialog.info({
title: '提示',
content: '已取消大文件上传,已上传的文件不会自动删除,重新操作可进行断点续传',
positiveText: '确定',
});
}
}
// 开始上传
function handleUpload(options: UploadCustomRequestOptions) {
uploadStatus.value = 1;
// 初始化上传进度
updateProgress(options, 0);
const file = options.file.file as File;
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(file);
fileReader.onload = async (e) => {
const spark = new SparkMD5.ArrayBuffer();
spark.append(e.target.result);
let md5 = spark.end();
let start = 0;
let end = 0;
let index = 0;
let shards: any[] = [];
while (end < file.size) {
start = index * chunkSize;
end = (index + 1) * chunkSize;
const params: UploadFileParams = {
uploadType: props.uploadType,
md5: md5,
index: index + 1,
fileName: file.name,
file: file.slice(start, end),
};
const shard = { index: index + 1, params: params };
shards.push(shard);
index++;
}
uploadStatus.value = 2;
const params = {
uploadType: props.uploadType,
fileName: file.name,
size: file.size,
md5: md5,
shardCount: shards.length,
};
CheckMultipart(params)
.then(async (res) => {
// 已存在
if (!res.waitUploadIndex || res.waitUploadIndex.length == 0) {
onFinish(options, res.attachment);
return;
}
// 断点续传,过滤掉已上传成功的分片文件
shards = shards.filter((shard) => res.waitUploadIndex.includes(shard.index));
if (shards.length == 0) {
onFinish(options, res.attachment);
return;
}
// 导入断点续传进度
updateProgress(options, res.progress);
sizeFormat.value = res.sizeFormat;
for (const item of shards) {
if (uploadStatus.value == 3) {
break;
}
item.params.uploadId = res.uploadId;
await handleUploadPart(options, item);
}
})
.catch(() => {
uploadStatus.value = 0;
options.onError();
});
};
}
// 上传分片文件
async function handleUploadPart(options: UploadCustomRequestOptions, item) {
const res = await UploadPart(item.params);
updateProgress(options, res.progress);
if (res.finish) {
onFinish(options, res.attachment);
}
}
// 更新上传进度
function updateProgress(options: UploadCustomRequestOptions, value: number) {
options.onProgress({ percent: value });
progress.value = value;
}
// 上传成功后的回调
function onFinish(options: UploadCustomRequestOptions, result: Attachment) {
options.onFinish();
message.success('上传成功');
uploadStatus.value = 0;
emit('onFinish', result, true);
}
function openModal() {
showModal.value = true;
uploadStatus.value = 0;
}
defineExpose({
openModal,
});
</script>
@@ -0,0 +1,412 @@
<template>
<div>
<div class="w-full">
<div class="upload">
<div class="upload-card">
<!--图片列表-->
<div
class="upload-card-item"
:style="getCSSProperties"
v-for="(item, index) in imgList"
:key="`img_${index}`"
>
<div class="upload-card-item-info">
<div class="img-box">
<template v-if="fileType === 'image'">
<img :src="item" @error="errorImg($event)" />
</template>
<template v-else>
<n-avatar :style="fileAvatarCSS">{{ getFileExt(item) }}</n-avatar>
</template>
</div>
<div class="img-box-actions">
<template v-if="fileType === 'image'">
<n-icon size="18" class="mx-2 action-icon" @click="preview(item)">
<EyeOutlined />
</n-icon>
</template>
<template v-else>
<n-icon size="18" class="mx-2 action-icon" @click="download(item)">
<CloudDownloadOutlined />
</n-icon>
</template>
<n-icon size="18" class="mx-2 action-icon" @click="remove(index)">
<DeleteOutlined />
</n-icon>
</div>
</div>
</div>
<!--上传图片-->
<div
class="upload-card-item upload-card-item-select-picture"
:style="getCSSProperties"
v-if="imgList.length < maxNumber"
>
<n-upload
v-bind="$props"
:file-list-style="{ display: 'none' }"
@before-upload="beforeUpload"
@finish="finish"
>
<div class="flex flex-col justify-center">
<n-icon size="18" class="m-auto">
<PlusOutlined />
</n-icon>
<span class="upload-title">{{ uploadTitle }}</span>
</div>
</n-upload>
</div>
</div>
</div>
<!--上传图片-->
<n-space>
<n-alert title="提示" type="info" v-if="helpText" class="flex w-full">
{{ helpText }}
</n-alert>
</n-space>
</div>
<Preview ref="previewRef" />
</div>
</template>
<script lang="ts">
import { defineComponent, toRefs, reactive, computed, watch, onMounted, ref } from 'vue';
import { EyeOutlined, DeleteOutlined, PlusOutlined, CloudDownloadOutlined } from '@vicons/antd';
import { basicProps } from './props';
import { useMessage, useDialog } from 'naive-ui';
import Preview from '@/components/FileChooser/src/Preview.vue';
import { ResultEnum } from '@/enums/httpEnum';
import componentSetting from '@/settings/componentSetting';
import { useGlobSetting } from '@/hooks/setting';
import { isArray, isJsonString, isNullOrUnDef, isUrl } from '@/utils/is';
import { getFileExt } from '@/utils/urlUtils';
import { errorImg } from '@/utils/hotgo';
const globSetting = useGlobSetting();
export default defineComponent({
name: 'BasicUpload',
components: { EyeOutlined, DeleteOutlined, PlusOutlined, CloudDownloadOutlined },
props: {
...basicProps,
},
emits: ['uploadChange', 'delete'],
setup(props, { emit }) {
const getCSSProperties = computed(() => {
return {
width: `${props.width}px`,
height: `${props.height}px`,
};
});
const previewRef = ref();
const message = useMessage();
const dialog = useDialog();
const uploadTitle = ref(props.fileType === 'image' ? '上传图片' : '上传附件');
const maxNumber = computed(() => {
return props.maxNumber;
});
const fileAvatarCSS = computed(() => {
return {
'--n-merged-size': `var(--n-avatar-size-override, ${props.width * 0.8}px)`,
'--n-font-size': `18px`,
};
});
const state = reactive({
showModal: false,
previewUrl: '',
originalImgList: [] as string[],
imgList: [] as string[],
});
//赋值默认图片显示
watch(
() => props.value,
() => {
loadValue(props.value);
return;
},
{
immediate: true,
deep: true,
}
);
watch(
() => props.values,
() => {
loadValue(props.values);
return;
},
{
immediate: true,
deep: true,
}
);
// 加载默认
function loadValue(value: any) {
if (value === undefined || value === null) {
return;
}
let data: string[] = [];
if (isJsonString(value)) {
value = JSON.parse(value);
}
// 单图模式
if (typeof value === 'string') {
if (value !== '') {
data.push(value);
}
} else {
// 多图模式
data = value;
}
if (!isArray(data) || data.length === 0) {
return;
}
state.imgList = data.map((item) => {
return getImgUrl(item);
});
state.originalImgList = state.imgList;
}
//预览
function preview(url: string) {
previewRef.value.openPreview(url);
}
//下载
function download(url: string) {
window.open(url);
}
//删除
function remove(index: number) {
dialog.info({
title: '提示',
content: '你确定要删除吗?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
state.imgList.splice(index, 1);
state.originalImgList.splice(index, 1);
if (props.maxNumber === 1) {
emit('uploadChange', '');
} else {
emit('uploadChange', state.originalImgList);
}
emit('delete', state.originalImgList);
},
onNegativeClick: () => {},
});
}
//组装完整图片地址
function getImgUrl(url: string): string {
const { imgUrl } = globSetting;
return isUrl(url) ? url : `${imgUrl}${url}`;
}
function checkFileType(map: string[], fileType: string) {
if (isNullOrUnDef(map)) {
return true;
}
return map.includes(fileType);
}
//上传之前
function beforeUpload({ file }) {
const fileInfo = file.file;
// 设置最大值,则判断
if (props.maxSize && fileInfo.size / 1024 / 1024 >= props.maxSize) {
message.error(`上传文件最大值不能超过${props.maxSize}M`);
return false;
}
// 设置类型,则判断
const fileType =
props.fileType === 'image'
? componentSetting.upload.imageType
: componentSetting.upload.fileType;
if (!checkFileType(fileType, fileInfo.type)) {
console.log('checkFileType fileInfo.type:' + fileInfo.type);
message.error(`只能上传文件类型为${fileType.join(',')}`);
return false;
}
return true;
}
//上传结束
function finish({ event: Event }) {
const res = JSON.parse(Event.target.response);
const infoField = componentSetting.upload.apiSetting.infoField;
const imgField = componentSetting.upload.apiSetting.imgField;
const { code } = res;
const msg = res.msg || res.message || '上传失败';
const result = res[infoField];
//成功
if (code === ResultEnum.SUCCESS) {
let imgUrl: string = getImgUrl(result[imgField]);
state.imgList.push(imgUrl);
state.originalImgList = state.imgList;
if (props.maxNumber === 1) {
emit('uploadChange', imgUrl);
} else {
emit('uploadChange', state.originalImgList);
}
} else {
message.error(msg);
}
}
onMounted(async () => {
setTimeout(function () {
if (props.maxNumber === 1) {
loadValue(props.value);
} else {
loadValue(props.values);
}
}, 50);
});
return {
errorImg,
...toRefs(state),
finish,
preview,
download,
remove,
beforeUpload,
getCSSProperties,
uploadTitle,
fileAvatarCSS,
getFileExt,
Preview,
previewRef,
maxNumber,
};
},
});
</script>
<style lang="less">
.n-upload {
width: auto; /** 居中 */
}
.upload {
width: 100%;
overflow: hidden;
&-card {
width: auto;
height: auto;
display: flex;
flex-wrap: wrap;
align-items: center;
&-item {
margin: 0 8px 8px 0;
position: relative;
padding: 8px;
border: 1px solid #d9d9d9;
border-radius: 2px;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
&:hover {
background: 0 0;
.upload-card-item-info::before {
opacity: 1;
}
&-info::before {
opacity: 1;
}
}
&-info {
position: relative;
height: 100%;
padding: 0;
overflow: hidden;
&:hover {
.img-box-actions {
opacity: 1;
}
}
&::before {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: all 0.3s;
content: ' ';
}
.img-box {
position: relative;
//padding: 8px;
//border: 1px solid #d9d9d9;
border-radius: 2px;
}
.img-box-actions {
position: absolute;
top: 50%;
left: 50%;
z-index: 10;
white-space: nowrap;
transform: translate(-50%, -50%);
opacity: 0;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: space-between;
&:hover {
background: 0 0;
}
.action-icon {
color: rgba(255, 255, 255, 0.85);
&:hover {
cursor: pointer;
color: #fff;
}
}
}
}
}
&-item-select-picture {
border: 1px dashed #d9d9d9;
border-radius: 2px;
cursor: pointer;
background: #fafafa;
color: #666;
.upload-title {
color: #666;
}
}
}
}
</style>
+42
View File
@@ -0,0 +1,42 @@
import type { PropType } from 'vue';
import { NUpload } from 'naive-ui';
export const basicProps = {
...NUpload.props,
fileType: {
type: String,
default: 'image',
},
accept: {
type: String,
default: '.jpg,.png,.jpeg,.svg,.gif,.webp',
},
helpText: {
type: String as PropType<string>,
default: '',
},
maxSize: {
type: Number as PropType<number>,
default: 2,
},
maxNumber: {
type: Number as PropType<number>,
default: Infinity,
},
value: {
type: String as PropType<string>,
default: () => '',
},
values: {
type: (Array as PropType<string[]>) || (Object as PropType<object>),
default: () => [],
},
width: {
type: Number as PropType<number>,
default: 104,
},
height: {
type: Number as PropType<number>,
default: 104, //建议不小于这个尺寸 太小页面可能显示有异常
},
};
@@ -0,0 +1,7 @@
export interface BasicProps {
title?: string;
dataSource: Function;
columns: any[];
pagination: object;
showPagination: boolean;
}
+80
View File
@@ -0,0 +1,80 @@
<template>
<div>
<BasicUpload
:action="`${uploadUrl}${urlPrefix}/upload/file`"
:headers="uploadHeaders"
:data="{ type: 0 }"
accept="*"
name="file"
:width="100"
:height="100"
fileType="file"
:maxNumber="maxNumber"
:helpText="helpText"
@uploadChange="uploadChange"
v-model:value="image"
v-model:values="images"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, unref, reactive, watch } from 'vue';
import { BasicUpload } from '@/components/Upload';
import { useGlobSetting } from '@/hooks/setting';
import { useUserStoreWidthOut } from '@/store/modules/user';
export interface Props {
value: string | string[] | null;
maxNumber: number;
helpText?: string;
}
const globSetting = useGlobSetting();
const urlPrefix = globSetting.urlPrefix || '';
const { uploadUrl } = globSetting;
const useUserStore = useUserStoreWidthOut();
const uploadHeaders = reactive({
Authorization: useUserStore.token,
uploadType: 'default',
});
const emit = defineEmits(['update:value']);
const props = withDefaults(defineProps<Props>(), { value: '', maxNumber: 1, helpText: '' });
const image = ref<string>('');
const images = ref<string[] | object>([]);
function uploadChange(list: string | string[]) {
if (props.maxNumber === 1) {
image.value = unref(list as string);
emit('update:value', image.value);
} else {
images.value = unref(list as string[]);
emit('update:value', images.value);
}
}
function loadImage() {
if (props.maxNumber === 1) {
image.value = props.value as string;
} else {
images.value = props.value as string[];
}
}
watch(
() => props.value,
() => {
loadImage();
},
{
immediate: true,
deep: true,
}
);
onMounted(async () => {
loadImage();
});
</script>
<style lang="less"></style>
+76
View File
@@ -0,0 +1,76 @@
<template>
<BasicUpload
:action="`${uploadUrl}${urlPrefix}/upload/file`"
:headers="uploadHeaders"
:data="{ type: 0 }"
name="file"
:width="100"
:height="100"
:maxNumber="maxNumber"
:helpText="helpText"
@uploadChange="uploadChange"
v-model:value="image"
v-model:values="images"
/>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref, unref, watch } from 'vue';
import { BasicUpload } from '@/components/Upload';
import { useGlobSetting } from '@/hooks/setting';
import { useUserStoreWidthOut } from '@/store/modules/user';
export interface Props {
value: string | string[] | null;
maxNumber: number;
helpText?: string;
}
const emit = defineEmits(['update:value']);
const props = withDefaults(defineProps<Props>(), { value: '', maxNumber: 1, helpText: '' });
const image = ref<string>('');
const images = ref<string[]>([]);
const globSetting = useGlobSetting();
const urlPrefix = globSetting.urlPrefix || '';
const { uploadUrl } = globSetting;
const useUserStore = useUserStoreWidthOut();
const uploadHeaders = reactive({
Authorization: useUserStore.token,
uploadType: 'image',
});
function uploadChange(list: string | string[]) {
if (props.maxNumber === 1) {
image.value = unref(list as string);
emit('update:value', image.value);
} else {
images.value = unref(list as string[]);
emit('update:value', images.value);
}
}
function loadImage() {
if (props.maxNumber === 1) {
image.value = props.value as string;
} else {
images.value = props.value as string[];
}
}
watch(
() => props.value,
() => {
loadImage();
},
{
immediate: true,
deep: true,
}
);
onMounted(async () => {
loadImage();
});
</script>
<style lang="less"></style>
+445
View File
@@ -0,0 +1,445 @@
<template>
<div class="yaml-editor-container">
<div
ref="editorRef"
class="yaml-editor"
:class="{ 'error-state': hasError }"
/>
<div v-if="errorMessage" class="error-message">
<n-alert type="error" closable @close="clearError">
{{ errorMessage }}
</n-alert>
</div>
<div v-if="showStats" class="editor-stats">
<n-space size="small">
<n-tag size="small" type="info">
行数: {{ lineCount }}
</n-tag>
<n-tag size="small" type="success" v-if="!hasError">
✓ 语法正确
</n-tag>
<n-tag size="small" type="error" v-if="hasError">
✗ 语法错误
</n-tag>
</n-space>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick, computed } from 'vue';
import { NAlert, NSpace, NTag, useMessage } from 'naive-ui';
import { EditorView, basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { yaml } from '@codemirror/lang-yaml';
import { oneDark } from '@codemirror/theme-one-dark';
import * as yamlParser from 'js-yaml';
export interface Props {
value?: string;
placeholder?: string;
height?: string | number;
disabled?: boolean;
readonly?: boolean;
darkTheme?: boolean;
showStats?: boolean;
validateOnChange?: boolean;
validateOnBlur?: boolean;
preventInvalidSubmit?: boolean;
}
const emit = defineEmits(['update:value', 'blur', 'focus', 'error', 'valid', 'change', 'validation-change']);
const message = useMessage();
const props = withDefaults(defineProps<Props>(), {
value: '',
placeholder: '# YAML 配置示例\n# 支持注释\nkey: value\nlist:\n - item1\n - item2\nconfig:\n enabled: true\n timeout: 30',
height: 300,
disabled: false,
readonly: false,
darkTheme: false,
showStats: true,
validateOnChange: false,
validateOnBlur: true,
preventInvalidSubmit: true,
});
const editorRef = ref<HTMLElement>();
const editorView = ref<EditorView>();
const errorMessage = ref('');
const hasError = ref(false);
const currentValue = ref(props.value || '');
// 计算属性
const lineCount = computed(() => {
return currentValue.value.split('\n').length;
});
const isValid = computed(() => {
return !hasError.value || !currentValue.value.trim();
});
// 监听外部value变化
watch(
() => props.value,
(newValue) => {
if (newValue !== currentValue.value && editorView.value) {
currentValue.value = newValue || '';
editorView.value.dispatch({
changes: {
from: 0,
to: editorView.value.state.doc.length,
insert: newValue || ''
}
});
clearError();
}
},
{ immediate: false }
);
// 验证YAML格式
function validateYaml(content: string): boolean {
if (!content.trim()) {
clearError();
return true;
}
try {
yamlParser.load(content);
clearError();
return true;
} catch (error: any) {
const errorMsg = `YAML语法错误: ${error.message}`;
errorMessage.value = errorMsg;
hasError.value = true;
emit('error', { message: errorMsg, error });
return false;
}
}
function clearError() {
errorMessage.value = '';
hasError.value = false;
}
// 内容变化处理
function onContentChange(content: string) {
currentValue.value = content;
// 验证 YAML 格式
const valid = validateYaml(content);
// 总是更新外部值
emit('update:value', content);
emit('change', content);
// 发出验证状态变化事件
emit('validation-change', valid);
if (props.validateOnChange && valid) {
emit('valid', content);
}
}
// 强制添加选中样式
function addSelectionStyles() {
const styleId = 'yaml-editor-selection-styles';
let existingStyle = document.getElementById(styleId);
if (existingStyle) {
existingStyle.remove();
}
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.yaml-editor .cm-selectionBackground {
background-color: rgba(64, 152, 252, 0.4) !important;
}
.yaml-editor .cm-focused .cm-selectionBackground {
background-color: rgba(64, 152, 252, 0.5) !important;
}
.yaml-editor .cm-selectionLayer .cm-selectionBackground {
background-color: rgba(64, 152, 252, 0.4) !important;
}
.yaml-editor .cm-content ::selection {
background-color: rgba(64, 152, 252, 0.4) !important;
}
.yaml-editor .cm-line::selection {
background-color: rgba(64, 152, 252, 0.4) !important;
}
`;
document.head.appendChild(style);
}
// 创建编辑器
function createEditor() {
if (!editorRef.value) return;
// 强制添加选中样式
addSelectionStyles();
const extensions = [
basicSetup,
yaml(),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const content = update.state.doc.toString();
onContentChange(content);
}
if (update.focusChanged) {
if (update.view.hasFocus) {
emit('focus');
} else {
emit('blur');
if (props.validateOnBlur) {
const content = update.state.doc.toString();
const isValid = validateYaml(content);
if (isValid) {
emit('valid', content);
}
}
}
}
}),
EditorView.theme({
'&': {
fontSize: '14px',
border: '1px solid #d9d9d9',
borderRadius: '6px',
},
'&.cm-focused': {
outline: 'none',
borderColor: '#4098fc',
boxShadow: '0 0 0 2px rgba(64, 152, 252, 0.2)',
},
'.cm-content': {
padding: '12px',
minHeight: typeof props.height === 'number' ? `${props.height}px` : props.height,
fontFamily: '"SF Mono", Monaco, Inconsolata, "Roboto Mono", Consolas, "Courier New", monospace',
lineHeight: '1.6',
},
'.cm-focused .cm-cursor': {
borderColor: '#4098fc',
},
'&.error-state': {
borderColor: '#ff4757',
},
'&.error-state.cm-focused': {
borderColor: '#ff4757',
boxShadow: '0 0 0 2px rgba(255, 71, 87, 0.2)',
}
}),
EditorState.readOnly.of(props.readonly),
];
// 添加暗色主题支持
if (props.darkTheme) {
extensions.push(oneDark);
}
const initialState = EditorState.create({
doc: currentValue.value || props.placeholder,
extensions,
});
editorView.value = new EditorView({
state: initialState,
parent: editorRef.value,
});
// 如果是占位符,选中所有文本
if (!currentValue.value && props.placeholder) {
nextTick(() => {
if (editorView.value) {
editorView.value.dispatch({
selection: { anchor: 0, head: editorView.value.state.doc.length }
});
}
});
}
}
// 提供外部调用的验证方法
function validate(): boolean {
return validateYaml(currentValue.value);
}
// 提供外部调用的获取解析后的数据方法
function getParsedData(): any {
try {
return yamlParser.load(currentValue.value);
} catch (error) {
return null;
}
}
// 提供外部调用的设置数据方法
function setData(data: any) {
try {
const yamlString = yamlParser.dump(data, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: false,
quotingType: '"',
forceQuotes: false,
});
if (editorView.value) {
editorView.value.dispatch({
changes: {
from: 0,
to: editorView.value.state.doc.length,
insert: yamlString
}
});
}
currentValue.value = yamlString;
emit('update:value', yamlString);
clearError();
} catch (error: any) {
const errorMsg = `数据转换YAML失败: ${error.message}`;
errorMessage.value = errorMsg;
hasError.value = true;
emit('error', { message: errorMsg, error });
}
}
// 获取当前值
function getValue(): string {
return currentValue.value;
}
// 设置焦点
function focus() {
editorView.value?.focus();
}
// 插入文本
function insertText(text: string) {
if (editorView.value) {
const selection = editorView.value.state.selection.main;
editorView.value.dispatch({
changes: {
from: selection.from,
to: selection.to,
insert: text
}
});
}
}
onMounted(() => {
nextTick(() => {
createEditor();
});
});
onBeforeUnmount(() => {
if (editorView.value) {
editorView.value.destroy();
}
// 清理动态样式
const existingStyle = document.getElementById('yaml-editor-selection-styles');
if (existingStyle) {
existingStyle.remove();
}
});
// 暴露方法给父组件
defineExpose({
validate,
getParsedData,
setData,
getValue,
focus,
insertText,
clearError,
hasError: () => hasError.value,
isValid: () => isValid.value,
});
</script>
<style lang="less" scoped>
.yaml-editor-container {
width: 100%;
.yaml-editor {
border-radius: 6px;
transition: all 0.2s;
&.error-state {
border-color: #ff4757;
}
:deep(.cm-editor) {
outline: none;
}
:deep(.cm-content) {
background: #fafafa;
}
:deep(.cm-line) {
line-height: 1.6;
}
// YAML 语法高亮样式增强
:deep(.cm-comment) {
color: #8e8e93;
font-style: italic;
}
:deep(.cm-string) {
color: #0c7d9d;
}
:deep(.cm-number) {
color: #1c00cf;
}
:deep(.cm-keyword) {
color: #ad3da4;
font-weight: 600;
}
:deep(.cm-property) {
color: #3f6ec7;
font-weight: 500;
}
}
.error-message {
margin-top: 8px;
}
.editor-stats {
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
}
// 暗色主题支持
.dark {
.yaml-editor-container {
.yaml-editor {
:deep(.cm-content) {
background: #1f1f1f;
color: #d4d4d4;
}
:deep(.cm-selectionBackground) {
background-color: rgba(64, 152, 252, 0.6) !important;
}
:deep(.cm-focused .cm-selectionBackground) {
background-color: rgba(64, 152, 252, 0.7) !important;
}
}
}
}
</style>