mirror of
https://github.com/vastxie/99AI.git
synced 2025-11-13 20:23:43 +08:00
v4.3.0
This commit is contained in:
20
admin/src/components/Auth/index.vue
Executable file
20
admin/src/components/Auth/index.vue
Executable file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'Auth',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
value: string | string[];
|
||||
}>();
|
||||
|
||||
function check() {
|
||||
return useAuth().auth(props.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot v-if="check()" />
|
||||
<slot v-else name="no-auth" />
|
||||
</div>
|
||||
</template>
|
||||
20
admin/src/components/AuthAll/index.vue
Executable file
20
admin/src/components/AuthAll/index.vue
Executable file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'AuthAll',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
value: string[];
|
||||
}>();
|
||||
|
||||
function check() {
|
||||
return useAuth().authAll(props.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot v-if="check()" />
|
||||
<slot v-else name="no-auth" />
|
||||
</div>
|
||||
</template>
|
||||
107
admin/src/components/FileUpload/index.vue
Executable file
107
admin/src/components/FileUpload/index.vue
Executable file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadProps, UploadUserFile } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
defineOptions({
|
||||
name: 'FileUpload',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action: UploadProps['action'];
|
||||
headers?: UploadProps['headers'];
|
||||
data?: UploadProps['data'];
|
||||
name?: UploadProps['name'];
|
||||
size?: number;
|
||||
max?: number;
|
||||
files?: UploadUserFile[];
|
||||
notip?: boolean;
|
||||
ext?: string[];
|
||||
}>(),
|
||||
{
|
||||
name: 'file',
|
||||
size: 2,
|
||||
max: 3,
|
||||
files: () => [],
|
||||
notip: false,
|
||||
ext: () => ['zip', 'rar'],
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
onSuccess: [res: any, file: UploadUserFile, fileList: UploadUserFile[]];
|
||||
}>();
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
const fileName = file.name.split('.');
|
||||
const fileExt = fileName.at(-1) ?? '';
|
||||
const isTypeOk = props.ext.includes(fileExt);
|
||||
const isSizeOk = file.size / 1024 / 1024 < props.size;
|
||||
if (!isTypeOk) {
|
||||
ElMessage.error(`上传文件只支持 ${props.ext.join(' / ')} 格式!`);
|
||||
}
|
||||
if (!isSizeOk) {
|
||||
ElMessage.error(`上传文件大小不能超过 ${props.size}MB!`);
|
||||
}
|
||||
return isTypeOk && isSizeOk;
|
||||
};
|
||||
|
||||
const onExceed: UploadProps['onExceed'] = () => {
|
||||
ElMessage.warning('文件上传超过限制');
|
||||
};
|
||||
|
||||
const onSuccess: UploadProps['onSuccess'] = (res, file, fileList) => {
|
||||
emits('onSuccess', res, file, fileList);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElUpload
|
||||
:headers="headers"
|
||||
:action="action"
|
||||
:data="data"
|
||||
:name="name"
|
||||
:before-upload="beforeUpload"
|
||||
:on-exceed="onExceed"
|
||||
:on-success="onSuccess"
|
||||
:file-list="files"
|
||||
:limit="max"
|
||||
drag
|
||||
>
|
||||
<div class="slot">
|
||||
<SvgIcon name="i-ep:upload-filled" class="el-icon--upload" />
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div v-if="!notip" class="el-upload__tip">
|
||||
<div style="display: inline-block">
|
||||
<ElAlert
|
||||
:title="`上传文件支持 ${ext.join(' / ')} 格式,单个文件大小不超过 ${size}MB,且文件数量不超过 ${max} 个`"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElUpload>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-upload.is-drag) {
|
||||
display: inline-block;
|
||||
|
||||
.el-upload-dragger {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.is-dragover {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.slot {
|
||||
width: 300px;
|
||||
padding: 40px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
47
admin/src/components/FixedActionBar/index.vue
Executable file
47
admin/src/components/FixedActionBar/index.vue
Executable file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'FixedActionBar',
|
||||
});
|
||||
|
||||
const isBottom = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
});
|
||||
|
||||
function onScroll() {
|
||||
// 变量scrollTop是滚动条滚动时,滚动条上端距离顶部的距离
|
||||
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
|
||||
// 变量windowHeight是可视区的高度
|
||||
const windowHeight = document.documentElement.clientHeight || document.body.clientHeight;
|
||||
// 变量scrollHeight是滚动条的总高度(当前可滚动的页面的总高度)
|
||||
const scrollHeight = document.documentElement.scrollHeight || document.body.scrollHeight;
|
||||
// 滚动条到底部
|
||||
isBottom.value = Math.ceil(scrollTop + windowHeight) >= scrollHeight;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fixed-action-bar bottom-0 z-4 bg-[var(--g-container-bg)] p-5 text-center transition"
|
||||
:class="{ shadow: !isBottom }"
|
||||
data-fixed-calc-width
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fixed-action-bar {
|
||||
box-shadow: 0 0 1px 0 var(--g-box-shadow-color);
|
||||
|
||||
&.shadow {
|
||||
box-shadow: 0 -10px 10px -10px var(--g-box-shadow-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
admin/src/components/IconifyIcon/index.vue
Normal file
21
admin/src/components/IconifyIcon/index.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, useAttrs } from 'vue';
|
||||
|
||||
interface Props {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const bindAttrs = computed<{ class: string; style: string }>(() => ({
|
||||
class: (attrs.class as string) || '',
|
||||
style: (attrs.style as string) || 'width: 2em, height: 2em',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon icon="icon" v-bind="bindAttrs" />
|
||||
</template>
|
||||
71
admin/src/components/ImagePreview/index.vue
Executable file
71
admin/src/components/ImagePreview/index.vue
Executable file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'ImagePreview',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
src: string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
}>(),
|
||||
{
|
||||
width: 200,
|
||||
height: 200,
|
||||
},
|
||||
);
|
||||
|
||||
const realWidth = computed(() => {
|
||||
return typeof props.width === 'string' ? props.width : `${props.width}px`;
|
||||
});
|
||||
|
||||
const realHeight = computed(() => {
|
||||
return typeof props.height === 'string' ? props.height : `${props.height}px`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElImage
|
||||
:src="src"
|
||||
fit="cover"
|
||||
:style="`width:${realWidth};height:${realHeight};`"
|
||||
:preview-src-list="[src]"
|
||||
preview-teleported
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-slot">
|
||||
<SvgIcon name="image-load-fail" />
|
||||
</div>
|
||||
</template>
|
||||
</ElImage>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-image {
|
||||
background-color: var(--el-fill-color);
|
||||
border-radius: 5px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
transition:
|
||||
background-color 0.3s,
|
||||
var(--el-transition-box-shadow);
|
||||
|
||||
:deep(.el-image__inner) {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.image-slot) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 30px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
274
admin/src/components/ImageUpload/index.vue
Executable file
274
admin/src/components/ImageUpload/index.vue
Executable file
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadProps } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
defineOptions({
|
||||
name: 'ImageUpload',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action: UploadProps['action'];
|
||||
headers?: UploadProps['headers'];
|
||||
data?: UploadProps['data'];
|
||||
name?: UploadProps['name'];
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
placeholder?: string;
|
||||
notip?: boolean;
|
||||
ext?: string[];
|
||||
}>(),
|
||||
{
|
||||
name: 'file',
|
||||
size: 2,
|
||||
width: 150,
|
||||
height: 150,
|
||||
placeholder: '',
|
||||
notip: false,
|
||||
ext: () => ['jpg', 'png', 'gif', 'bmp'],
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
onSuccess: [res: any];
|
||||
}>();
|
||||
|
||||
const url = defineModel<string>({
|
||||
default: '',
|
||||
});
|
||||
|
||||
const uploadData = ref({
|
||||
imageViewerVisible: false,
|
||||
progress: {
|
||||
preview: '',
|
||||
percent: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// 预览
|
||||
function preview() {
|
||||
uploadData.value.imageViewerVisible = true;
|
||||
}
|
||||
// 关闭预览
|
||||
function previewClose() {
|
||||
uploadData.value.imageViewerVisible = false;
|
||||
}
|
||||
// 移除
|
||||
function remove() {
|
||||
url.value = '';
|
||||
}
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
const fileName = file.name.split('.');
|
||||
const fileExt = fileName.at(-1) ?? '';
|
||||
const isTypeOk = props.ext.includes(fileExt);
|
||||
const isSizeOk = file.size / 1024 / 1024 < props.size;
|
||||
if (!isTypeOk) {
|
||||
ElMessage.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`);
|
||||
}
|
||||
if (!isSizeOk) {
|
||||
ElMessage.error(`上传图片大小不能超过 ${props.size}MB!`);
|
||||
}
|
||||
if (isTypeOk && isSizeOk) {
|
||||
uploadData.value.progress.preview = URL.createObjectURL(file);
|
||||
}
|
||||
return isTypeOk && isSizeOk;
|
||||
};
|
||||
const onProgress: UploadProps['onProgress'] = (file) => {
|
||||
uploadData.value.progress.percent = ~~file.percent;
|
||||
};
|
||||
const onSuccess: UploadProps['onSuccess'] = (res) => {
|
||||
uploadData.value.progress.preview = '';
|
||||
uploadData.value.progress.percent = 0;
|
||||
emits('onSuccess', res);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upload-container">
|
||||
<ElUpload
|
||||
:show-file-list="false"
|
||||
:headers="headers"
|
||||
:action="action"
|
||||
:data="data"
|
||||
:name="name"
|
||||
:before-upload="beforeUpload"
|
||||
:on-progress="onProgress"
|
||||
:on-success="onSuccess"
|
||||
drag
|
||||
class="image-upload"
|
||||
>
|
||||
<ElImage
|
||||
v-if="url === ''"
|
||||
:src="url === '' ? placeholder : url"
|
||||
:style="`width:${width}px;height:${height}px;`"
|
||||
fit="fill"
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
|
||||
<SvgIcon name="i-ep:plus" class="icon" />
|
||||
</div>
|
||||
</template>
|
||||
</ElImage>
|
||||
<div v-else class="image">
|
||||
<ElImage :src="url" :style="`width:${width}px;height:${height}px;`" fit="fill" />
|
||||
<div class="mask">
|
||||
<div class="actions">
|
||||
<span title="预览" @click.stop="preview">
|
||||
<SvgIcon name="i-ep:zoom-in" class="icon" />
|
||||
</span>
|
||||
<span title="移除" @click.stop="remove">
|
||||
<SvgIcon name="i-ep:delete" class="icon" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-show="url === '' && uploadData.progress.percent"
|
||||
class="progress"
|
||||
:style="`width:${width}px;height:${height}px;`"
|
||||
>
|
||||
<ElImage
|
||||
:src="uploadData.progress.preview"
|
||||
:style="`width:${width}px;height:${height}px;`"
|
||||
fit="fill"
|
||||
/>
|
||||
<ElProgress
|
||||
type="circle"
|
||||
:width="Math.min(width, height) * 0.8"
|
||||
:percentage="uploadData.progress.percent"
|
||||
/>
|
||||
</div>
|
||||
</ElUpload>
|
||||
<div v-if="!notip" class="el-upload__tip">
|
||||
<div style="display: inline-block">
|
||||
<ElAlert
|
||||
:title="`上传图片支持 ${ext.join(' / ')} 格式,且图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height}`"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ElImageViewer
|
||||
v-if="uploadData.imageViewerVisible"
|
||||
:url-list="[url]"
|
||||
teleported
|
||||
@close="previewClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-container {
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
.el-image {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--el-overlay-color-lighter);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
|
||||
@include position-center(xy);
|
||||
|
||||
span {
|
||||
width: 50%;
|
||||
color: var(--el-color-white);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.1s,
|
||||
transform 0.1s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .mask {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image-upload {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
.el-upload-dragger {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
|
||||
&.is-dragover {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.image-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--el-text-color-placeholder);
|
||||
background-color: transparent;
|
||||
|
||||
.icon {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
background-color: var(--el-overlay-color-lighter);
|
||||
}
|
||||
|
||||
.el-progress {
|
||||
z-index: 1;
|
||||
|
||||
@include position-center(xy);
|
||||
|
||||
.el-progress__text {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
310
admin/src/components/ImagesUpload/index.vue
Executable file
310
admin/src/components/ImagesUpload/index.vue
Executable file
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadProps } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
defineOptions({
|
||||
name: 'ImagesUpload',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action: UploadProps['action'];
|
||||
headers?: UploadProps['headers'];
|
||||
data?: UploadProps['data'];
|
||||
name?: UploadProps['name'];
|
||||
size?: number;
|
||||
max?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
placeholder?: string;
|
||||
notip?: boolean;
|
||||
ext?: string[];
|
||||
}>(),
|
||||
{
|
||||
name: 'file',
|
||||
size: 2,
|
||||
max: 3,
|
||||
width: 150,
|
||||
height: 150,
|
||||
placeholder: '',
|
||||
notip: false,
|
||||
ext: () => ['jpg', 'png', 'gif', 'bmp'],
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
onSuccess: [res: any];
|
||||
}>();
|
||||
|
||||
const url = defineModel<string[]>({
|
||||
default: [],
|
||||
});
|
||||
|
||||
const uploadData = ref({
|
||||
dialogImageIndex: 0,
|
||||
imageViewerVisible: false,
|
||||
progress: {
|
||||
preview: '',
|
||||
percent: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// 预览
|
||||
function preview(index: number) {
|
||||
uploadData.value.dialogImageIndex = index;
|
||||
uploadData.value.imageViewerVisible = true;
|
||||
}
|
||||
// 关闭预览
|
||||
function previewClose() {
|
||||
uploadData.value.imageViewerVisible = false;
|
||||
}
|
||||
// 移除
|
||||
function remove(index: number) {
|
||||
url.value.splice(index, 1);
|
||||
}
|
||||
// 移动
|
||||
function move(index: number, type: 'left' | 'right') {
|
||||
if (type === 'left' && index !== 0) {
|
||||
url.value[index] = url.value.splice(index - 1, 1, url.value[index])[0];
|
||||
}
|
||||
if (type === 'right' && index !== url.value.length - 1) {
|
||||
url.value[index] = url.value.splice(index + 1, 1, url.value[index])[0];
|
||||
}
|
||||
}
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
const fileName = file.name.split('.');
|
||||
const fileExt = fileName.at(-1) ?? '';
|
||||
const isTypeOk = props.ext.includes(fileExt);
|
||||
const isSizeOk = file.size / 1024 / 1024 < props.size;
|
||||
if (!isTypeOk) {
|
||||
ElMessage.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`);
|
||||
}
|
||||
if (!isSizeOk) {
|
||||
ElMessage.error(`上传图片大小不能超过 ${props.size}MB!`);
|
||||
}
|
||||
if (isTypeOk && isSizeOk) {
|
||||
uploadData.value.progress.preview = URL.createObjectURL(file);
|
||||
}
|
||||
return isTypeOk && isSizeOk;
|
||||
};
|
||||
const onProgress: UploadProps['onProgress'] = (file) => {
|
||||
uploadData.value.progress.percent = ~~file.percent;
|
||||
};
|
||||
const onSuccess: UploadProps['onSuccess'] = (res) => {
|
||||
uploadData.value.progress.preview = '';
|
||||
uploadData.value.progress.percent = 0;
|
||||
emits('onSuccess', res);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upload-container">
|
||||
<div v-for="(item, index) in url as string[]" :key="index" class="images">
|
||||
<ElImage
|
||||
v-if="index < max"
|
||||
:src="item"
|
||||
:style="`width:${width}px;height:${height}px;`"
|
||||
fit="cover"
|
||||
/>
|
||||
<div class="mask">
|
||||
<div class="actions">
|
||||
<span title="预览" @click="preview(index)">
|
||||
<SvgIcon name="i-ep:zoom-in" class="icon" />
|
||||
</span>
|
||||
<span title="移除" @click="remove(index)">
|
||||
<SvgIcon name="i-ep:delete" class="icon" />
|
||||
</span>
|
||||
<span
|
||||
v-show="url.length > 1"
|
||||
title="左移"
|
||||
:class="{ disabled: index === 0 }"
|
||||
@click="move(index, 'left')"
|
||||
>
|
||||
<SvgIcon name="i-ep:back" class="icon" />
|
||||
</span>
|
||||
<span
|
||||
v-show="url.length > 1"
|
||||
title="右移"
|
||||
:class="{ disabled: index === url.length - 1 }"
|
||||
@click="move(index, 'right')"
|
||||
>
|
||||
<SvgIcon name="i-ep:right" class="icon" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ElUpload
|
||||
v-show="url.length < max"
|
||||
:show-file-list="false"
|
||||
:headers="headers"
|
||||
:action="action"
|
||||
:data="data"
|
||||
:name="name"
|
||||
:before-upload="beforeUpload"
|
||||
:on-progress="onProgress"
|
||||
:on-success="onSuccess"
|
||||
drag
|
||||
class="images-upload"
|
||||
>
|
||||
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
|
||||
<SvgIcon name="i-ep:plus" class="icon" />
|
||||
</div>
|
||||
<div
|
||||
v-show="uploadData.progress.percent"
|
||||
class="progress"
|
||||
:style="`width:${width}px;height:${height}px;`"
|
||||
>
|
||||
<ElImage
|
||||
:src="uploadData.progress.preview"
|
||||
:style="`width:${width}px;height:${height}px;`"
|
||||
fit="fill"
|
||||
/>
|
||||
<ElProgress
|
||||
type="circle"
|
||||
:width="Math.min(width, height) * 0.8"
|
||||
:percentage="uploadData.progress.percent"
|
||||
/>
|
||||
</div>
|
||||
</ElUpload>
|
||||
<div v-if="!notip" class="el-upload__tip">
|
||||
<div style="display: inline-block">
|
||||
<ElAlert
|
||||
:title="`上传图片支持 ${ext.join(' / ')} 格式,单张图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height},且图片数量不超过 ${max} 张`"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ElImageViewer
|
||||
v-if="uploadData.imageViewerVisible"
|
||||
:url-list="url as string[]"
|
||||
:initial-index="uploadData.dialogImageIndex"
|
||||
teleported
|
||||
@close="previewClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-container {
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
.el-image {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.images {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--el-overlay-color-lighter);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
|
||||
@include position-center(xy);
|
||||
|
||||
span {
|
||||
width: 50%;
|
||||
color: var(--el-color-white);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.1s,
|
||||
transform 0.1s;
|
||||
|
||||
&.disabled {
|
||||
color: var(--el-text-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .mask {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.images-upload {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
.el-upload-dragger {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
|
||||
&.is-dragover {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.image-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--el-text-color-placeholder);
|
||||
background-color: transparent;
|
||||
|
||||
.icon {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
background-color: var(--el-overlay-color-lighter);
|
||||
}
|
||||
|
||||
.el-progress {
|
||||
z-index: 1;
|
||||
|
||||
@include position-center(xy);
|
||||
|
||||
.el-progress__text {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
49
admin/src/components/NotAllowed/index.vue
Executable file
49
admin/src/components/NotAllowed/index.vue
Executable file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
|
||||
defineOptions({
|
||||
name: 'NotAllowed',
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const data = ref({
|
||||
inter: Number.NaN,
|
||||
countdown: 5,
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
data.value.inter && window.clearInterval(data.value.inter);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
data.value.inter = window.setInterval(() => {
|
||||
data.value.countdown--;
|
||||
if (data.value.countdown === 0) {
|
||||
data.value.inter && window.clearInterval(data.value.inter);
|
||||
goBack();
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
router.push(settingsStore.settings.home.fullPath);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute left-[50%] top-[50%] flex flex-col items-center justify-between lg-flex-row -translate-x-50% -translate-y-50% lg-gap-12"
|
||||
>
|
||||
<SvgIcon name="403" class="text-[300px] lg-text-[400px]" />
|
||||
<div class="flex flex-col gap-4">
|
||||
<h1 class="m-0 text-6xl font-sans">403</h1>
|
||||
<div class="desc mx-0 text-xl text-stone-5">抱歉,你无权访问该页面</div>
|
||||
<div>
|
||||
<HButton @click="goBack"> {{ data.countdown }} 秒后,返回首页 </HButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
34
admin/src/components/PageHeader/index.vue
Executable file
34
admin/src/components/PageHeader/index.vue
Executable file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'PageHeader',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
title?: string;
|
||||
content?: string;
|
||||
}>();
|
||||
|
||||
const slots = useSlots();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="page-header mb-5 flex flex-wrap items-center justify-between gap-5 bg-[var(--g-container-bg)] px-5 py-4 transition-background-color-300"
|
||||
>
|
||||
<div class="main flex-[1_1_70%]">
|
||||
<div class="text-2xl">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-stone-5 empty-hidden">
|
||||
<slot name="content">
|
||||
{{ content }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="slots.default" class="ml-a flex-none">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
56
admin/src/components/PageMain/index.vue
Executable file
56
admin/src/components/PageMain/index.vue
Executable file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'PageMain',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
collaspe?: boolean;
|
||||
height?: string;
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
collaspe: false,
|
||||
height: '',
|
||||
},
|
||||
);
|
||||
|
||||
const titleSlot = !!useSlots().title;
|
||||
|
||||
const isCollaspe = ref(props.collaspe);
|
||||
function unCollaspe() {
|
||||
isCollaspe.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="page-main relative m-4 flex flex-col bg-[var(--g-container-bg)] transition-background-color-300"
|
||||
:class="{
|
||||
'of-hidden': isCollaspe,
|
||||
}"
|
||||
:style="{
|
||||
height: isCollaspe ? height : '',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="titleSlot || title"
|
||||
class="title-container border-b-1 border-b-[var(--g-bg)] border-b-solid px-5 py-4 transition-border-color-300"
|
||||
>
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</div>
|
||||
<div class="main-container p-5">
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-if="isCollaspe"
|
||||
class="collaspe absolute bottom-0 w-full cursor-pointer from-transparent to-[var(--g-container-bg)] bg-gradient-to-b pb-2 pt-10 text-center"
|
||||
@click="unCollaspe"
|
||||
>
|
||||
<SvgIcon name="i-ep:arrow-down" class="text-xl op-30 transition-opacity hover-op-100" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
151
admin/src/components/PcasCascader/index.vue
Executable file
151
admin/src/components/PcasCascader/index.vue
Executable file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
// 行政区划数据来源于 https://github.com/modood/Administrative-divisions-of-China
|
||||
import pcasRaw from './pcas-code.json';
|
||||
|
||||
defineOptions({
|
||||
name: 'PcasCascader',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
type?: 'pc' | 'pca' | 'pcas';
|
||||
format?: 'code' | 'name' | 'both';
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
type: 'pca',
|
||||
format: 'code',
|
||||
},
|
||||
);
|
||||
|
||||
const value = defineModel<
|
||||
| string[]
|
||||
| {
|
||||
code: string;
|
||||
name: string;
|
||||
}[]
|
||||
>({
|
||||
default: [],
|
||||
});
|
||||
|
||||
interface pcasItem {
|
||||
code: string;
|
||||
name: string;
|
||||
children?: pcasItem[];
|
||||
}
|
||||
|
||||
const pcasData = computed(() => {
|
||||
const data: pcasItem[] = [];
|
||||
// 省份
|
||||
pcasRaw.forEach((p) => {
|
||||
const tempP: pcasItem = {
|
||||
code: p.code,
|
||||
name: p.name,
|
||||
};
|
||||
const tempChildrenC: pcasItem[] = [];
|
||||
// 城市
|
||||
p.children.forEach((c) => {
|
||||
const tempC: pcasItem = {
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
};
|
||||
if (['pca', 'pcas'].includes(props.type)) {
|
||||
const tempChildrenA: pcasItem[] = [];
|
||||
// 区县
|
||||
c.children.forEach((a) => {
|
||||
const tempA: pcasItem = {
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
};
|
||||
if (props.type === 'pcas') {
|
||||
const tempChildrenS: pcasItem[] = [];
|
||||
// 街道
|
||||
a.children.forEach((s) => {
|
||||
const tempS: pcasItem = {
|
||||
code: s.code,
|
||||
name: s.name,
|
||||
};
|
||||
tempChildrenS.push(tempS);
|
||||
});
|
||||
tempA.children = tempChildrenS;
|
||||
}
|
||||
tempChildrenA.push(tempA);
|
||||
});
|
||||
tempC.children = tempChildrenA;
|
||||
}
|
||||
tempChildrenC.push(tempC);
|
||||
});
|
||||
tempP.children = tempChildrenC;
|
||||
data.push(tempP);
|
||||
});
|
||||
return data;
|
||||
});
|
||||
|
||||
const myValue = computed({
|
||||
// 将入参数据转成 code 码
|
||||
get: () => {
|
||||
return anyToCode(value.value);
|
||||
},
|
||||
// 将 code 码转成出参数据
|
||||
set: (val) => {
|
||||
value.value = val ? codeToAny(val) : [];
|
||||
},
|
||||
});
|
||||
|
||||
function anyToCode(value: any[], dictionarie: any[] = pcasData.value) {
|
||||
const input: string[] = [];
|
||||
if (value.length > 0) {
|
||||
const findItem = dictionarie.find((item) => {
|
||||
if (props.format === 'code') {
|
||||
return item.code === value[0];
|
||||
} else if (props.format === 'name') {
|
||||
return item.name === value[0];
|
||||
} else {
|
||||
return item.name === value[0].name && item.code === value[0].code;
|
||||
}
|
||||
});
|
||||
input.push(findItem.code);
|
||||
if (findItem.children) {
|
||||
input.push(...anyToCode(value.slice(1 - value.length), findItem.children));
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function codeToAny(codes: string[], dictionarie: any[] = pcasData.value): any {
|
||||
const output = [];
|
||||
const findItem = dictionarie.find((item) => item.code === codes[0]);
|
||||
if (findItem) {
|
||||
switch (props.format) {
|
||||
case 'code':
|
||||
output.push(findItem.code);
|
||||
break;
|
||||
case 'name':
|
||||
output.push(findItem.name);
|
||||
break;
|
||||
case 'both':
|
||||
output.push({
|
||||
code: findItem.code,
|
||||
name: findItem.name,
|
||||
});
|
||||
}
|
||||
const newCodes = codes.slice(1 - codes.length);
|
||||
if (newCodes.length > 0 && findItem.children) {
|
||||
output.push(...codeToAny(newCodes, findItem.children));
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCascader
|
||||
v-model="myValue"
|
||||
:options="pcasData as any[]"
|
||||
:props="{ value: 'code', label: 'name' }"
|
||||
:disabled="disabled"
|
||||
clearable
|
||||
filterable
|
||||
/>
|
||||
</template>
|
||||
61401
admin/src/components/PcasCascader/pcas-code.json
Executable file
61401
admin/src/components/PcasCascader/pcas-code.json
Executable file
File diff suppressed because it is too large
Load Diff
299
admin/src/components/PromptTemplateEditor/index.vue
Normal file
299
admin/src/components/PromptTemplateEditor/index.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
import { Delete, Plus, Rank } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { computed, ref } from 'vue';
|
||||
import draggable from 'vuedraggable';
|
||||
|
||||
// 定义字段类型接口
|
||||
interface TemplateField {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'input' | 'select';
|
||||
placeholder: string;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
// 定义 Props 和 Emits
|
||||
const props = defineProps<{
|
||||
modelValue: TemplateField[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: TemplateField[]): void;
|
||||
}>();
|
||||
|
||||
// 本地状态,避免直接修改 prop
|
||||
const localFields = ref<TemplateField[]>([]);
|
||||
|
||||
// 使用计算属性同步 prop 和本地状态
|
||||
const fields = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
// 监听外部 modelValue 变化,同步到本地(如果需要深度监听或特殊处理)
|
||||
// watch(() => props.modelValue, (newValue) => {
|
||||
// // 可以添加深拷贝或其他逻辑
|
||||
// localFields.value = JSON.parse(JSON.stringify(newValue));
|
||||
// }, { deep: true, immediate: true });
|
||||
|
||||
// 添加新字段
|
||||
const addField = (type: 'input' | 'select' = 'input') => {
|
||||
fields.value = [
|
||||
...fields.value,
|
||||
{
|
||||
id: uuidv4(),
|
||||
type,
|
||||
title: '',
|
||||
placeholder: '',
|
||||
options: type === 'select' ? [''] : undefined, // Select 默认带一个空选项
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// 删除字段
|
||||
const removeField = (id: string) => {
|
||||
fields.value = fields.value.filter((field) => field.id !== id);
|
||||
};
|
||||
|
||||
// 添加选项(仅用于 select)
|
||||
const addOption = (fieldId: string) => {
|
||||
fields.value = fields.value.map((field) => {
|
||||
if (field.id === fieldId && field.type === 'select') {
|
||||
// 确保 options 数组存在
|
||||
const options = field.options ? [...field.options] : [];
|
||||
options.push(''); // 添加一个空选项
|
||||
return { ...field, options };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
};
|
||||
|
||||
// 删除选项(仅用于 select)
|
||||
const removeOption = (fieldId: string, optionIndex: number) => {
|
||||
fields.value = fields.value.map((field) => {
|
||||
if (field.id === fieldId && field.type === 'select' && field.options) {
|
||||
const options = [...field.options];
|
||||
if (options.length > 1) {
|
||||
// 至少保留一个选项输入框
|
||||
options.splice(optionIndex, 1);
|
||||
return { ...field, options };
|
||||
} else {
|
||||
ElMessage.warning('下拉框至少需要一个选项');
|
||||
}
|
||||
}
|
||||
return field;
|
||||
});
|
||||
};
|
||||
|
||||
// 更新字段类型
|
||||
const updateFieldType = (id: string, newType: 'input' | 'select') => {
|
||||
fields.value = fields.value.map((field) => {
|
||||
if (field.id === id) {
|
||||
return {
|
||||
...field,
|
||||
type: newType,
|
||||
// 从 input 转 select 时,添加默认 options
|
||||
options: newType === 'select' && !field.options ? [''] : field.options,
|
||||
// 从 select 转 input 时,移除 options (可选,也可保留)
|
||||
// options: newType === 'input' ? undefined : field.options,
|
||||
};
|
||||
}
|
||||
return field;
|
||||
});
|
||||
};
|
||||
|
||||
// 更新选项值
|
||||
const updateOptionValue = (fieldId: string, optionIndex: number, value: string) => {
|
||||
fields.value = fields.value.map((field) => {
|
||||
if (field.id === fieldId && field.type === 'select' && field.options) {
|
||||
const options = [...field.options];
|
||||
options[optionIndex] = value;
|
||||
return { ...field, options };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
};
|
||||
|
||||
// 更新 Placeholder 值
|
||||
const updatePlaceholderValue = (fieldId: string, value: string) => {
|
||||
fields.value = fields.value.map((field) => {
|
||||
if (field.id === fieldId) {
|
||||
return { ...field, placeholder: value };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
};
|
||||
|
||||
// 更新 Title 值
|
||||
const updateTitleValue = (fieldId: string, value: string) => {
|
||||
fields.value = fields.value.map((field) => {
|
||||
if (field.id === fieldId) {
|
||||
return { ...field, title: value };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
};
|
||||
|
||||
// Draggable 配置
|
||||
const dragOptions = {
|
||||
animation: 200,
|
||||
ghostClass: 'ghost', // Class name for the drop placeholder
|
||||
handle: '.drag-handle', // Specify the handle element
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prompt-template-editor">
|
||||
<!-- Use CSS Grid for layout -->
|
||||
<draggable v-model="fields" item-key="id" v-bind="dragOptions" tag="div" class="field-grid">
|
||||
<template #item="{ element: field, index }">
|
||||
<el-card shadow="never" class="field-item border border-gray-200 relative group">
|
||||
<div class="flex items-start space-x-3">
|
||||
<!-- Add Number Prefix -->
|
||||
<div class="field-number font-semibold text-gray-400 pt-2 mr-1">{{ index + 1 }}.</div>
|
||||
<!-- Drag Handle -->
|
||||
<div class="drag-handle cursor-move text-gray-400 hover:text-gray-600 pt-2">
|
||||
<el-icon :size="20"><Rank /></el-icon>
|
||||
</div>
|
||||
<!-- Field Content -->
|
||||
<div class="flex-grow">
|
||||
<el-form label-position="top" size="small">
|
||||
<div class="flex items-center mb-2 space-x-4">
|
||||
<el-radio-group
|
||||
:model-value="field.type"
|
||||
@update:modelValue="
|
||||
(newType) => updateFieldType(field.id, newType as 'input' | 'select')
|
||||
"
|
||||
size="small"
|
||||
>
|
||||
<el-radio-button label="input">输入框</el-radio-button>
|
||||
<el-radio-button label="select">下拉框</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
link
|
||||
class="ml-auto !p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click="removeField(field.id)"
|
||||
/>
|
||||
</div>
|
||||
<el-form-item label="字段名称 (Title / Label)">
|
||||
<el-input
|
||||
:model-value="field.title"
|
||||
@update:modelValue="(val) => updateTitleValue(field.id, val)"
|
||||
placeholder="例如:您的姓名"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="提示文字 (Placeholder)">
|
||||
<el-input
|
||||
:model-value="field.placeholder"
|
||||
@update:modelValue="(val) => updatePlaceholderValue(field.id, val)"
|
||||
placeholder="例如:请输入您的姓名"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<div v-if="field.type === 'select'">
|
||||
<el-form-item label="下拉选项">
|
||||
<div class="space-y-2 w-full">
|
||||
<div
|
||||
v-for="(option, index) in field.options"
|
||||
:key="index"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<el-input
|
||||
:model-value="option"
|
||||
@update:modelValue="(val) => updateOptionValue(field.id, index, val)"
|
||||
placeholder="选项内容"
|
||||
size="small"
|
||||
clearable
|
||||
class="flex-grow"
|
||||
/>
|
||||
<el-button
|
||||
:icon="Delete"
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
class="!p-1"
|
||||
:disabled="field.options && field.options.length <= 1"
|
||||
@click="removeOption(field.id, index)"
|
||||
/>
|
||||
</div>
|
||||
<el-button
|
||||
:icon="Plus"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="addOption(field.id)"
|
||||
>
|
||||
添加选项
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div class="mt-4 flex justify-center space-x-2">
|
||||
<el-button :icon="Plus" type="primary" plain @click="addField('input')">添加输入框</el-button>
|
||||
<el-button :icon="Plus" type="success" plain @click="addField('select')"
|
||||
>添加下拉框</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prompt-template-editor {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
/* Use Grid for layout */
|
||||
display: grid;
|
||||
/* grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); */ /* Try forcing 3 columns */
|
||||
grid-template-columns: repeat(3, 1fr); /* Force 3 columns */
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-item {
|
||||
background-color: #fdfdfd;
|
||||
transition: box-shadow 0.2s ease-in-out;
|
||||
width: 100%; /* Ensure card takes full width of cell */
|
||||
}
|
||||
.field-item:hover {
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.field-number {
|
||||
/* Style for number prefix */
|
||||
min-width: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
background: #c8ebfb;
|
||||
border: 1px dashed #409eff;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
line-height: normal;
|
||||
margin-bottom: 4px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
49
admin/src/components/SearchBar/index.vue
Executable file
49
admin/src/components/SearchBar/index.vue
Executable file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'SearchBar',
|
||||
});
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
showToggle?: boolean;
|
||||
background?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showToggle: true,
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits<{
|
||||
toggle: [value: boolean];
|
||||
}>();
|
||||
|
||||
const fold = defineModel<boolean>('fold', {
|
||||
default: true,
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
fold.value = !fold.value;
|
||||
emits('toggle', fold.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative"
|
||||
:class="{
|
||||
'py-4': showToggle,
|
||||
'px-4 bg-[var(--g-bg)] transition': background,
|
||||
}"
|
||||
>
|
||||
<slot :fold="fold" :toggle="toggle" />
|
||||
<div v-if="showToggle" class="absolute bottom-0 left-0 w-full translate-y-1/2 text-center">
|
||||
<button
|
||||
class="h-5 inline-flex cursor-pointer select-none items-center border-size-0 rounded bg-[var(--g-bg)] px-2 text-xs font-medium outline-none"
|
||||
@click="toggle"
|
||||
>
|
||||
<SvgIcon :name="fold ? 'i-ep:caret-bottom' : 'i-ep:caret-top'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
69
admin/src/components/SvgIcon/index.vue
Executable file
69
admin/src/components/SvgIcon/index.vue
Executable file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'SvgIcon',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
flip?: 'horizontal' | 'vertical' | 'both';
|
||||
rotate?: number;
|
||||
color?: string;
|
||||
size?: string | number;
|
||||
}>();
|
||||
|
||||
const outputType = computed(() => {
|
||||
if (/^https?:\/\//.test(props.name)) {
|
||||
return 'img';
|
||||
} else if (/i-[^:]+:[^:]+/.test(props.name)) {
|
||||
return 'unocss';
|
||||
} else if (props.name.includes(':')) {
|
||||
return 'iconify';
|
||||
} else {
|
||||
return 'svg';
|
||||
}
|
||||
});
|
||||
|
||||
const style = computed(() => {
|
||||
const transform = [];
|
||||
if (props.flip) {
|
||||
switch (props.flip) {
|
||||
case 'horizontal':
|
||||
transform.push('rotateY(180deg)');
|
||||
break;
|
||||
case 'vertical':
|
||||
transform.push('rotateX(180deg)');
|
||||
break;
|
||||
case 'both':
|
||||
transform.push('rotateX(180deg)');
|
||||
transform.push('rotateY(180deg)');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (props.rotate) {
|
||||
transform.push(`rotate(${props.rotate % 360}deg)`);
|
||||
}
|
||||
return {
|
||||
...(props.color && { color: props.color }),
|
||||
...(props.size && {
|
||||
fontSize: typeof props.size === 'number' ? `${props.size}px` : props.size,
|
||||
}),
|
||||
...(transform.length && { transform: transform.join(' ') }),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<i
|
||||
class="relative h-[1em] w-[1em] flex-inline items-center justify-center fill-current leading-[1em]"
|
||||
:class="{ [name]: outputType === 'unocss' }"
|
||||
:style="style"
|
||||
>
|
||||
<Icon v-if="outputType === 'iconify'" :icon="name" />
|
||||
<svg v-else-if="outputType === 'svg'" class="h-[1em] w-[1em]" aria-hidden="true">
|
||||
<use :xlink:href="`#icon-${name}`" />
|
||||
</svg>
|
||||
<img v-else-if="outputType === 'img'" :src="name" class="h-[1em] w-[1em]" />
|
||||
</i>
|
||||
</template>
|
||||
58
admin/src/components/SystemInfo/index.vue
Executable file
58
admin/src/components/SystemInfo/index.vue
Executable file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import eventBus from '@/utils/eventBus';
|
||||
|
||||
const isShow = ref(false);
|
||||
|
||||
const { pkg, lastBuildTime } = __SYSTEM_INFO__;
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('global-system-info-toggle', () => {
|
||||
isShow.value = !isShow.value;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HSlideover v-model="isShow" title="系统信息">
|
||||
<div class="px-4">
|
||||
<h2 class="m-0 text-lg font-bold">最后编译时间</h2>
|
||||
<div class="my-4 text-center text-lg font-sans">
|
||||
{{ lastBuildTime }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<h2 class="m-0 text-lg font-bold">生产环境依赖</h2>
|
||||
<ul class="list-none pl-0 text-sm">
|
||||
<li
|
||||
v-for="(val, key) in pkg.dependencies as object"
|
||||
:key="key"
|
||||
class="flex items-center justify-between rounded px-2 py-1.5 hover-bg-stone-1 dark-hover-bg-stone-9"
|
||||
>
|
||||
<div class="font-bold">
|
||||
{{ key }}
|
||||
</div>
|
||||
<div class="font-sans">
|
||||
{{ val }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<h2 class="m-0 text-lg font-bold">开发环境依赖</h2>
|
||||
<ul class="list-none pl-0 text-sm">
|
||||
<li
|
||||
v-for="(val, key) in pkg.devDependencies as object"
|
||||
:key="key"
|
||||
class="flex items-center justify-between rounded px-2 py-1.5 hover-bg-stone-1 dark-hover-bg-stone-9"
|
||||
>
|
||||
<div class="font-bold">
|
||||
{{ key }}
|
||||
</div>
|
||||
<div class="font-sans">
|
||||
{{ val }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</HSlideover>
|
||||
</template>
|
||||
38
admin/src/components/Trend/index.vue
Executable file
38
admin/src/components/Trend/index.vue
Executable file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'Trend',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: string;
|
||||
type?: 'up' | 'down';
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
reverse?: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: 'up',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
reverse: false,
|
||||
},
|
||||
);
|
||||
|
||||
const isUp = computed(() => {
|
||||
let isUp = props.type === 'up';
|
||||
if (props.reverse) {
|
||||
isUp = !isUp;
|
||||
}
|
||||
return isUp;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center transition" :class="`${isUp ? 'c-green' : 'c-red'}`">
|
||||
<span v-if="prefix" class="prefix">{{ prefix }}</span>
|
||||
<span class="text">{{ value }}</span>
|
||||
<span v-if="suffix" class="suffix">{{ suffix }}</span>
|
||||
<SvgIcon name="i-ep:caret-top" :rotate="isUp ? 0 : 180" class="ml-1 transition" />
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user