chore: seed migration objects batch 16
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 '文件';
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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>
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as basicModal } from './src/basicModal.vue';
|
||||||
|
export { useModal } from './src/hooks/useModal';
|
||||||
|
export * from './src/type';
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
@@ -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>
|
||||||
@@ -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';
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { isArray } from "@/utils/is";
|
||||||
|
|
||||||
|
interface Account {
|
||||||
|
/**
|
||||||
|
* 账号名称
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* 用户名
|
||||||
|
*/
|
||||||
|
username: string;
|
||||||
|
/**
|
||||||
|
* 密码
|
||||||
|
*/
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 获取配置的账号信息
|
||||||
|
* @returns {[]Account} 返回账号信息数组
|
||||||
|
*/
|
||||||
|
export function getDemoAccounts() {
|
||||||
|
|
||||||
|
let envConf = import.meta.env.VITE_APP_DEMO_ACCOUNT || "";
|
||||||
|
// 帐号密码一样
|
||||||
|
// [["username"],["username","password"],["username","password","name"]]
|
||||||
|
try {
|
||||||
|
let accounts = JSON.parse(envConf);
|
||||||
|
if (accounts && isArray(accounts)) {
|
||||||
|
return accounts.map((item: String[]) => {
|
||||||
|
let [username = "", password = "", name = ""] = item;
|
||||||
|
username = username;
|
||||||
|
password = password || username;
|
||||||
|
name = name || username;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
} as Account;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
return [] as Account[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { on } from '@/utils/domUtils';
|
||||||
|
import { isServer } from '@/utils/is';
|
||||||
|
import type { ComponentPublicInstance, DirectiveBinding, ObjectDirective } from 'vue';
|
||||||
|
|
||||||
|
type DocumentHandler = <T extends MouseEvent>(mouseup: T, mousedown: T) => void;
|
||||||
|
|
||||||
|
type FlushList = Map<
|
||||||
|
HTMLElement,
|
||||||
|
{
|
||||||
|
documentHandler: DocumentHandler;
|
||||||
|
bindingFn: (...args: unknown[]) => unknown;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
const nodeList: FlushList = new Map();
|
||||||
|
|
||||||
|
let startClick: MouseEvent;
|
||||||
|
|
||||||
|
if (!isServer) {
|
||||||
|
on(document, 'mousedown', (e: MouseEvent) => (startClick = e));
|
||||||
|
on(document, 'mouseup', (e: MouseEvent) => {
|
||||||
|
for (const { documentHandler } of nodeList.values()) {
|
||||||
|
documentHandler(e, startClick);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDocumentHandler(el: HTMLElement, binding: DirectiveBinding): DocumentHandler {
|
||||||
|
let excludes: HTMLElement[] = [];
|
||||||
|
if (Array.isArray(binding.arg)) {
|
||||||
|
excludes = binding.arg;
|
||||||
|
} else {
|
||||||
|
// due to current implementation on binding type is wrong the type casting is necessary here
|
||||||
|
excludes.push(binding.arg as unknown as HTMLElement);
|
||||||
|
}
|
||||||
|
return function (mouseup, mousedown) {
|
||||||
|
const popperRef = (
|
||||||
|
binding.instance as ComponentPublicInstance<{
|
||||||
|
popperRef: Nullable<HTMLElement>;
|
||||||
|
}>
|
||||||
|
).popperRef;
|
||||||
|
const mouseUpTarget = mouseup.target as Node;
|
||||||
|
const mouseDownTarget = mousedown.target as Node;
|
||||||
|
const isBound = !binding || !binding.instance;
|
||||||
|
const isTargetExists = !mouseUpTarget || !mouseDownTarget;
|
||||||
|
const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget);
|
||||||
|
const isSelf = el === mouseUpTarget;
|
||||||
|
|
||||||
|
const isTargetExcluded =
|
||||||
|
(excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) ||
|
||||||
|
(excludes.length && excludes.includes(mouseDownTarget as HTMLElement));
|
||||||
|
const isContainedByPopper =
|
||||||
|
popperRef && (popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget));
|
||||||
|
if (
|
||||||
|
isBound ||
|
||||||
|
isTargetExists ||
|
||||||
|
isContainedByEl ||
|
||||||
|
isSelf ||
|
||||||
|
isTargetExcluded ||
|
||||||
|
isContainedByPopper
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
binding.value();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClickOutside: ObjectDirective = {
|
||||||
|
beforeMount(el, binding) {
|
||||||
|
nodeList.set(el, {
|
||||||
|
documentHandler: createDocumentHandler(el, binding),
|
||||||
|
bindingFn: binding.value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updated(el, binding) {
|
||||||
|
nodeList.set(el, {
|
||||||
|
documentHandler: createDocumentHandler(el, binding),
|
||||||
|
bindingFn: binding.value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
unmounted(el) {
|
||||||
|
nodeList.delete(el);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClickOutside;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* v-copy
|
||||||
|
* 复制某个值至剪贴板
|
||||||
|
* 接收参数:string类型/Ref<string>类型/Reactive<string>类型
|
||||||
|
*/
|
||||||
|
import type { Directive, DirectiveBinding } from 'vue';
|
||||||
|
interface ElType extends HTMLElement {
|
||||||
|
copyData: string | number;
|
||||||
|
__handleClick__: any;
|
||||||
|
}
|
||||||
|
const copy: Directive = {
|
||||||
|
mounted(el: ElType, binding: DirectiveBinding) {
|
||||||
|
el.copyData = binding.value;
|
||||||
|
el.addEventListener('click', handleClick);
|
||||||
|
},
|
||||||
|
updated(el: ElType, binding: DirectiveBinding) {
|
||||||
|
el.copyData = binding.value;
|
||||||
|
},
|
||||||
|
beforeUnmount(el: ElType) {
|
||||||
|
el.removeEventListener('click', el.__handleClick__);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
function handleClick(this: any) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = this.copyData.toLocaleString();
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand('Copy');
|
||||||
|
document.body.removeChild(input);
|
||||||
|
console.log('复制成功', this.copyData);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default copy;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* v-debounce
|
||||||
|
* 按钮防抖指令,可自行扩展至input
|
||||||
|
* 接收参数:function类型
|
||||||
|
*/
|
||||||
|
import type { Directive, DirectiveBinding } from 'vue';
|
||||||
|
interface ElType extends HTMLElement {
|
||||||
|
__handleClick__: () => any;
|
||||||
|
}
|
||||||
|
const debounce: Directive = {
|
||||||
|
mounted(el: ElType, binding: DirectiveBinding) {
|
||||||
|
if (typeof binding.value !== 'function') {
|
||||||
|
throw 'callback must be a function';
|
||||||
|
}
|
||||||
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
el.__handleClick__ = function () {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
binding.value();
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
el.addEventListener('click', el.__handleClick__);
|
||||||
|
},
|
||||||
|
beforeUnmount(el: ElType) {
|
||||||
|
el.removeEventListener('click', el.__handleClick__);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default debounce;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
需求:实现一个拖拽指令,可在父元素区域任意拖拽元素。
|
||||||
|
|
||||||
|
思路:
|
||||||
|
1、设置需要拖拽的元素为absolute,其父元素为relative。
|
||||||
|
2、鼠标按下(onmousedown)时记录目标元素当前的 left 和 top 值。
|
||||||
|
3、鼠标移动(onmousemove)时计算每次移动的横向距离和纵向距离的变化值,并改变元素的 left 和 top 值
|
||||||
|
4、鼠标松开(onmouseup)时完成一次拖拽
|
||||||
|
|
||||||
|
使用:在 Dom 上加上 v-draggable 即可
|
||||||
|
<div class="dialog-model" v-draggable></div>
|
||||||
|
*/
|
||||||
|
import type { Directive } from 'vue';
|
||||||
|
interface ElType extends HTMLElement {
|
||||||
|
parentNode: any;
|
||||||
|
}
|
||||||
|
const draggable: Directive = {
|
||||||
|
mounted: function (el: ElType) {
|
||||||
|
el.style.cursor = 'move';
|
||||||
|
el.style.position = 'absolute';
|
||||||
|
el.onmousedown = function (e) {
|
||||||
|
const disX = e.pageX - el.offsetLeft;
|
||||||
|
const disY = e.pageY - el.offsetTop;
|
||||||
|
document.onmousemove = function (e) {
|
||||||
|
let x = e.pageX - disX;
|
||||||
|
let y = e.pageY - disY;
|
||||||
|
const maxX = el.parentNode.offsetWidth - el.offsetWidth;
|
||||||
|
const maxY = el.parentNode.offsetHeight - el.offsetHeight;
|
||||||
|
if (x < 0) {
|
||||||
|
x = 0;
|
||||||
|
} else if (x > maxX) {
|
||||||
|
x = maxX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (y < 0) {
|
||||||
|
y = 0;
|
||||||
|
} else if (y > maxY) {
|
||||||
|
y = maxY;
|
||||||
|
}
|
||||||
|
el.style.left = x + 'px';
|
||||||
|
el.style.top = y + 'px';
|
||||||
|
};
|
||||||
|
document.onmouseup = function () {
|
||||||
|
document.onmousemove = document.onmouseup = null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default draggable;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ObjectDirective } from 'vue';
|
||||||
|
import { usePermission } from '@/hooks/web/usePermission';
|
||||||
|
|
||||||
|
export const permission: ObjectDirective = {
|
||||||
|
mounted(el: HTMLButtonElement, binding) {
|
||||||
|
if (binding.value == undefined) return;
|
||||||
|
const { action, effect } = binding.value;
|
||||||
|
const { hasPermission } = usePermission();
|
||||||
|
if (!hasPermission(action)) {
|
||||||
|
if (effect == 'disabled') {
|
||||||
|
el.disabled = true;
|
||||||
|
el.style['disabled'] = 'disabled';
|
||||||
|
el.classList.add('n-button--disabled');
|
||||||
|
} else {
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
需求:防止按钮在短时间内被多次点击,使用节流函数限制规定时间内只能点击一次。
|
||||||
|
|
||||||
|
思路:
|
||||||
|
1、第一次点击,立即调用方法并禁用按钮,等延迟结束再次激活按钮
|
||||||
|
2、将需要触发的方法绑定在指令上
|
||||||
|
|
||||||
|
使用:给 Dom 加上 v-throttle 及回调函数即可
|
||||||
|
<button v-throttle="debounceClick">节流提交</button>
|
||||||
|
*/
|
||||||
|
import type { Directive, DirectiveBinding } from 'vue';
|
||||||
|
interface ElType extends HTMLElement {
|
||||||
|
__handleClick__: () => any;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
const throttle: Directive = {
|
||||||
|
mounted(el: ElType, binding: DirectiveBinding) {
|
||||||
|
if (typeof binding.value !== 'function') {
|
||||||
|
throw 'callback must be a function';
|
||||||
|
}
|
||||||
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
el.__handleClick__ = function () {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
if (!el.disabled) {
|
||||||
|
el.disabled = true;
|
||||||
|
binding.value();
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
el.disabled = false;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
el.addEventListener('click', el.__handleClick__);
|
||||||
|
},
|
||||||
|
beforeUnmount(el: ElType) {
|
||||||
|
el.removeEventListener('click', el.__handleClick__);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default throttle;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export enum ApiEnum {
|
||||||
|
// api前缀
|
||||||
|
Prefix = '/api',
|
||||||
|
|
||||||
|
// 基础
|
||||||
|
SiteRegister = '/site/register', // 账号注册
|
||||||
|
SiteAccountLogin = '/site/accountLogin', // 账号登录
|
||||||
|
SiteMobileLogin = '/site/mobileLogin', // 手机号登录
|
||||||
|
SiteLoginConfig = '/site/loginConfig', // 登录配置
|
||||||
|
SiteLogout = '/site/logout', // 注销
|
||||||
|
SiteConfig = '/site/config', // 配置信息
|
||||||
|
|
||||||
|
// 用户
|
||||||
|
MemberInfo = '/member/info', // 登录用户信息
|
||||||
|
|
||||||
|
// 角色
|
||||||
|
RoleDynamic = '/role/dynamic', // 动态路由
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export enum sizeEnum {
|
||||||
|
XS = 'XS',
|
||||||
|
SM = 'SM',
|
||||||
|
MD = 'MD',
|
||||||
|
LG = 'LG',
|
||||||
|
XL = 'XL',
|
||||||
|
XXL = 'XXL',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum screenEnum {
|
||||||
|
XS = 480,
|
||||||
|
SM = 576,
|
||||||
|
MD = 768,
|
||||||
|
LG = 992,
|
||||||
|
XL = 1200,
|
||||||
|
XXL = 1600,
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenMap = new Map<sizeEnum, number>();
|
||||||
|
|
||||||
|
screenMap.set(sizeEnum.XS, screenEnum.XS);
|
||||||
|
screenMap.set(sizeEnum.SM, screenEnum.SM);
|
||||||
|
screenMap.set(sizeEnum.MD, screenEnum.MD);
|
||||||
|
screenMap.set(sizeEnum.LG, screenEnum.LG);
|
||||||
|
screenMap.set(sizeEnum.XL, screenEnum.XL);
|
||||||
|
screenMap.set(sizeEnum.XXL, screenEnum.XXL);
|
||||||
|
|
||||||
|
export { screenMap };
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// token key
|
||||||
|
export const TOKEN_KEY = 'TOKEN';
|
||||||
|
|
||||||
|
// member info key
|
||||||
|
export const USER_INFO_KEY = 'USER__INFO__';
|
||||||
|
|
||||||
|
// role info key
|
||||||
|
export const ROLES_KEY = 'ROLES__KEY__';
|
||||||
|
|
||||||
|
// project config key
|
||||||
|
export const PROJ_CFG_KEY = 'PROJ__CFG__KEY__';
|
||||||
|
|
||||||
|
// lock info
|
||||||
|
export const LOCK_INFO_KEY = 'LOCK__INFO__KEY__';
|
||||||
|
|
||||||
|
// base global local key
|
||||||
|
export const BASE_LOCAL_CACHE_KEY = 'LOCAL__CACHE__KEY__';
|
||||||
|
|
||||||
|
// base global session key
|
||||||
|
export const BASE_SESSION_CACHE_KEY = 'SESSION__CACHE__KEY__';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// 部门类型
|
||||||
|
export enum DeptTypeEnum {
|
||||||
|
// 公司
|
||||||
|
Company = 'company',
|
||||||
|
|
||||||
|
// 租户
|
||||||
|
Tenant = 'tenant',
|
||||||
|
|
||||||
|
// 商户
|
||||||
|
Merchant = 'merchant',
|
||||||
|
|
||||||
|
// 用户
|
||||||
|
User = 'user',
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* @description: 请求结果集
|
||||||
|
*/
|
||||||
|
export enum ResultEnum {
|
||||||
|
SUCCESS = 0,
|
||||||
|
ERROR = -1,
|
||||||
|
TIMEOUT = 61,
|
||||||
|
TYPE = 'success',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description: 请求方法
|
||||||
|
*/
|
||||||
|
export enum RequestEnum {
|
||||||
|
GET = 'GET',
|
||||||
|
POST = 'POST',
|
||||||
|
PATCH = 'PATCH',
|
||||||
|
PUT = 'PUT',
|
||||||
|
DELETE = 'DELETE',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description: 常用的contentTyp类型
|
||||||
|
*/
|
||||||
|
export enum ContentTypeEnum {
|
||||||
|
// json
|
||||||
|
JSON = 'application/json;charset=UTF-8',
|
||||||
|
// json
|
||||||
|
TEXT = 'text/plain;charset=UTF-8',
|
||||||
|
// form-data 一般配合qs
|
||||||
|
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||||
|
// form-data 上传
|
||||||
|
FORM_DATA = 'multipart/form-data;charset=UTF-8',
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// 本地字典
|
||||||
|
// 一些不需要放在服务端注册的字典,也可以在这里进行注册
|
||||||
|
|
||||||
|
import { DictOptions, Option } from '@/store/modules/dict';
|
||||||
|
|
||||||
|
export const noticeTypeOptions: Option[] = [
|
||||||
|
{
|
||||||
|
key: 1,
|
||||||
|
value: 1,
|
||||||
|
label: '通知',
|
||||||
|
listClass: 'warning',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 2,
|
||||||
|
value: 2,
|
||||||
|
label: '公告',
|
||||||
|
listClass: 'error',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 3,
|
||||||
|
value: 3,
|
||||||
|
label: '私信',
|
||||||
|
listClass: 'info',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const noticeTagOptions: Option[] = [
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
label: '无标签',
|
||||||
|
key: 0,
|
||||||
|
listClass: 'default',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 1,
|
||||||
|
label: '一般',
|
||||||
|
key: 1,
|
||||||
|
listClass: 'info',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 2,
|
||||||
|
label: '紧急',
|
||||||
|
key: 2,
|
||||||
|
listClass: 'error',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 3,
|
||||||
|
label: '重要',
|
||||||
|
key: 3,
|
||||||
|
listClass: 'warning',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 4,
|
||||||
|
label: '提醒',
|
||||||
|
key: 4,
|
||||||
|
listClass: 'success',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 5,
|
||||||
|
label: '次要',
|
||||||
|
key: 5,
|
||||||
|
listClass: 'default',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const tagTypeOptions: Option[] = [
|
||||||
|
{
|
||||||
|
value: 'success',
|
||||||
|
label: '绿色',
|
||||||
|
key: 'success',
|
||||||
|
listClass: 'success',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'warning',
|
||||||
|
label: '橙色',
|
||||||
|
key: 'warning',
|
||||||
|
listClass: 'warning',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'error',
|
||||||
|
label: '红色',
|
||||||
|
key: 'error',
|
||||||
|
listClass: 'error',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'info',
|
||||||
|
label: '蓝色',
|
||||||
|
key: 'info',
|
||||||
|
listClass: 'info',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'default',
|
||||||
|
label: '灰色',
|
||||||
|
key: 'default',
|
||||||
|
listClass: 'default',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'primary',
|
||||||
|
label: '主题色',
|
||||||
|
key: 'primary',
|
||||||
|
listClass: 'primary',
|
||||||
|
extra: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const localDict: DictOptions = {
|
||||||
|
noticeTypeOptions: noticeTypeOptions, // 消息通知类型
|
||||||
|
noticeTagOptions: noticeTagOptions, // 消息标签类型
|
||||||
|
tagTypeOptions: tagTypeOptions, // 标签类型选项
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export const statusOptions = [
|
||||||
|
{
|
||||||
|
value: 1,
|
||||||
|
label: '正常',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 2,
|
||||||
|
label: '停用',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 操作类
|
||||||
|
export const statusActions = [
|
||||||
|
{
|
||||||
|
label: '设为启用',
|
||||||
|
key: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '设为禁用',
|
||||||
|
key: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export enum PageEnum {
|
||||||
|
// 登录
|
||||||
|
BASE_LOGIN = '/login',
|
||||||
|
BASE_LOGIN_NAME = 'Login',
|
||||||
|
//重定向
|
||||||
|
REDIRECT = '/redirect',
|
||||||
|
REDIRECT_NAME = 'Redirect',
|
||||||
|
// 首页
|
||||||
|
BASE_HOME = '/dashboard',
|
||||||
|
//首页跳转默认路由
|
||||||
|
BASE_HOME_REDIRECT = '/dashboard/console',
|
||||||
|
// 错误
|
||||||
|
ERROR_PAGE_NAME = 'ErrorPage',
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface PermissionsEnum {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export enum SocketEnum {
|
||||||
|
EventPing = 'ping',
|
||||||
|
EventKick = 'kick',
|
||||||
|
EventNotice = 'notice',
|
||||||
|
EventConnected = 'connected',
|
||||||
|
EventAdminMonitorTrends = 'admin/monitor/trends',
|
||||||
|
EventAdminMonitorRunInfo = 'admin/monitor/runInfo',
|
||||||
|
EventAdminOrderNotify = 'admin/order/notify',
|
||||||
|
HeartBeatInterval = 1000,
|
||||||
|
CodeSuc = 0,
|
||||||
|
CodeErr = -1,
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { NAvatar, NTag, NText, SelectRenderLabel, SelectRenderTag } from 'naive-ui';
|
||||||
|
import { Component, h } from 'vue';
|
||||||
|
import { BellOutlined, NotificationOutlined, SendOutlined } from '@vicons/antd';
|
||||||
|
import { useDictStore } from '@/store/modules/dict';
|
||||||
|
|
||||||
|
export interface personOption {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
username: string;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renderMultipleSelectTag: SelectRenderTag = ({ option, handleClose }) => {
|
||||||
|
// @ts-ignore
|
||||||
|
return h(
|
||||||
|
NTag,
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
padding: '0 6px 0 4px',
|
||||||
|
},
|
||||||
|
round: true,
|
||||||
|
closable: true,
|
||||||
|
onClose: (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
option.avatar !== ''
|
||||||
|
? h(NAvatar, {
|
||||||
|
src: option.avatar as string,
|
||||||
|
round: true,
|
||||||
|
size: 22,
|
||||||
|
style: {
|
||||||
|
marginRight: '4px',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: h(
|
||||||
|
NAvatar,
|
||||||
|
{
|
||||||
|
round: true,
|
||||||
|
size: 22,
|
||||||
|
style: {
|
||||||
|
marginRight: '4px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
option.label !== ''
|
||||||
|
? ((option?.label as string).substring(0, 1) as string)
|
||||||
|
: ((option?.username as string).substring(0, 1) as string),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
option.label as string,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderLabel: SelectRenderLabel = (option) => {
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
option.avatar !== ''
|
||||||
|
? h(NAvatar, {
|
||||||
|
src: option.avatar as string,
|
||||||
|
round: true,
|
||||||
|
size: 'small',
|
||||||
|
})
|
||||||
|
: h(
|
||||||
|
NAvatar,
|
||||||
|
{
|
||||||
|
round: true,
|
||||||
|
size: 'small',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
option.label !== ''
|
||||||
|
? ((option?.label as string).substring(0, 1) as string)
|
||||||
|
: ((option?.username as string).substring(0, 2) as string),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
marginLeft: '12px',
|
||||||
|
padding: '4px 0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h('div', null, [option.label as string]),
|
||||||
|
h(
|
||||||
|
NText,
|
||||||
|
{ depth: 3, tag: 'div' },
|
||||||
|
{
|
||||||
|
default: () => option.username,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MessageTab {
|
||||||
|
/** tab的key */
|
||||||
|
key: number;
|
||||||
|
/** tab名称 */
|
||||||
|
name: string;
|
||||||
|
/** badge类型 */
|
||||||
|
badgeProps?: import('naive-ui').BadgeProps;
|
||||||
|
/** 消息数据 */
|
||||||
|
list: MessageRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageRow {
|
||||||
|
/** 消息ID */
|
||||||
|
id: number;
|
||||||
|
/** 消息类型 */
|
||||||
|
type: number;
|
||||||
|
/** 消息标题 */
|
||||||
|
title: string;
|
||||||
|
/** 消息内容 */
|
||||||
|
content: string;
|
||||||
|
/** 发送时间 */
|
||||||
|
createdAt: string;
|
||||||
|
/** 是否已读 */
|
||||||
|
isRead: boolean;
|
||||||
|
/** 发送者头像 */
|
||||||
|
senderAvatar: string;
|
||||||
|
/** 标签ID */
|
||||||
|
tag: number;
|
||||||
|
/** 标签名称 */
|
||||||
|
tagTitle?: string;
|
||||||
|
/** 标签props */
|
||||||
|
tagProps?: import('naive-ui').TagProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取消息的展示图标
|
||||||
|
export function getIcon(row: MessageRow): Component {
|
||||||
|
if (row.type === 1) {
|
||||||
|
return NotificationOutlined;
|
||||||
|
}
|
||||||
|
if (row.type === 2) {
|
||||||
|
return BellOutlined;
|
||||||
|
}
|
||||||
|
return SendOutlined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析消息
|
||||||
|
export function parseMessage(row): MessageRow {
|
||||||
|
row = row as MessageRow;
|
||||||
|
if (row.tag <= 0) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
const dict = useDictStore();
|
||||||
|
const label = dict.getLabel('noticeTagOptions', row.tag);
|
||||||
|
row.tagTitle = label;
|
||||||
|
row.tagProps = { type: label };
|
||||||
|
return row;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import useContext from './useContext';
|
||||||
|
import useBoolean from './useBoolean';
|
||||||
|
import useLoading from './useLoading';
|
||||||
|
import useLoadingEmpty from './useLoadingEmpty';
|
||||||
|
import useSendCode from './useSendCode';
|
||||||
|
import useSorter from './useSorter';
|
||||||
|
|
||||||
|
export { useContext, useBoolean, useLoading, useLoadingEmpty, useSendCode, useSorter };
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export default function useBoolean(initValue = false) {
|
||||||
|
const bool = ref(initValue);
|
||||||
|
|
||||||
|
function setBool(value: boolean) {
|
||||||
|
bool.value = value;
|
||||||
|
}
|
||||||
|
function setTrue() {
|
||||||
|
setBool(true);
|
||||||
|
}
|
||||||
|
function setFalse() {
|
||||||
|
setBool(false);
|
||||||
|
}
|
||||||
|
function toggle() {
|
||||||
|
setBool(!bool.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bool,
|
||||||
|
setBool,
|
||||||
|
setTrue,
|
||||||
|
setFalse,
|
||||||
|
toggle,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { inject, provide } from 'vue';
|
||||||
|
import type { InjectionKey } from 'vue';
|
||||||
|
|
||||||
|
export default function useContext<T>(contextName = 'context') {
|
||||||
|
const injectKey: InjectionKey<T> = Symbol(contextName);
|
||||||
|
|
||||||
|
function useProvide(context: T) {
|
||||||
|
provide(injectKey, context);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInject() {
|
||||||
|
return inject(injectKey) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
useProvide,
|
||||||
|
useInject,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { computed, onScopeDispose, ref } from 'vue';
|
||||||
|
import useBoolean from './useBoolean';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 倒计时
|
||||||
|
* @param second - 倒计时的时间(s)
|
||||||
|
*/
|
||||||
|
export default function useCountDown(second: number) {
|
||||||
|
if (second <= 0 && second % 1 !== 0) {
|
||||||
|
throw new Error('倒计时的时间应该为一个正整数!');
|
||||||
|
}
|
||||||
|
const { bool: isComplete, setTrue, setFalse } = useBoolean(false);
|
||||||
|
|
||||||
|
const counts = ref(0);
|
||||||
|
const isCounting = computed(() => Boolean(counts.value));
|
||||||
|
|
||||||
|
let intervalId: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始计时
|
||||||
|
* @param updateSecond - 更改初时传入的倒计时时间
|
||||||
|
*/
|
||||||
|
function start(updateSecond: number = second) {
|
||||||
|
if (!counts.value) {
|
||||||
|
setFalse();
|
||||||
|
counts.value = updateSecond;
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
counts.value -= 1;
|
||||||
|
if (counts.value <= 0) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
setTrue();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止计时
|
||||||
|
*/
|
||||||
|
function stop() {
|
||||||
|
intervalId = clearInterval(intervalId);
|
||||||
|
counts.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
onScopeDispose(stop);
|
||||||
|
|
||||||
|
return {
|
||||||
|
counts,
|
||||||
|
isCounting,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
isComplete,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import useBoolean from './useBoolean';
|
||||||
|
|
||||||
|
export default function useLoading(initValue = false) {
|
||||||
|
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(initValue);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
startLoading,
|
||||||
|
endLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import useBoolean from './useBoolean';
|
||||||
|
|
||||||
|
export default function useLoadingEmpty(initLoading = false, initEmpty = false) {
|
||||||
|
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(initLoading);
|
||||||
|
const { bool: empty, setBool: setEmpty } = useBoolean(initEmpty);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
startLoading,
|
||||||
|
endLoading,
|
||||||
|
empty,
|
||||||
|
setEmpty,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import useLoading from './useLoading';
|
||||||
|
import useCountDown from './useCountDown';
|
||||||
|
|
||||||
|
export default function useSmsCode() {
|
||||||
|
const { loading, startLoading, endLoading } = useLoading();
|
||||||
|
const { counts, start, isCounting } = useCountDown(60);
|
||||||
|
const initLabel = '获取验证码';
|
||||||
|
const countingLabel = (second: number) => `重新获取(${second})`;
|
||||||
|
const sendLabel = computed(() => {
|
||||||
|
let text = initLabel;
|
||||||
|
if (loading.value) {
|
||||||
|
text = '';
|
||||||
|
}
|
||||||
|
if (isCounting.value) {
|
||||||
|
text = countingLabel(counts.value);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 激活发送
|
||||||
|
*/
|
||||||
|
function activateSend(request: Promise<any>) {
|
||||||
|
startLoading();
|
||||||
|
request
|
||||||
|
.then((_res) => {
|
||||||
|
window['$message']?.success('验证码发送成功!');
|
||||||
|
start();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
endLoading();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendLabel,
|
||||||
|
start,
|
||||||
|
isCounting,
|
||||||
|
activateSend,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { DataTableSortState } from 'naive-ui';
|
||||||
|
import { SorterMultiple } from 'naive-ui/es/data-table/src/interface';
|
||||||
|
|
||||||
|
export default function useSorter(callback: Function = function () {}) {
|
||||||
|
const sortStatesRef = ref<DataTableSortState[]>([]);
|
||||||
|
|
||||||
|
function updateSorter(sorter: DataTableSortState | DataTableSortState[] | null) {
|
||||||
|
if (sorter === null) {
|
||||||
|
// 默认排序
|
||||||
|
sortStatesRef.value = [];
|
||||||
|
} else if (Array.isArray(sorter)) {
|
||||||
|
// 多字段排序
|
||||||
|
sortStatesRef.value = filterMultipleSorters(sorter);
|
||||||
|
} else {
|
||||||
|
// 单字段排序
|
||||||
|
sortStatesRef.value = [];
|
||||||
|
sortStatesRef.value.push(sorter);
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多字段排序,将不参与排序的字段过滤掉,再根据设置的优先级对排序器重新排列
|
||||||
|
function filterMultipleSorters(sorters: DataTableSortState[]): DataTableSortState[] {
|
||||||
|
const filter = sorters.filter((item) => item.order !== false);
|
||||||
|
return filter.sort((a, b) => {
|
||||||
|
if (typeof a.sorter === 'object' && typeof b.sorter === 'object') {
|
||||||
|
const aSorter = a.sorter as SorterMultiple;
|
||||||
|
const bSorter = b.sorter as SorterMultiple;
|
||||||
|
return bSorter.multiple - aSorter.multiple;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateSorter,
|
||||||
|
sortStatesRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user