This commit is contained in:
vastxie
2025-05-31 02:28:46 +08:00
parent 0f7adc5c65
commit 86e2eecc1f
1808 changed files with 183083 additions and 86701 deletions

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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