初始化版本,引入智慧食堂模块,第三方模块

This commit is contained in:
zjf committed 2024-12-06 18:48:18 +08:00
commit d22f88efd3
677 files changed
+69341

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
import request from "/@/utils/request";
export const turbo = (content: string) => {
return request({
url: '/admin/chat/msg',
method: 'post',
data: {content: content},
});
};
+6
View File
@@ -0,0 +1,6 @@
export default {
chat: {
send: 'Send',
inputPlaceholder: 'Type a message...'
}
};
+6
View File
@@ -0,0 +1,6 @@
export default {
chat: {
send: '发送',
inputPlaceholder: '请输入消息...'
},
};
+108
View File
@@ -0,0 +1,108 @@
<template>
<div class="z-[1000]" v-if="getThemeConfig.isChat">
<button id="open-chat"
@click="chatContainer = !chatContainer"
class="fixed mb-16 mr-4 bottom-4 right-4 inline-flex bg-primary items-center justify-center text-sm font-medium disabled:pointer-events-none disabled:opacity-50 border rounded-full w-16 h-16 hover:bg-gray-700 m-0 cursor-pointer border-gray-200 bg-none p-0 normal-case leading-5 hover:text-gray-900"
type="button" aria-haspopup="dialog" aria-expanded="false" data-state="closed">
<svg xmlns=" http://www.w3.org/2000/svg" width="30" height="40" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="text-white block border-gray-200 align-middle">
<path d="m3 21 1.9-5.7a8.5 8.5 0 1 1 3.8 3.8z" class="border-gray-200">
</path>
</svg>
</button>
<div v-show="chatContainer" class="fixed bottom-24 right-4 w-96 mb-12">
<div class="bg-gray-100 shadow-md rounded-lg max-w-lg w-full">
<div class="p-4 border-b bg-primary text-white rounded-t-lg flex justify-between items-center">
<p class="text-lg font-semibold">AI 助手</p>
<button @click="chatContainer = false"
class="text-gray-300 hover:text-gray-400 focus:outline-none focus:text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div ref="chatboxRef" class="p-4 h-96 overflow-y-auto">
<!-- Chat messages will be displayed here -->
<div v-for="message in messageList">
<div class="mb-2 text-right" v-if="message.inputMessage">
<p class="bg-blue-500 text-white rounded-lg py-2 px-4 inline-block">{{ message.inputMessage }}</p>
</div>
<div class="mb-2" v-if="message.botMessage">
<p class="bg-gray-200 text-gray-700 rounded-lg py-2 px-4 inline-block">{{ message.botMessage }}</p>
</div>
</div>
</div>
<div class="p-4 border-t flex">
<input v-model="userInput" ref="userInputRef" type="text" :placeholder="$t('chat.inputPlaceholder')"
:readonly="readonly"
class="w-full px-3 py-2 border rounded-l-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<button @click="sendMessage"
class="bg-blue-500 text-white px-4 py-2 rounded-r-md hover:bg-blue-600 transition duration-300">{{ $t('chat.send') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts" name="chat">
import {onKeyStroke} from '@vueuse/core'
import {turbo} from "/@/components/Chat/chat";
import {useThemeConfig} from "/@/stores/themeConfig";
import {AxiosResponse} from "axios";
// 定义变量内容
const storesThemeConfig = useThemeConfig();
const {themeConfig} = storeToRefs(storesThemeConfig);
// 获取布局配置信息
const getThemeConfig = computed(() => {
return themeConfig.value;
});
interface Message {
inputMessage?: string;
botMessage?: string;
}
const messageList = ref<Message[]>([{botMessage: '您好!我是通用大模型,请问有什么可以帮助您?'}]);
const chatContainer = ref(false)
const userInput = ref("")
const userInputRef = ref()
const chatboxRef = ref()
const readonly = ref(false)
// watch 判断 messageList 长度是否变化
watch(() => messageList.value.length, (newLength, oldLength) => {
if (newLength !== oldLength) {
setTimeout(() => {
chatboxRef.value.scrollTop = chatboxRef.value.scrollHeight;
}, 500);
}
});
// 发送消息
const sendMessage = () => {
const userMessage = userInput.value;
if (userMessage.trim() !== "") {
readonly.value = true
messageList.value.push({inputMessage: userMessage, botMessage: "正在处理,请等待..."});
turbo(userInput.value).then((response: any) => {
if (response) {
messageList.value[messageList.value.length - 1].botMessage = response
} else {
messageList.value[messageList.value.length - 1].botMessage = "输出数据可能包含不适当内容"
}
}).finally(() => {
readonly.value = false
})
userInput.value = "";
}
};
// 监听回车键事件
onKeyStroke('Enter', (e) => {
sendMessage()
}, {target: userInputRef})
</script>
+19
View File
@@ -0,0 +1,19 @@
<template>
<div></div>
</template>
<script setup lang="ts" name="check-token">
import { checkToken } from '/@/api/login';
const refreshLock = ref(false);
const refreshTime = ref();
onMounted(() => {
refreshToken();
});
const refreshToken = () => {
refreshTime.value = setInterval(() => {
checkToken(refreshTime.value, refreshLock.value);
}, 60000);
};
</script>
+95
View File
@@ -0,0 +1,95 @@
<template>
<el-cascader :options="areas" :disabled="disabled" :props="optionProps" v-model="selectedOptions"
filterable
@change="handleChange">
<template #default="{ node, data }">
<span>{{ data.name }}</span>
<!-- 图标 -->
<svg v-if="data.hot === '1'" t="1708145002710" class="icon ml-4" viewBox="0 0 1024 1024" version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="6040" width="12" height="12">
<path
d="M760.591059 332.739765L681.682824 409.6s0-307.2-263.107765-409.539765c0 0-26.262588 281.6-157.816471 383.939765-131.553882 102.460235-394.721882 409.6 131.433412 639.939765 0 0-263.168-281.6 78.908235-486.27953 0 0-26.322824 102.339765 105.231059 204.8 131.614118 102.4 0 281.6 0 281.6s631.506824-153.6 184.32-691.260235z"
fill="#EA322B" p-id="6041"></path>
</svg>
</template>
</el-cascader>
</template>
<script lang="ts" setup>
import {fetchTree} from "/@/api/admin/sysArea";
import {CascaderProps, Resolve} from "element-plus";
const emit = defineEmits(['update:modelValue', 'change']);
// 定义props
const props = defineProps({
// 当前的值
modelValue: String,
// 层级
type: {
type: Number,
default: () => 4,
},
// 是否禁用
disabled: {
type: Boolean,
default: () => false,
},
// 子级是否必选
plus: {
type: Boolean,
default: () => false,
},
// lazy
lazy: {
type: Boolean,
default: () => true,
},
});
// 定义optionProps
const optionProps: CascaderProps = {
checkStrictly: props.plus,
label: 'name',
value: 'adcode',
lazy: props.lazy,
lazyLoad: async (node: Node, resolve: Resolve) => {
// 初始化数据
const {level} = node
if (level < props.type) {
const {data} = await fetchTree({pid: node.data.id || 100000})
resolve(data)
} else {
resolve([])
}
}
}
// 所有省市区的数据
let areas = ref()
// 计算属性selectedOptions
const selectedOptions = computed({
get: () => {
return props.modelValue?.split(',');
},
set: (val) => {
emit('update:modelValue', val?.join(','));
},
});
// 处理change事件的函数
const handleChange = (value: String[]) => {
emit('change', value?.join(','));
};
// 初始化数据
onMounted(async () => {
if (!props.lazy) {
const {data} = await fetchTree({type: props.type})
areas.value = data
}
})
</script>
+121
View File
@@ -0,0 +1,121 @@
<!--
* @Descripttion: 代码编辑器
* @version: 1.0
* @Author: sakuya
* @Date: 2022年5月20日21:46:29
* @LastEditors:
* @LastEditTime:
-->
<template>
<div class="code-editor" :style="{ height: _height }">
<textarea ref="textarea" v-model="contentValue"></textarea>
</div>
</template>
<script>
import { markRaw } from 'vue';
//框架
import CodeMirror from 'codemirror';
import 'codemirror/lib/codemirror.css';
//主题
import 'codemirror/theme/idea.css';
import 'codemirror/theme/darcula.css';
//功能
import 'codemirror/addon/selection/active-line';
//语言
import 'codemirror/mode/velocity/velocity';
import 'codemirror/mode/go/go';
export default {
props: {
modelValue: {
type: String,
default: '',
},
mode: {
type: String,
default: 'go',
},
height: {
type: [String, Number],
default: 300,
},
options: {
type: Object,
default: () => {},
},
theme: {
type: String,
default: 'idea',
},
readOnly: {
type: Boolean,
default: false,
},
},
data() {
return {
contentValue: this.modelValue,
coder: null,
opt: {
theme: this.theme, //主题
styleActiveLine: true, //高亮当前行
lineNumbers: true, //行号
lineWrapping: false, //自动换行
tabSize: 4, //Tab缩进
indentUnit: 4, //缩进单位
indentWithTabs: true, //自动缩进
mode: this.mode, //语言
readOnly: this.readOnly, //只读
...this.options,
},
};
},
computed: {
_height() {
return Number(this.height) ? Number(this.height) + 'px' : this.height;
},
},
watch: {
modelValue(val) {
this.contentValue = val;
if (val !== this.coder.getValue()) {
this.coder.setValue(val);
}
},
},
mounted() {
this.init();
//获取挂载的所有modes
//console.log(CodeMirror.modes)
},
methods: {
init() {
this.coder = markRaw(CodeMirror.fromTextArea(this.$refs.textarea, this.opt));
this.coder.on('change', (coder) => {
this.contentValue = coder.getValue();
this.$emit('update:modelValue', this.contentValue);
});
},
formatStrInJson(strValue) {
return JSON.stringify(JSON.parse(strValue), null, 4);
},
},
};
</script>
<style scoped>
.code-editor {
font-size: 14px;
border: 1px solid #ddd;
line-height: 150%;
}
.code-editor:deep(.CodeMirror) {
height: 100%;
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<template>
<div class="color-picker flex flex-1">
<el-color-picker v-model="color" :predefine="predefineColors" />
<el-input v-model="color" class="mx-[10px] flex-1" type="text" readonly />
<el-button type="text" @click="reset">重置</el-button>
</div>
</template>
<script lang="ts" setup>
const props = defineProps({
modelValue: {
type: String,
},
defaultColor: {
type: String,
},
});
const emit = defineEmits<{
(event: 'update:modelValue', value: any): void;
}>();
const color = computed({
get() {
return props.modelValue;
},
set(value) {
emit('update:modelValue', value);
},
});
const predefineColors = ['#409EFF', '#28C76F', '#EA5455', '#FF9F43', '#01CFE8', '#4A5DFF'];
const reset = () => {
color.value = props.defaultColor;
};
</script>
+806
View File
@@ -0,0 +1,806 @@
<!--
* @Descripttion: cron规则生成器
* @version: 1.0
* @Author: sakuya
* @Date: 2023年05月23日13:04:12
* @LastEditors:
* @LastEditTime:
-->
<template>
<el-input v-model="defaultValue" v-bind="$attrs">
<template #append>
<el-dropdown size="medium" @command="handleShortcuts">
<el-button icon="el-icon-arrow-down"></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="0 * * * * ?">每分钟</el-dropdown-item>
<el-dropdown-item command="0 0 * * * ?">每小时</el-dropdown-item>
<el-dropdown-item command="0 0 0 * * ?">每天零点</el-dropdown-item>
<el-dropdown-item command="0 0 0 1 * ?">每月一号零点</el-dropdown-item>
<el-dropdown-item command="0 0 0 L * ?">每月最后一天零点</el-dropdown-item>
<el-dropdown-item command="0 0 0 ? * 1">每周星期日零点</el-dropdown-item>
<el-dropdown-item v-for="(item, index) in shortcuts" :key="item.value" :divided="index == 0" :command="item.value">{{
item.text
}}</el-dropdown-item>
<el-dropdown-item icon="el-icon-plus" divided command="custom">自定义</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-input>
<el-dialog title="cron规则生成器" v-model="dialogVisible" :width="580" destroy-on-close append-to-body>
<div class="sc-cron">
<el-tabs>
<el-tab-pane>
<template #label>
<div class="sc-cron-num">
<h2>秒</h2>
<h4>{{ value_second }}</h4>
</div>
</template>
<el-form>
<el-form-item label="类型">
<el-radio-group v-model="value.second.type">
<el-radio-button label="0">任意值</el-radio-button>
<el-radio-button label="1">范围</el-radio-button>
<el-radio-button label="2">间隔</el-radio-button>
<el-radio-button label="3">指定</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围" v-if="value.second.type == 1">
<el-input-number v-model="value.second.range.start" :min="0" :max="59" controls-position="right"></el-input-number>
<span style="padding: 0 15px">-</span>
<el-input-number v-model="value.second.range.end" :min="0" :max="59" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="间隔" v-if="value.second.type == 2">
<el-input-number v-model="value.second.loop.start" :min="0" :max="59" controls-position="right"></el-input-number>
秒开始,每
<el-input-number v-model="value.second.loop.end" :min="0" :max="59" controls-position="right"></el-input-number>
秒执行一次
</el-form-item>
<el-form-item label="指定" v-if="value.second.type == 3">
<el-select v-model="value.second.appoint" multiple style="width: 100%">
<el-option v-for="(item, index) in data.second" :key="index" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane>
<template #label>
<div class="sc-cron-num">
<h2>分钟</h2>
<h4>{{ value_minute }}</h4>
</div>
</template>
<el-form>
<el-form-item label="类型">
<el-radio-group v-model="value.minute.type">
<el-radio-button label="0">任意值</el-radio-button>
<el-radio-button label="1">范围</el-radio-button>
<el-radio-button label="2">间隔</el-radio-button>
<el-radio-button label="3">指定</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围" v-if="value.minute.type == 1">
<el-input-number v-model="value.minute.range.start" :min="0" :max="59" controls-position="right"></el-input-number>
<span style="padding: 0 15px">-</span>
<el-input-number v-model="value.minute.range.end" :min="0" :max="59" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="间隔" v-if="value.minute.type == 2">
<el-input-number v-model="value.minute.loop.start" :min="0" :max="59" controls-position="right"></el-input-number>
分钟开始,每
<el-input-number v-model="value.minute.loop.end" :min="0" :max="59" controls-position="right"></el-input-number>
分钟执行一次
</el-form-item>
<el-form-item label="指定" v-if="value.minute.type == 3">
<el-select v-model="value.minute.appoint" multiple style="width: 100%">
<el-option v-for="(item, index) in data.minute" :key="index" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane>
<template #label>
<div class="sc-cron-num">
<h2>小时</h2>
<h4>{{ value_hour }}</h4>
</div>
</template>
<el-form>
<el-form-item label="类型">
<el-radio-group v-model="value.hour.type">
<el-radio-button label="0">任意值</el-radio-button>
<el-radio-button label="1">范围</el-radio-button>
<el-radio-button label="2">间隔</el-radio-button>
<el-radio-button label="3">指定</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围" v-if="value.hour.type == 1">
<el-input-number v-model="value.hour.range.start" :min="0" :max="23" controls-position="right"></el-input-number>
<span style="padding: 0 15px">-</span>
<el-input-number v-model="value.hour.range.end" :min="0" :max="23" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="间隔" v-if="value.hour.type == 2">
<el-input-number v-model="value.hour.loop.start" :min="0" :max="23" controls-position="right"></el-input-number>
小时开始,每
<el-input-number v-model="value.hour.loop.end" :min="0" :max="23" controls-position="right"></el-input-number>
小时执行一次
</el-form-item>
<el-form-item label="指定" v-if="value.hour.type == 3">
<el-select v-model="value.hour.appoint" multiple style="width: 100%">
<el-option v-for="(item, index) in data.hour" :key="index" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane>
<template #label>
<div class="sc-cron-num">
<h2>日</h2>
<h4>{{ value_day }}</h4>
</div>
</template>
<el-form>
<el-form-item label="类型">
<el-radio-group v-model="value.day.type">
<el-radio-button label="0">任意值</el-radio-button>
<el-radio-button label="1">范围</el-radio-button>
<el-radio-button label="2">间隔</el-radio-button>
<el-radio-button label="3">指定</el-radio-button>
<el-radio-button label="4">本月最后一天</el-radio-button>
<el-radio-button label="5">不指定</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围" v-if="value.day.type == 1">
<el-input-number v-model="value.day.range.start" :min="1" :max="31" controls-position="right"></el-input-number>
<span style="padding: 0 15px">-</span>
<el-input-number v-model="value.day.range.end" :min="1" :max="31" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="间隔" v-if="value.day.type == 2">
<el-input-number v-model="value.day.loop.start" :min="1" :max="31" controls-position="right"></el-input-number>
号开始,每
<el-input-number v-model="value.day.loop.end" :min="1" :max="31" controls-position="right"></el-input-number>
天执行一次
</el-form-item>
<el-form-item label="指定" v-if="value.day.type == 3">
<el-select v-model="value.day.appoint" multiple style="width: 100%">
<el-option v-for="(item, index) in data.day" :key="index" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane>
<template #label>
<div class="sc-cron-num">
<h2>月</h2>
<h4>{{ value_month }}</h4>
</div>
</template>
<el-form>
<el-form-item label="类型">
<el-radio-group v-model="value.month.type">
<el-radio-button label="0">任意值</el-radio-button>
<el-radio-button label="1">范围</el-radio-button>
<el-radio-button label="2">间隔</el-radio-button>
<el-radio-button label="3">指定</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围" v-if="value.month.type == 1">
<el-input-number v-model="value.month.range.start" :min="1" :max="12" controls-position="right"></el-input-number>
<span style="padding: 0 15px">-</span>
<el-input-number v-model="value.month.range.end" :min="1" :max="12" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="间隔" v-if="value.month.type == 2">
<el-input-number v-model="value.month.loop.start" :min="1" :max="12" controls-position="right"></el-input-number>
月开始,每
<el-input-number v-model="value.month.loop.end" :min="1" :max="12" controls-position="right"></el-input-number>
月执行一次
</el-form-item>
<el-form-item label="指定" v-if="value.month.type == 3">
<el-select v-model="value.month.appoint" multiple style="width: 100%">
<el-option v-for="(item, index) in data.month" :key="index" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane>
<template #label>
<div class="sc-cron-num">
<h2>周</h2>
<h4>{{ value_week }}</h4>
</div>
</template>
<el-form>
<el-form>
<el-form-item label="类型">
<el-radio-group v-model="value.week.type">
<el-radio-button label="0">任意值</el-radio-button>
<el-radio-button label="1">范围</el-radio-button>
<el-radio-button label="2">间隔</el-radio-button>
<el-radio-button label="3">指定</el-radio-button>
<el-radio-button label="4">本月最后一周</el-radio-button>
<el-radio-button label="5">不指定</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围" v-if="value.week.type == 1">
<el-select v-model="value.week.range.start">
<el-option v-for="(item, index) in data.week" :key="index" :label="item.label" :value="item.value"></el-option>
</el-select>
<span style="padding: 0 15px">-</span>
<el-select v-model="value.week.range.end">
<el-option v-for="(item, index) in data.week" :key="index" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
<el-form-item label="间隔" v-if="value.week.type == 2">
第
<el-input-number v-model="value.week.loop.start" :min="1" :max="4" controls-position="right"></el-input-number>
周的星期
<el-select v-model="value.week.loop.end">
<el-option v-for="(item, index) in data.week" :key="index" :label="item.label" :value="item.value"></el-option>
</el-select>
执行一次
</el-form-item>
<el-form-item label="指定" v-if="value.week.type == 3">
<el-select v-model="value.week.appoint" multiple style="width: 100%">
<el-option v-for="(item, index) in data.week" :key="index" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
<el-form-item label="最后一周" v-if="value.week.type == 4">
<el-select v-model="value.week.last">
<el-option v-for="(item, index) in data.week" :key="index" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-form>
</el-tab-pane>
<el-tab-pane>
<template #label>
<div class="sc-cron-num">
<h2>年</h2>
<h4>{{ value_year }}</h4>
</div>
</template>
<el-form>
<el-form-item label="类型">
<el-radio-group v-model="value.year.type">
<el-radio-button label="-1">忽略</el-radio-button>
<el-radio-button label="0">任意值</el-radio-button>
<el-radio-button label="1">范围</el-radio-button>
<el-radio-button label="2">间隔</el-radio-button>
<el-radio-button label="3">指定</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围" v-if="value.year.type == 1">
<el-input-number v-model="value.year.range.start" controls-position="right"></el-input-number>
<span style="padding: 0 15px">-</span>
<el-input-number v-model="value.year.range.end" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="间隔" v-if="value.year.type == 2">
<el-input-number v-model="value.year.loop.start" controls-position="right"></el-input-number>
年开始,每
<el-input-number v-model="value.year.loop.end" :min="1" controls-position="right"></el-input-number>
年执行一次
</el-form-item>
<el-form-item label="指定" v-if="value.year.type == 3">
<el-select v-model="value.year.appoint" multiple style="width: 100%">
<el-option v-for="(item, index) in data.year" :key="index" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" @click="submit()">确 认</el-button>
</template>
</el-dialog>
</template>
<script>
export default {
props: {
modelValue: { type: String, default: '* * * * * ?' },
shortcuts: { type: Array, default: () => [] },
},
data() {
return {
type: '0',
defaultValue: '',
dialogVisible: false,
value: {
second: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 0,
end: 1,
},
appoint: [],
},
minute: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 0,
end: 1,
},
appoint: [],
},
hour: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 0,
end: 1,
},
appoint: [],
},
day: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 1,
end: 1,
},
appoint: [],
},
month: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 1,
end: 1,
},
appoint: [],
},
week: {
type: '5',
range: {
start: '2',
end: '3',
},
loop: {
start: 0,
end: '2',
},
last: '2',
appoint: [],
},
year: {
type: '-1',
range: {
start: this.getYear()[0],
end: this.getYear()[1],
},
loop: {
start: this.getYear()[0],
end: 1,
},
appoint: [],
},
},
data: {
second: ['0', '5', '15', '20', '25', '30', '35', '40', '45', '50', '55', '59'],
minute: ['0', '5', '15', '20', '25', '30', '35', '40', '45', '50', '55', '59'],
hour: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23'],
day: [
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
'11',
'12',
'13',
'14',
'15',
'16',
'17',
'18',
'19',
'20',
'21',
'22',
'23',
'24',
'25',
'26',
'27',
'28',
'29',
'30',
'31',
],
month: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
week: [
{
value: '1',
label: '周日',
},
{
value: '2',
label: '周一',
},
{
value: '3',
label: '周二',
},
{
value: '4',
label: '周三',
},
{
value: '5',
label: '周四',
},
{
value: '6',
label: '周五',
},
{
value: '7',
label: '周六',
},
],
year: this.getYear(),
},
};
},
watch: {
'value.week.type'(val) {
if (val != '5') {
this.value.day.type = '5';
}
},
'value.day.type'(val) {
if (val != '5') {
this.value.week.type = '5';
}
},
modelValue() {
this.defaultValue = this.modelValue;
},
},
computed: {
value_second() {
let v = this.value.second;
if (v.type == 0) {
return '*';
} else if (v.type == 1) {
return v.range.start + '-' + v.range.end;
} else if (v.type == 2) {
return v.loop.start + '/' + v.loop.end;
} else if (v.type == 3) {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
} else {
return '*';
}
},
value_minute() {
let v = this.value.minute;
if (v.type == 0) {
return '*';
} else if (v.type == 1) {
return v.range.start + '-' + v.range.end;
} else if (v.type == 2) {
return v.loop.start + '/' + v.loop.end;
} else if (v.type == 3) {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
} else {
return '*';
}
},
value_hour() {
let v = this.value.hour;
if (v.type == 0) {
return '*';
} else if (v.type == 1) {
return v.range.start + '-' + v.range.end;
} else if (v.type == 2) {
return v.loop.start + '/' + v.loop.end;
} else if (v.type == 3) {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
} else {
return '*';
}
},
value_day() {
let v = this.value.day;
if (v.type == 0) {
return '*';
} else if (v.type == 1) {
return v.range.start + '-' + v.range.end;
} else if (v.type == 2) {
return v.loop.start + '/' + v.loop.end;
} else if (v.type == 3) {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
} else if (v.type == 4) {
return 'L';
} else if (v.type == 5) {
return '?';
} else {
return '*';
}
},
value_month() {
let v = this.value.month;
if (v.type == 0) {
return '*';
} else if (v.type == 1) {
return v.range.start + '-' + v.range.end;
} else if (v.type == 2) {
return v.loop.start + '/' + v.loop.end;
} else if (v.type == 3) {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
} else {
return '*';
}
},
value_week() {
let v = this.value.week;
if (v.type == 0) {
return '*';
} else if (v.type == 1) {
return v.range.start + '-' + v.range.end;
} else if (v.type == 2) {
return v.loop.end + '#' + v.loop.start;
} else if (v.type == 3) {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
} else if (v.type == 4) {
return v.last + 'L';
} else if (v.type == 5) {
return '?';
} else {
return '*';
}
},
value_year() {
let v = this.value.year;
if (v.type == -1) {
return '';
} else if (v.type == 0) {
return '*';
} else if (v.type == 1) {
return v.range.start + '-' + v.range.end;
} else if (v.type == 2) {
return v.loop.start + '/' + v.loop.end;
} else if (v.type == 3) {
return v.appoint.length > 0 ? v.appoint.join(',') : '';
} else {
return '';
}
},
},
mounted() {
this.defaultValue = this.modelValue;
},
methods: {
handleShortcuts(command) {
if (command == 'custom') {
this.open();
} else {
this.defaultValue = command;
this.$emit('update:modelValue', this.defaultValue);
}
},
open() {
this.set();
this.dialogVisible = true;
},
set() {
this.defaultValue = this.modelValue;
let arr = (this.modelValue || '* * * * * ?').split(' ');
//简单检查
if (arr.length < 6) {
this.$message.warning('cron表达式错误,已转换为默认表达式');
arr = '* * * * * ?'.split(' ');
}
//秒
if (arr[0] == '*') {
this.value.second.type = '0';
} else if (arr[0].includes('-')) {
this.value.second.type = '1';
this.value.second.range.start = Number(arr[0].split('-')[0]);
this.value.second.range.end = Number(arr[0].split('-')[1]);
} else if (arr[0].includes('/')) {
this.value.second.type = '2';
this.value.second.loop.start = Number(arr[0].split('/')[0]);
this.value.second.loop.end = Number(arr[0].split('/')[1]);
} else {
this.value.second.type = '3';
this.value.second.appoint = arr[0].split(',');
}
//分
if (arr[1] == '*') {
this.value.minute.type = '0';
} else if (arr[1].includes('-')) {
this.value.minute.type = '1';
this.value.minute.range.start = Number(arr[1].split('-')[0]);
this.value.minute.range.end = Number(arr[1].split('-')[1]);
} else if (arr[1].includes('/')) {
this.value.minute.type = '2';
this.value.minute.loop.start = Number(arr[1].split('/')[0]);
this.value.minute.loop.end = Number(arr[1].split('/')[1]);
} else {
this.value.minute.type = '3';
this.value.minute.appoint = arr[1].split(',');
}
//小时
if (arr[2] == '*') {
this.value.hour.type = '0';
} else if (arr[2].includes('-')) {
this.value.hour.type = '1';
this.value.hour.range.start = Number(arr[2].split('-')[0]);
this.value.hour.range.end = Number(arr[2].split('-')[1]);
} else if (arr[2].includes('/')) {
this.value.hour.type = '2';
this.value.hour.loop.start = Number(arr[2].split('/')[0]);
this.value.hour.loop.end = Number(arr[2].split('/')[1]);
} else {
this.value.hour.type = '3';
this.value.hour.appoint = arr[2].split(',');
}
//日
if (arr[3] == '*') {
this.value.day.type = '0';
} else if (arr[3] == 'L') {
this.value.day.type = '4';
} else if (arr[3] == '?') {
this.value.day.type = '5';
} else if (arr[3].includes('-')) {
this.value.day.type = '1';
this.value.day.range.start = Number(arr[3].split('-')[0]);
this.value.day.range.end = Number(arr[3].split('-')[1]);
} else if (arr[3].includes('/')) {
this.value.day.type = '2';
this.value.day.loop.start = Number(arr[3].split('/')[0]);
this.value.day.loop.end = Number(arr[3].split('/')[1]);
} else {
this.value.day.type = '3';
this.value.day.appoint = arr[3].split(',');
}
//月
if (arr[4] == '*') {
this.value.month.type = '0';
} else if (arr[4].includes('-')) {
this.value.month.type = '1';
this.value.month.range.start = Number(arr[4].split('-')[0]);
this.value.month.range.end = Number(arr[4].split('-')[1]);
} else if (arr[4].includes('/')) {
this.value.month.type = '2';
this.value.month.loop.start = Number(arr[4].split('/')[0]);
this.value.month.loop.end = Number(arr[4].split('/')[1]);
} else {
this.value.month.type = '3';
this.value.month.appoint = arr[4].split(',');
}
//周
if (arr[5] == '*') {
this.value.week.type = '0';
} else if (arr[5] == '?') {
this.value.week.type = '5';
} else if (arr[5].includes('-')) {
this.value.week.type = '1';
this.value.week.range.start = arr[5].split('-')[0];
this.value.week.range.end = arr[5].split('-')[1];
} else if (arr[5].includes('#')) {
this.value.week.type = '2';
this.value.week.loop.start = Number(arr[5].split('#')[1]);
this.value.week.loop.end = arr[5].split('#')[0];
} else if (arr[5].includes('L')) {
this.value.week.type = '4';
this.value.week.last = arr[5].split('L')[0];
} else {
this.value.week.type = '3';
this.value.week.appoint = arr[5].split(',');
}
//年
if (!arr[6]) {
this.value.year.type = '-1';
} else if (arr[6] == '*') {
this.value.year.type = '0';
} else if (arr[6].includes('-')) {
this.value.year.type = '1';
this.value.year.range.start = Number(arr[6].split('-')[0]);
this.value.year.range.end = Number(arr[6].split('-')[1]);
} else if (arr[6].includes('/')) {
this.value.year.type = '2';
this.value.year.loop.start = Number(arr[6].split('/')[1]);
this.value.year.loop.end = Number(arr[6].split('/')[0]);
} else {
this.value.year.type = '3';
this.value.year.appoint = arr[6].split(',');
}
},
getYear() {
let v = [];
let y = new Date().getFullYear();
for (let i = 0; i < 11; i++) {
v.push(y + i);
}
return v;
},
submit() {
let year = this.value_year ? ' ' + this.value_year : '';
this.defaultValue =
this.value_second +
' ' +
this.value_minute +
' ' +
this.value_hour +
' ' +
this.value_day +
' ' +
this.value_month +
' ' +
this.value_week +
year;
this.$emit('update:modelValue', this.defaultValue);
this.dialogVisible = false;
},
},
};
</script>
<style scoped>
.sc-cron:deep(.el-tabs__item) {
height: auto;
line-height: 1;
padding: 0 7px;
vertical-align: bottom;
}
.sc-cron-num {
text-align: center;
margin-bottom: 15px;
width: 100%;
}
.sc-cron-num h2 {
font-size: 12px;
margin-bottom: 15px;
font-weight: normal;
}
.sc-cron-num h4 {
display: block;
height: 32px;
line-height: 30px;
width: 100%;
font-size: 12px;
padding: 0 15px;
background: var(--el-color-primary-light-9);
border-radius: 4px;
}
.sc-cron:deep(.el-tabs__item.is-active) .sc-cron-num h4 {
background: var(--el-color-primary);
color: #fff;
}
[data-theme='dark'] .sc-cron-num h4 {
background: var(--el-color-white);
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div class="del-wrap">
<slot></slot>
<div v-if="showClose" class="icon-close" @click.stop="handleClose">
<el-icon><CloseBold /></el-icon>
</div>
</div>
</template>
<script lang="ts">
export default defineComponent({
props: {
showClose: {
type: Boolean,
default: true,
},
},
emits: ['close'],
setup(props, { emit }) {
const handleClose = () => {
emit('close');
};
return {
handleClose,
};
},
});
</script>
<style scoped lang="scss">
.del-wrap {
position: relative;
&:hover > .icon-close {
display: flex;
}
.icon-close {
display: none;
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
background-color: rgba(0, 0, 0, 0.3);
justify-content: center;
align-items: center;
border-radius: 50%;
color: #fff;
cursor: pointer;
}
}
</style>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div>
<template v-for="(item, index) in props.options">
<template v-if="values.includes(item.value || item)">
<span v-if="item.elTagType == 'default' || item.elTagType == ''" :key="index" :index="index" :class="item.elTagClass">{{
item.label || item
}}</span>
<el-tag
v-else
:disable-transitions="true"
:key="index * 2"
:index="index"
:type="item.elTagType === 'primary' ? '' : item.elTagType"
:class="item.elTagClass"
>{{ item.label || item }}</el-tag
>
</template>
</template>
</div>
</template>
<script setup lang="ts" name="dict-tag">
import { computed } from 'vue';
const props = defineProps({
// 数据
options: {
type: Array as any,
default: null,
},
// 当前的值
value: [Number, String, Array],
});
const values = computed(() => {
if (props.value !== null && typeof props.value !== 'undefined') {
return Array.isArray(props.value) ? props.value : [String(props.value)];
} else {
return [];
}
});
</script>
<style scoped>
.el-tag + .el-tag {
margin-left: 10px;
}
</style>
+141
View File
@@ -0,0 +1,141 @@
<template>
<div class="flex flex-col border border-br" :style="styles">
<Toolbar class="border-b border-br" :editor="editorRef" :mode="mode" />
<Editor
class="flex-1 overflow-y-auto"
:mode="mode"
:defaultConfig="state.editorConfig"
v-model="state.editorVal"
@onCreated="handleCreated"
@onChange="handleChange"
/>
</div>
</template>
<script setup lang="ts" name="wngEditor">
import '@wangeditor/editor/dist/css/style.css';
import { reactive, shallowRef, watch, onBeforeUnmount, CSSProperties } from 'vue';
// @ts-ignore
import { IDomEditor } from '@wangeditor/editor';
import { Toolbar, Editor } from '@wangeditor/editor-for-vue';
import { Session } from '/@/utils/storage';
import other from '/@/utils/other';
const { proxy } = getCurrentInstance();
// 定义父组件传过来的值
const props = defineProps({
// 是否禁用
disable: {
type: Boolean,
default: () => false,
},
// 内容框默认 placeholder
placeholder: {
type: String,
default: () => '请输入内容...',
},
// https://www.wangeditor.com/v5/getting-started.html#mode-%E6%A8%A1%E5%BC%8F
// 模式,可选 <default|simple>,默认 default
mode: {
type: String,
default: () => 'default',
},
// 高度
height: {
type: String,
default: () => '310',
},
// 宽度
width: {
type: String,
default: () => 'auto',
},
// 双向绑定,用于获取 editor.getHtml()
getHtml: String,
// 双向绑定,用于获取 editor.getText()
getText: String,
uploadFileUrl: {
type: String,
default: `/admin/sys-file/upload`,
},
});
// 定义子组件向父组件传值/事件
const emit = defineEmits(['update:getHtml', 'update:getText']);
// 定义上传需要的请求头信息
const headers = computed(() => {
return {
Authorization: 'Bearer ' + Session.get('token'),
'TENANT-ID': Session.getTenant(),
};
});
// 定义上传需要的字段信息
const uploadAttr = reactive({
fieldName: 'file',
server: proxy.baseURL + props.uploadFileUrl,
headers: headers,
customInsert(res, insertFn) {
insertFn(proxy.baseURL + res.data.url);
},
});
const editorRef = shallowRef();
const state = reactive({
editorConfig: {
placeholder: props.placeholder,
MENU_CONF: {
uploadImage: uploadAttr,
uploadVideo: uploadAttr,
},
},
editorVal: props.getHtml,
});
const styles = computed<CSSProperties>(() => ({
height: other.addUnit(props.height),
width: other.addUnit(props.width),
'z-index': 1000,
}));
// 编辑器回调函数
const handleCreated = (editor: IDomEditor) => {
editorRef.value = editor;
};
// 编辑器内容改变时
const handleChange = (editor: IDomEditor) => {
emit('update:getHtml', editor.getHtml());
emit('update:getText', editor.getText());
};
// 页面销毁时
onBeforeUnmount(() => {
const editor = editorRef.value;
if (editor == null) return;
emit('update:getHtml', '');
emit('update:getText', '');
editor.destroy();
});
// 监听是否禁用改变
onMounted(() =>{
nextTick(()=>{
const editor = editorRef.value;
if (editor == null) return;
props.disable ? editor.disable() : editor.enable();
})
})
// 监听双向绑定值改变,用于回显
watch(
() => props.getHtml,
(val) => {
state.editorVal = val;
},
{
deep: true,
}
);
</script>
+210
View File
@@ -0,0 +1,210 @@
<template>
<div class="form-table" ref="scFormTable">
<el-table
:data="data"
ref="table"
border
stripe
:cell-style="{ textAlign: 'center' }"
:header-cell-style="{
textAlign: 'center',
background: 'var(--el-table-row-hover-bg-color)',
color: 'var(--el-text-color-primary)',
}"
>
<el-table-column type="index" width="50" fixed="left">
<template #header>
<el-button v-if="!hideAdd" type="primary" icon="el-icon-plus" size="small" circle @click="rowAdd"></el-button>
<el-tooltip v-else content="序号" placement="top"> # </el-tooltip>
</template>
<template #default="scope">
<div :class="['form-table-handle', { 'form-table-handle-delete': !hideDelete }]">
<span>{{ scope.$index + 1 }}</span>
<el-button
v-if="!hideDelete"
type="danger"
icon="el-icon-delete"
size="small"
plain
circle
@click="rowDel(scope.row, scope.$index)"
></el-button>
</div>
</template>
</el-table-column>
<el-table-column label="" width="50" v-if="dragSort">
<template #header>
<el-icon>
<el-tooltip content="拖动排序" placement="top">
<WarningFilled />
</el-tooltip>
</el-icon>
</template>
<template #default>
<div class="move" style="cursor: move">
<el-icon>
<Sort />
</el-icon>
</div>
</template>
</el-table-column>
<slot></slot>
<template #empty>
{{ placeholder }}
</template>
</el-table>
</div>
</template>
<script>
import Sortable from 'sortablejs';
export default {
props: {
/**
* 表格数据
*/
modelValue: { type: Array, default: () => [] },
/**
* 新增行模板
*/
addTemplate: { type: Object, default: () => {} },
/**
* 无数据时的提示语
*/
placeholder: { type: String, default: '暂无数据' },
/**
* 是否启用拖拽排序
*/
dragSort: { type: Boolean, default: false },
/**
* 是否隐藏新增按钮
*/
hideAdd: { type: Boolean, default: false },
/**
* 是否隐藏删除按钮
*/
hideDelete: { type: Boolean, default: false },
},
data() {
return {
/**
* 表格数据
*/
data: [],
};
},
mounted() {
this.data = this.modelValue;
if (this.dragSort) {
this.rowDrop();
}
},
watch: {
modelValue() {
this.data = this.modelValue;
},
data: {
handler() {
/**
* 更新表格数据
* @event update:modelValue
* @type {Array}
*/
this.$emit('update:modelValue', this.data);
},
deep: true,
},
},
methods: {
/**
* 启用表格行拖拽排序
*/
rowDrop() {
const _this = this;
const tbody = this.$refs.table.$el.querySelector('.el-table__body-wrapper tbody');
Sortable.create(tbody, {
handle: '.move',
animation: 300,
ghostClass: 'ghost',
onEnd({ newIndex, oldIndex }) {
_this.data.splice(newIndex, 0, _this.data.splice(oldIndex, 1)[0]);
const newArray = _this.data.slice(0);
const tmpHeight = _this.$refs.scFormTable.offsetHeight;
_this.$refs.scFormTable.style.setProperty('height', tmpHeight + 'px');
_this.data = [];
_this.$nextTick(() => {
_this.data = newArray;
_this.$nextTick(() => {
_this.$refs.scFormTable.style.removeProperty('height');
});
});
},
});
},
/**
* 新增一行
*/
rowAdd() {
const temp = JSON.parse(JSON.stringify(this.addTemplate));
this.data.push(temp);
},
/**
* 删除一行
* @param {Object} row - 要删除的行数据
* @param {number} index - 要删除的行的索引
*/
rowDel(row, index) {
this.data.splice(index, 1);
this.$emit('delete', row);
},
/**
* 插入一行
* @param {Object} row - 要插入的行数据,默认为新增行模板
*/
pushRow(row) {
const temp = row || JSON.parse(JSON.stringify(this.addTemplate));
this.data.push(temp);
},
/**
* 根据索引删除一行
* @param {number} index - 要删除的行的索引
*/
deleteRow(index) {
this.data.splice(index, 1);
},
},
};
</script>
<style scoped>
.form-table {
width: 100%;
}
.form-table .form-table-handle {
text-align: center;
}
.form-table .form-table-handle span {
display: inline-block;
}
.form-table .form-table-handle button {
display: none;
}
.form-table .hover-row .form-table-handle-delete span {
display: none;
}
.form-table .hover-row .form-table-handle-delete button {
display: inline-block;
}
.form-table .move {
text-align: center;
font-size: 14px;
margin-top: 3px;
}
</style>
+71
View File
@@ -0,0 +1,71 @@
import { readFileSync, readdirSync } from 'fs';
let idPerfix = '';
const iconNames: string[] = [];
const svgTitle = /<svg([^>+].*?)>/;
const clearHeightWidth = /(width|height)="([^>+].*?)"/g;
const hasViewBox = /(viewBox="[^>+].*?")/g;
const clearReturn = /(\r)|(\n)/g;
// 清理 svg 的 fill
const clearFill = /(fill="[^>+].*?")/g;
function findSvgFile(dir: string): string[] {
const svgRes = [] as any;
const dirents = readdirSync(dir, {
withFileTypes: true,
});
for (const dirent of dirents) {
iconNames.push(`${idPerfix}-${dirent.name.replace('.svg', '')}`);
if (dirent.isDirectory()) {
svgRes.push(...findSvgFile(dir + dirent.name + '/'));
} else {
const svg = readFileSync(dir + dirent.name)
.toString()
.replace(clearReturn, '')
.replace(clearFill, 'fill=""')
.replace(svgTitle, ($1, $2) => {
let width = 0;
let height = 0;
let content = $2.replace(clearHeightWidth, (s1: string, s2: string, s3: number) => {
if (s2 === 'width') {
width = s3;
} else if (s2 === 'height') {
height = s3;
}
return '';
});
if (!hasViewBox.test($2)) {
content += `viewBox="0 0 ${width} ${height}"`;
}
return `<symbol id="${idPerfix}-${dirent.name.replace('.svg', '')}" ${content}>`;
})
.replace('</svg>', '</symbol>');
svgRes.push(svg);
}
}
return svgRes;
}
export const svgBuilder = (path: string, perfix = 'local') => {
if (path === '') return;
idPerfix = perfix;
const res = findSvgFile(path);
return {
name: 'svg-transform',
transformIndexHtml(html: string) {
/* eslint-disable */
return html.replace(
'<body>',
`
<body>
<svg id="local-icon" data-icon-name="${iconNames.join(
','
)}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="position: absolute; width: 0; height: 0">
${res.join('')}
</svg>
`
);
/* eslint-enable */
},
};
};
+254
View File
@@ -0,0 +1,254 @@
<template>
<div class="icon-selector w100 h100">
<el-input
v-model="state.fontIconSearch"
:placeholder="state.fontIconPlaceholder"
:clearable="clearable"
:disabled="disabled"
:size="size"
ref="inputWidthRef"
@clear="onClearFontIcon"
@focus="onIconFocus"
@blur="onIconBlur"
>
<template #prepend>
<SvgIcon :name="state.fontIconPrefix === '' ? prepend : state.fontIconPrefix" class="font14" />
</template>
</el-input>
<el-popover
placement="bottom"
:width="state.fontIconWidth"
transition="el-zoom-in-top"
popper-class="icon-selector-popper"
trigger="click"
:virtual-ref="inputWidthRef"
virtual-triggering
>
<template #default>
<div class="icon-selector-warp">
<div class="icon-selector-warp-title">{{ title }}</div>
<el-tabs v-model="state.fontIconTabActive" @tab-click="onIconClick">
<el-tab-pane lazy label="ali" name="ali">
<IconList :list="fontIconSheetsFilterList" :empty="emptyDescription" :prefix="state.fontIconPrefix" @get-icon="onColClick" />
</el-tab-pane>
<el-tab-pane lazy label="ele" name="ele">
<IconList :list="fontIconSheetsFilterList" :empty="emptyDescription" :prefix="state.fontIconPrefix" @get-icon="onColClick" />
</el-tab-pane>
<el-tab-pane lazy label="awe" name="awe">
<IconList :list="fontIconSheetsFilterList" :empty="emptyDescription" :prefix="state.fontIconPrefix" @get-icon="onColClick" />
</el-tab-pane>
<el-tab-pane lazy label="local" name="local">
<IconList :list="fontIconSheetsFilterList" :empty="emptyDescription" :prefix="state.fontIconPrefix" @get-icon="onColClick" />
</el-tab-pane>
</el-tabs>
</div>
</template>
</el-popover>
</div>
</template>
<script setup lang="ts" name="iconSelector">
import { defineAsyncComponent, ref, reactive, onMounted, nextTick, computed, watch } from 'vue';
import type { TabsPaneContext } from 'element-plus';
import initIconfont from '/@/utils/getStyleSheets';
import '/@/theme/iconSelector.scss';
// 定义父组件传过来的值
const props = defineProps({
// 输入框前置内容
prepend: {
type: String,
default: () => 'ele-Pointer',
},
// 输入框占位文本
placeholder: {
type: String,
default: () => '请输入内容搜索图标或者选择图标',
},
// 输入框占位文本
size: {
type: String,
default: () => 'default',
},
// 弹窗标题
title: {
type: String,
default: () => '请选择图标',
},
// 禁用
disabled: {
type: Boolean,
default: () => false,
},
// 是否可清空
clearable: {
type: Boolean,
default: () => true,
},
// 自定义空状态描述文字
emptyDescription: {
type: String,
default: () => '无相关图标',
},
// 双向绑定值,默认为 modelValue,
// 参考:https://v3.cn.vuejs.org/guide/migration/v-model.html#%E8%BF%81%E7%A7%BB%E7%AD%96%E7%95%A5
// 参考:https://v3.cn.vuejs.org/guide/component-custom-events.html#%E5%A4%9A%E4%B8%AA-v-model-%E7%BB%91%E5%AE%9A
modelValue: String,
});
// 定义子组件向父组件传值/事件
const emit = defineEmits(['update:modelValue', 'get', 'clear']);
// 引入组件
const IconList = defineAsyncComponent(() => import('/@/components/IconSelector/list.vue'));
// 定义变量内容
const inputWidthRef = ref();
const state = reactive({
fontIconPrefix: '',
fontIconWidth: 0,
fontIconSearch: '',
fontIconPlaceholder: '',
fontIconTabActive: 'ali',
fontIconList: {
ali: [],
ele: [],
awe: [],
local: [],
},
});
// 处理 input 获取焦点时,modelValue 有值时,改变 input 的 placeholder 值
const onIconFocus = () => {
if (!props.modelValue) return false;
state.fontIconSearch = '';
state.fontIconPlaceholder = props.modelValue;
};
// 处理 input 失去焦点时,为空将清空 input 值,为点击选中图标时,将取原先值
const onIconBlur = () => {
const list = fontIconTabNameList();
setTimeout(() => {
const icon = list.filter((icon: string) => icon === state.fontIconSearch);
if (icon.length <= 0) state.fontIconSearch = '';
}, 300);
};
// 图标搜索及图标数据显示
const fontIconSheetsFilterList = computed(() => {
const list = fontIconTabNameList();
if (!state.fontIconSearch) return list;
let search = state.fontIconSearch.trim().toLowerCase();
return list.filter((item: string) => {
if (item.toLowerCase().indexOf(search) !== -1) return item;
});
});
// 根据 tab name 类型设置图标
const fontIconTabNameList = () => {
let iconList: any = [];
if (state.fontIconTabActive === 'ali') iconList = state.fontIconList.ali;
else if (state.fontIconTabActive === 'ele') iconList = state.fontIconList.ele;
else if (state.fontIconTabActive === 'awe') iconList = state.fontIconList.awe;
else if (state.fontIconTabActive === 'local') iconList = state.fontIconList.local;
return iconList;
};
// 处理 icon 双向绑定数值回显
const initModeValueEcho = () => {
if (props.modelValue === '') return ((<string | undefined>state.fontIconPlaceholder) = props.placeholder);
(<string | undefined>state.fontIconPlaceholder) = props.modelValue;
(<string | undefined>state.fontIconPrefix) = props.modelValue;
};
// 处理 icon 类型,用于回显时,tab 高亮与初始化数据
const initFontIconName = () => {
let name = 'ali';
if (!props.modelValue) {
return name;
}
if (props.modelValue!.indexOf('iconfont') > -1) name = 'ali';
else if (props.modelValue!.indexOf('ele-') > -1) name = 'ele';
else if (props.modelValue!.indexOf('fa') > -1) name = 'awe';
else if (props.modelValue!.indexOf('local') > -1) name = 'local';
// 初始化 tab 高亮回显
state.fontIconTabActive = name;
return name;
};
// 初始化数据
const initFontIconData = async (name: string) => {
if (name === 'ali') {
// 阿里字体图标使用 `iconfont xxx`
if (state.fontIconList.ali.length > 0) return;
await initIconfont.ali().then((res: any) => {
state.fontIconList.ali = res.map((i: string) => `iconfont ${i}`);
});
} else if (name === 'ele') {
// element plus 图标
if (state.fontIconList.ele.length > 0) return;
await initIconfont.ele().then((res: any) => {
state.fontIconList.ele = res;
});
} else if (name === 'awe') {
// fontawesome字体图标使用 `fa xxx`
if (state.fontIconList.awe.length > 0) return;
await initIconfont.awe().then((res: any) => {
state.fontIconList.awe = res.map((i: string) => `fa ${i}`);
});
} else if (name === 'local') {
if (state.fontIconList.local.length > 0) return;
await initIconfont.local().then((res: any) => {
state.fontIconList.local = res.map((i: string) => `${i}`);
});
}
// 初始化 input 的 placeholder
// 参考(单项数据流):https://cn.vuejs.org/v2/guide/components-props.html?#%E5%8D%95%E5%90%91%E6%95%B0%E6%8D%AE%E6%B5%81
state.fontIconPlaceholder = props.placeholder;
// 初始化双向绑定回显
initModeValueEcho();
};
// 图标点击切换
const onIconClick = (pane: TabsPaneContext) => {
initFontIconData(pane.paneName as string);
inputWidthRef.value.focus();
};
// 获取当前点击的 icon 图标
const onColClick = (v: string) => {
state.fontIconPlaceholder = v;
state.fontIconPrefix = v;
emit('get', state.fontIconPrefix);
emit('update:modelValue', state.fontIconPrefix);
inputWidthRef.value.focus();
};
// 清空当前点击的 icon 图标
const onClearFontIcon = () => {
state.fontIconPrefix = '';
emit('clear', state.fontIconPrefix);
emit('update:modelValue', state.fontIconPrefix);
};
// 获取 input 的宽度
const getInputWidth = () => {
nextTick(() => {
state.fontIconWidth = inputWidthRef.value.$el.offsetWidth;
});
};
// 监听页面宽度改变
const initResize = () => {
window.addEventListener('resize', () => {
getInputWidth();
});
};
// 页面加载时
onMounted(() => {
initFontIconData(initFontIconName());
window.addEventListener('resize', getInputWidth);
getInputWidth();
});
onUnmounted(() => {
// 移除监听
window.removeEventListener("resize", getInputWidth);
})
// 监听双向绑定 modelValue 的变化
watch(
() => props.modelValue,
() => {
initModeValueEcho();
initFontIconName();
}
);
</script>
+84
View File
@@ -0,0 +1,84 @@
<template>
<div class="icon-selector-warp-row">
<el-scrollbar ref="selectorScrollbarRef">
<el-row :gutter="10" v-if="props.list.length > 0">
<el-col :xs="6" :sm="4" :md="4" :lg="4" :xl="4" v-for="(v, k) in list" :key="k" @click="onColClick(v)">
<div class="icon-selector-warp-item" :class="{ 'icon-selector-active': prefix === v }">
<SvgIcon :name="v" />
</div>
</el-col>
</el-row>
<el-empty :image-size="100" v-if="list.length <= 0" :description="empty"></el-empty>
</el-scrollbar>
</div>
</template>
<script setup lang="ts" name="iconSelectorList">
// 定义父组件传过来的值
const props = defineProps({
// 图标列表数据
list: {
type: Array,
default: () => [],
},
// 自定义空状态描述文字
empty: {
type: String,
default: () => '无相关图标',
},
// 高亮当前选中图标
prefix: {
type: String,
default: () => '',
},
});
// 定义子组件向父组件传值/事件
const emit = defineEmits(['get-icon']);
// 当前 icon 图标点击时
const onColClick = (v: unknown | string) => {
emit('get-icon', v);
};
</script>
<style scoped lang="scss">
.icon-selector-warp-row {
height: 230px;
overflow: hidden;
.el-row {
padding: 15px;
}
.el-scrollbar__bar.is-horizontal {
display: none;
}
.icon-selector-warp-item {
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--el-border-color);
border-radius: 5px;
margin-bottom: 10px;
height: 30px;
i {
font-size: 20px;
color: var(--el-text-color-regular);
}
&:hover {
cursor: pointer;
background-color: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-5);
i {
color: var(--el-color-primary);
}
}
}
.icon-selector-active {
background-color: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-5);
i {
color: var(--el-color-primary);
}
}
}
</style>
+36
View File
@@ -0,0 +1,36 @@
<template>
<div class="custom-link mt-[30px]">
<div class="flex flex-wrap items-center">
自定义链接
<div class="ml-4 flex-1 min-w-[100px]">
<el-input :model-value="modelValue.query?.url" placeholder="请输入链接地址" @input="handleInput" />
</div>
</div>
<div class="form-tips">请填写完整的带有“https://”或“http://”的链接地址,链接的域名必须在微信公众平台设置业务域名</div>
</div>
</template>
<script lang="ts" setup>
import type { PropType } from 'vue';
import { LinkTypeEnum, type Link } from '.';
defineProps({
modelValue: {
type: Object as PropType<Link>,
default: () => ({}),
},
});
const emit = defineEmits<{
(event: 'update:modelValue', value: Link): void;
}>();
const handleInput = (value: string) => {
emit('update:modelValue', {
path: '/pages/webview/webview',
query: {
url: value,
},
type: LinkTypeEnum.CUSTOM_LINK,
});
};
</script>
+11
View File
@@ -0,0 +1,11 @@
export enum LinkTypeEnum {
'SHOP_PAGES' = 'shop',
'CUSTOM_LINK' = 'custom',
}
export interface Link {
path: string;
name?: string;
type: string;
query?: Record<string, any>;
}
+92
View File
@@ -0,0 +1,92 @@
<template>
<div class="flex link">
<el-menu :default-active="activeMenu" class="!w-[160px] min-h-[350px] link-menu" @select="handleSelect">
<el-menu-item v-for="(item, index) in menus" :index="item.type" :key="index">
<span>{{ item.name }}</span>
</el-menu-item>
</el-menu>
<div class="flex-1 pl-4">
<shop-pages v-model="activeLink" v-if="LinkTypeEnum.SHOP_PAGES == activeMenu" />
<custom-link v-model="activeLink" v-if="LinkTypeEnum.CUSTOM_LINK == activeMenu" />
</div>
</div>
</template>
<script lang="ts" setup>
import type { PropType } from 'vue';
import { LinkTypeEnum, type Link } from '.';
import ShopPages from './shop-pages.vue';
import CustomLink from './custom-link.vue';
const props = defineProps({
modelValue: {
type: Object as PropType<Link>,
required: true,
},
});
const emit = defineEmits<{
(event: 'update:modelValue', value: any): void;
}>();
const menus = ref([
{
name: '商城页面',
type: LinkTypeEnum.SHOP_PAGES,
link: {},
},
{
name: '自定义链接',
type: LinkTypeEnum.CUSTOM_LINK,
link: {},
},
]);
const activeLink = computed({
get() {
return menus.value.find((item) => item.type == activeMenu.value)?.link as Link;
},
set(value) {
menus.value.forEach((item) => {
if (item.type == activeMenu.value) {
item.link = value;
}
});
},
});
const activeMenu = ref<string>(LinkTypeEnum.SHOP_PAGES);
const handleSelect = (index: string) => {
activeMenu.value = index;
};
watch(activeLink, (value) => {
if (!value?.type) return;
emit('update:modelValue', value);
});
watch(
() => props.modelValue,
(value) => {
activeMenu.value = value.type;
activeLink.value = value;
},
{
immediate: true,
}
);
</script>
<style lang="scss" scoped>
.link-menu {
--el-menu-item-height: 40px;
:deep(.el-menu-item) {
border-color: transparent !important;
&.is-active {
border-right-width: 2px !important;
border-color: var(--el-color-primary) !important;
background-color: var(--el-color-primary-light-9) !important;
}
}
}
</style>
+76
View File
@@ -0,0 +1,76 @@
<template>
<div class="flex-1 link-picker" @click="!disabled && popupRef?.open()">
<el-input :model-value="getLink" placeholder="请选择链接" readonly :disabled="disabled"> </el-input>
<popup ref="popupRef" width="700px" title="链接选择" @confirm="handleConfirm">
<link-content v-model="activeLink" />
</popup>
</div>
</template>
<script lang="ts" setup>
import {type Link, LinkTypeEnum} from '.';
import LinkContent from './index.vue';
import Popup from '/@/components/Popup/index.vue';
const props = defineProps({
modelValue: {
type: Object,
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits<{
(event: 'update:modelValue', value: any): void;
}>();
const popupRef = shallowRef<InstanceType<typeof Popup>>();
const activeLink = ref<Link>({ path: '', type: LinkTypeEnum.SHOP_PAGES });
const handleConfirm = () => {
emit('update:modelValue', activeLink.value);
};
const getLink = computed(() => {
switch (props.modelValue?.type) {
case LinkTypeEnum.SHOP_PAGES:
return props.modelValue.name;
case LinkTypeEnum.CUSTOM_LINK:
return props.modelValue.query?.url;
default:
return props.modelValue?.name;
}
});
watch(
() => props.modelValue,
(value) => {
if (value?.type) {
activeLink.value = value as Link;
}
},
{
immediate: true,
}
);
</script>
<style scoped lang="scss">
.link-picker {
:deep(.el-input) {
&.is-disabled {
.el-input__inner {
cursor: not-allowed;
}
.el-input__suffix {
cursor: not-allowed;
}
}
.el-input__inner {
cursor: pointer;
}
.el-input__suffix {
cursor: pointer;
}
}
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<template>
<div class="shop-pages">
<div class="flex flex-wrap link-list">
<div
class="link-item border border-br px-5 py-[5px] rounded-[3px] cursor-pointer mr-[10px] mb-[10px]"
v-for="(item, index) in linkList"
:class="{
'border-primary text-primary': modelValue.path == item.path && modelValue.name == item.name,
}"
:key="index"
@click="handleSelect(item)"
>
{{ item.name }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { PropType } from 'vue';
import { LinkTypeEnum, type Link } from '.';
defineProps({
modelValue: {
type: Object as PropType<Link>,
default: () => ({}),
},
});
const emit = defineEmits<{
(event: 'update:modelValue', value: Link): void;
}>();
const linkList = ref([
{
path: '/pages/index/index',
name: '首页',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/news/news',
name: '文章资讯',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/user/user',
name: '个人中心',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/collection/collection',
name: '我的收藏',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/customer_service/customer_service',
name: '联系客服',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/user_set/user_set',
name: '个人设置',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/as_us/as_us',
name: '关于我们',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/user_data/user_data',
name: '个人资料',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/agreement/agreement',
name: '隐私政策',
query: {
type: 'privacy',
},
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/agreement/agreement',
name: '服务协议',
query: {
type: 'service',
},
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/search/search',
name: '搜索',
type: LinkTypeEnum.SHOP_PAGES,
}
]);
const handleSelect = (value: Link) => {
emit('update:modelValue', value);
};
</script>
+92
View File
@@ -0,0 +1,92 @@
<template>
<div>
<div class="relative flex items-center justify-center file-item" :style="{ height: fileSize, width: fileSize }">
<el-image class="image" v-if="type === 'image'" fit="contain" :src="uri"></el-image>
<video class="video" v-else-if="type === 'video'" :src="uri"></video>
<div
v-if="type == 'video'"
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] rounded-full w-5 h-5 flex justify-center items-center bg-[rgba(0,0,0,0.3)]"
>
<el-icon><CaretRight /></el-icon>
</div>
<div v-if="type === 'file'" class="flex items-center justify-center">
<img class="w-16" :src="getFileImage(uri)" />
</div>
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import txt from '/@/assets/txt.png';
import word from '/@/assets/word.png';
import excel from '/@/assets/excel.png';
import pdf from '/@/assets/pdf.png';
import ppt from '/@/assets/ppt.png';
import folder from '/@/assets/icon_folder.png';
export default defineComponent({
props: {
// 图片地址
uri: {
type: String,
},
// 图片尺寸
fileSize: {
type: String,
default: '100px',
},
// 文件类型
type: {
type: String,
default: 'image',
},
},
emits: ['close'],
methods: {
getFileImage(uri?: string) {
if (uri?.includes('txt')) {
return txt;
}
if (uri?.includes('xls')) {
return excel;
}
if (uri?.includes('doc')) {
return word;
}
if (uri?.includes('pdf')) {
return pdf;
}
if (uri?.includes('ppt')) {
return ppt;
}
return folder;
},
},
});
</script>
<style scoped lang="scss">
.file-item {
box-sizing: border-box;
position: relative;
border-radius: 4px;
overflow: hidden;
@apply bg-br-extra-light border border-br-extra-light;
.image,
.video {
display: block;
box-sizing: border-box;
width: 100%;
height: 100%;
}
}
</style>
+200
View File
@@ -0,0 +1,200 @@
import { fileGroupAdd, fileGroupDelete, fileGroupUpdate, fileCateLists, fileDelete, fileList, fileMove, fileRename } from '/@/api/admin/file';
import { usePaging } from './usePaging';
import { ElMessage, ElTree, type CheckboxValueType } from 'element-plus';
import { shallowRef, type Ref } from 'vue';
import { useMessageBox } from '/@/hooks/message';
// 左侧分组的钩子函数
export function useCate(type: number) {
const treeRef = shallowRef<InstanceType<typeof ElTree>>();
// 分组列表
const cateLists = ref<any[]>([]);
// 选中的分组id
const cateId = ref<number | string>('');
// 获取分组列表
const getCateLists = async () => {
const { data } = await fileCateLists({
type,
});
const item: any[] = [
{
name: '全部',
id: '',
},
{
name: '未分组',
id: -1,
},
];
cateLists.value = data;
cateLists.value?.unshift(...item);
setTimeout(() => {
treeRef.value?.setCurrentKey(cateId.value);
}, 0);
};
// 添加分组
const handleAddCate = async (value: string) => {
await fileGroupAdd({
type,
name: value,
pid: -1,
});
getCateLists();
};
// 编辑分组
const handleEditCate = async (value: string, id: number) => {
await fileGroupUpdate({
id,
name: value,
});
getCateLists();
};
// 删除分组
const handleDeleteCate = async (id: number) => {
try {
await useMessageBox().confirm('确定要删除?');
} catch (error) {
return;
}
await fileGroupDelete({ id });
cateId.value = '';
getCateLists();
};
//选中分类
const handleCatSelect = (item: any) => {
cateId.value = item.id;
};
return {
treeRef,
cateId,
cateLists,
handleAddCate,
handleEditCate,
handleDeleteCate,
getCateLists,
handleCatSelect,
};
}
// 处理文件的钩子函数
export function useFile(cateId: Ref<string | number>, type: Ref<number>, limit: Ref<number>, size: number) {
const tableRef = shallowRef();
const listShowType = ref('normal');
const moveId = ref(-1);
const select = ref<any[]>([]);
const isCheckAll = ref(false);
const isIndeterminate = ref(false);
const fileParams = reactive({
original: '',
type: type,
groupId: cateId,
});
const { pager, getLists, resetPage } = usePaging({
fetchFun: fileList,
params: fileParams,
firstLoading: true,
size,
});
const getFileList = () => {
getLists();
};
const refresh = () => {
resetPage();
};
const isSelect = (id: number) => {
return !!select.value.find((item: any) => item.id == id);
};
const batchFileDelete = async (id?: number[]) => {
try {
await useMessageBox().confirm('确认删除后本地将同步删除,如文件已被使用,请谨慎操作!');
} catch {
return;
}
const ids = id ? id : select.value.map((item: any) => item.id);
await fileDelete({ ids });
getFileList();
clearSelect();
};
const batchFileMove = async () => {
const ids = select.value.map((item: any) => item.id);
await fileMove({ ids, groupId: moveId.value });
moveId.value = -1;
getFileList();
clearSelect();
};
const selectFile = (item: any) => {
const index = select.value.findIndex((items: any) => items.id == item.id);
if (index != -1) {
select.value.splice(index, 1);
return;
}
if (select.value.length == limit.value) {
if (limit.value == 1) {
select.value = [];
select.value.push(item);
return;
}
ElMessage.warning('已达到选择上限');
return;
}
select.value.push(item);
};
const clearSelect = () => {
select.value = [];
};
const cancelSelete = (id: number) => {
select.value = select.value.filter((item: any) => item.id != id);
};
const selectAll = (value: CheckboxValueType) => {
isIndeterminate.value = false;
tableRef.value?.toggleAllSelection();
if (value) {
select.value = [...pager.lists];
return;
}
clearSelect();
};
const handleFileRename = async (value: string, id: number) => {
await fileRename({
id,
original: value,
});
getFileList();
};
return {
listShowType,
tableRef,
moveId,
pager,
fileParams,
select,
isCheckAll,
isIndeterminate,
getFileList,
refresh,
batchFileDelete,
batchFileMove,
selectFile,
isSelect,
clearSelect,
cancelSelete,
selectAll,
handleFileRename,
};
}
+18
View File
@@ -0,0 +1,18 @@
export default {
material: {
uploadFileTip: 'upload',
addGroup: 'add group',
editGroup: 'edit group',
delGroup: 'del group',
moveBtn: 'move',
preview: 'preview',
edit: 'edit',
view: 'view',
add: 'add',
allCheck: 'all check',
rename: 'rename',
download: 'download',
list: 'list',
grid: 'grid',
},
};
+18
View File
@@ -0,0 +1,18 @@
export default {
material: {
uploadFileTip: '上传',
addGroup: '新增分组',
editGroup: '修改分组',
delGroup: '删除分组',
moveBtn: '移动',
preview: '预览',
edit: '修改',
view: '查看',
add: '添加',
allCheck: '全选',
rename: '重命名',
download: '下载',
list: '列表',
grid: '平铺',
},
};
+507
View File
@@ -0,0 +1,507 @@
<template>
<div class="material">
<div class="material__left">
<div class="flex-1 min-h-0">
<el-scrollbar>
<div class="pt-4 material-left__content p-b-4">
<el-tree
ref="treeRef"
node-key="id"
:data="cateLists"
empty-text="''"
:highlight-current="true"
:expand-on-click-node="false"
:current-node-key="cateId"
@node-click="handleCatSelect"
>
<template v-slot="{ data }">
<div class="flex items-center flex-1 min-w-0 pr-4">
<img class="w-[20px] h-[16px] mr-3" src="/@/assets/icon_folder.png"/>
<span class="flex-1 mr-2 truncate">
{{ data.name }}
</span>
<el-dropdown v-if="data.id > 0" :hide-on-click="false">
<span class="muted m-r-10">···</span>
<template #dropdown>
<el-dropdown-menu>
<popover-input
@confirm="handleEditCate($event, data.id)"
size="default"
:value="data.name"
width="400px"
:limit="20"
show-limit
teleported
>
<div>
<el-dropdown-item> {{ $t('material.editGroup') }}</el-dropdown-item>
</div>
</popover-input>
<div @click="handleDeleteCate(data.id)">
<el-dropdown-item>{{ $t('material.delGroup') }}</el-dropdown-item>
</div>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-tree>
</div>
</el-scrollbar>
</div>
<div class="flex justify-center p-2 border-t border-br">
<popover-input @confirm="handleAddCate" size="default" width="400px" :limit="20" show-limit teleported>
<el-button> {{ $t('material.addGroup') }}</el-button>
</popover-input>
</div>
</div>
<div class="flex flex-col material__center">
<div class="flex operate-btn">
<div class="flex flex-1">
<el-button icon="folder-add" type="primary" class="ml10" v-auth="'sys_file_del'" @click="visibleUpload = true"
>{{ $t('material.uploadFileTip') }}
</el-button>
<el-button v-if="mode == 'page'" :disabled="!select.length" @click.stop="batchFileDelete()">
{{ $t('common.delBtn') }}
</el-button>
<popup v-if="mode == 'page'" class="ml-3" @confirm="batchFileMove" :disabled="!select.length"
:title="$t('material.moveBtn')">
<template #trigger>
<el-button :disabled="!select.length">{{ $t('material.moveBtn') }}</el-button>
</template>
<div>
<span class="mr-5">移动文件至</span>
<el-select v-model="moveId" placeholder="请选择">
<template v-for="item in cateLists" :key="item.id">
<el-option v-if="item.id !== ''" :label="item.name" :value="item.id"></el-option>
</template>
</el-select>
</div>
</popup>
</div>
<el-input class="mr-16 ml-80" :placeholder="$t('file.inputfileNameTip')" v-model="fileParams.original"
@keyup.enter="refresh">
<template #append>
<el-button @click="refresh">
<template #icon>
<el-icon>
<Search/>
</el-icon>
</template>
</el-button>
</template>
</el-input>
<div class="flex items-center ml-2">
<el-tooltip :content="$t('material.list')" placement="top">
<div
class="list-icon"
:class="{
select: listShowType == 'table',
}"
@click="listShowType = 'table'"
>
<el-icon>
<Expand/>
</el-icon>
</div>
</el-tooltip>
<el-tooltip :content="$t('material.grid')" placement="top">
<div
class="list-icon"
:class="{
select: listShowType == 'normal',
}"
@click="listShowType = 'normal'"
>
<el-icon>
<Menu/>
</el-icon>
</div>
</el-tooltip>
</div>
</div>
<div class="mt-3" v-if="mode == 'page'">
<el-checkbox :disabled="!pager.lists.length" v-model="isCheckAll" @change="selectAll"
:indeterminate="isIndeterminate">
{{ $t('material.allCheck') }}
</el-checkbox>
</div>
<div class="flex flex-col flex-1 min-h-0 mb-1 material-center__content">
<el-scrollbar v-if="pager.lists.length" v-show="listShowType == 'normal'">
<ul class="flex flex-wrap mt-4 file-list">
<li class="file-item-wrap" v-for="item in pager.lists" :key="item.id" :style="{ width: fileSize }">
<del-wrap @close="batchFileDelete([item.id])">
<file-item :uri="getFileUri(item)" :file-size="fileSize" :type="type" @click="selectFile(item)">
<div class="item-selected" v-if="isSelect(item.id)">
<el-icon class="el-input__icon">
<Check/>
</el-icon>
</div>
</file-item>
</del-wrap>
<div class="flex items-center justify-center mt-2">
{{ item.original }}
</div>
<div class="flex items-center justify-center operation-btns">
<popover-input
@confirm="handleFileRename($event, item.id)"
size="default"
:value="item.name"
width="400px"
:limit="50"
show-limit
teleported
>
<el-button type="primary" link> {{ $t('material.rename') }}</el-button>
</popover-input>
<el-button type="primary" link @click="handleDownFile(item)"> {{ $t('material.download') }}</el-button>
<el-button type="primary" link @click="handlePreview(item)"> {{ $t('material.view') }}</el-button>
</div>
</li>
</ul>
</el-scrollbar>
<el-table
ref="tableRef"
class="mt-4"
v-show="listShowType == 'table'"
:data="pager.lists"
width="100%"
height="100%"
size="large"
@row-click="selectFile"
>
<el-table-column width="55">
<template #default="{ row }">
<el-checkbox :modelValue="isSelect(row.id)" @change="selectFile(row)"/>
</template>
</el-table-column>
<el-table-column label="图片" width="100">
<template #default="{ row }">
<file-item :uri="getFileUri(row)" file-size="50px" :type="type"></file-item>
</template>
</el-table-column>
<el-table-column label="名称" min-width="100" show-overflow-tooltip>
<template #default="{ row }">
<el-link @click.stop="handlePreview(getFileUri(row))" :underline="false">
{{ row.original }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="createTime" label="上传时间" min-width="100"/>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<div class="inline-block">
<popover-input
@confirm="handleFileRename($event, row.id)"
size="default"
:value="row.name"
width="400px"
:limit="50"
show-limit
teleported
>
<el-button type="primary" link> 重命名</el-button>
</popover-input>
</div>
<div class="inline-block">
<el-button type="primary" link @click.stop="handlePreview(getFileUri(row))"> 查看</el-button>
</div>
<div class="inline-block">
<el-button type="primary" link @click.stop="batchFileDelete([row.id])"> 删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="flex items-center justify-center flex-1" v-if="!pager.lists.length">{{
$t('el.transfer.noData')
}}~
</div>
</div>
<div>
<pagination v-bind="pager" @change="getFileList" layout="total, prev, pager, next, jumper"/>
</div>
</div>
<div class="material__right" v-if="mode == 'picker'">
<div class="flex flex-wrap justify-between p-2">
<div class="flex items-center sm">
已选择 {{ select.length }}
<span v-if="limit">/{{ limit }}</span>
</div>
<el-button type="primary" link @click="clearSelect">清空</el-button>
</div>
<div class="flex-1 min-h-0">
<el-scrollbar class="ls-scrollbar">
<ul class="flex flex-col select-lists p-t-3">
<li class="mb-4" v-for="item in select" :key="item.id">
<div class="select-item">
<del-wrap @close="cancelSelete(item.id)">
<file-item :uri="item.uri" file-size="100px" :type="type"></file-item>
</del-wrap>
</div>
</li>
</ul>
</el-scrollbar>
</div>
</div>
<preview v-model="showPreview" :url="previewUrl" :type="type" :fileName="fileName"/>
</div>
<el-dialog :title="$t('material.uploadFileTip')" v-model="visibleUpload" :destroy-on-close="true" draggable>
<upload-file @change="refresh" v-if="props.type === 'image'" :data="{ groupId: cateId, type: typeValue }"
:fileType="['png', 'jpg', 'jpeg']"/>
<upload-file @change="refresh" v-if="props.type === 'video'" :data="{ groupId: cateId, type: typeValue }"
:fileType="['mp4']"/>
<upload-file
@change="refresh"
v-if="props.type === 'file'"
:data="{ cid: cateId, type: typeValue }"
:fileType="['doc', 'xls', 'ppt', 'txt', 'pdf', 'docx', 'xlsx', 'pptx']"
/>
</el-dialog>
</template>
<script lang="ts" setup>
const Popup = defineAsyncComponent(() => import('/@/components/Popup/index.vue'));
const PopoverInput = defineAsyncComponent(() => import('/@/components/PopoverInput/index.vue'));
import {useCate, useFile} from './hook';
import FileItem from './file.vue';
import Preview from './preview.vue';
import type {Ref} from 'vue';
import other from '/@/utils/other';
const {proxy} = getCurrentInstance();
const kkServerURL = import.meta.env.VITE_KK_SERVER_URL
const props = defineProps({
fileSize: {
type: String,
default: '100px',
},
limit: {
type: Number,
default: 1,
},
type: {
type: String,
default: 'image',
},
mode: {
type: String,
default: 'picker',
},
pageSize: {
type: Number,
default: 15,
},
});
const emit = defineEmits(['change']);
const {limit} = toRefs(props);
const typeValue = computed<number>(() => {
switch (props.type) {
case 'image':
return 10;
case 'video':
return 20;
case 'file':
return 30;
default:
return 0;
}
});
const visible: Ref<boolean> = ref(false);
const visibleUpload: Ref<boolean> = ref(false);
const previewUrl = ref('');
const fileName = ref('');
const showPreview = ref(false);
const {
treeRef,
cateId,
cateLists,
handleAddCate,
handleEditCate,
handleDeleteCate,
getCateLists,
handleCatSelect
} = useCate(typeValue.value);
const {
tableRef,
listShowType,
moveId,
pager,
fileParams,
select,
isCheckAll,
isIndeterminate,
getFileList,
refresh,
batchFileDelete,
batchFileMove,
selectFile,
isSelect,
clearSelect,
cancelSelete,
selectAll,
handleFileRename,
} = useFile(cateId, typeValue, limit, props.pageSize);
/**
* 获取数据
*/
const getData = async () => {
await getCateLists();
treeRef.value?.setCurrentKey(cateId.value);
getFileList();
};
/**
* 处理预览
*
* @param {string} item - 资源
*/
const handlePreview = (item: { fileName: string }) => {
previewUrl.value = getFileUri(item);
showPreview.value = true;
fileName.value = item.fileName;
};
/**
* 处理下载文件
*
* @param {any} item - 文件项对象
*/
const handleDownFile = (item: any) => {
other.downBlobFile(`/admin/sys-file/oss/file?fileName=${item.fileName}`, {}, item.original);
};
watch(
visible,
async (val: boolean) => {
if (val) {
getData();
}
},
{
immediate: true,
}
);
watch(cateId, () => {
fileParams.name = '';
refresh();
});
watch(
select,
(val: any[]) => {
emit('change', val);
if (val.length == pager.lists.length && val.length !== 0) {
isIndeterminate.value = false;
isCheckAll.value = true;
return;
}
if (val.length > 0) {
isIndeterminate.value = true;
} else {
isCheckAll.value = false;
isIndeterminate.value = false;
}
},
{
deep: true,
}
);
const getFileUri = (item: any) => {
return `${proxy.baseURL}/admin/sys-file/oss/file?fileName=${item.fileName}`;
};
onMounted(() => {
props.mode == 'page' && getData();
});
defineExpose({
clearSelect,
});
</script>
<style scoped lang="scss">
.material {
@apply h-full min-h-0 flex flex-1;
&__left {
@apply border-r border-br flex flex-col w-[200px];
:deep(.el-tree-node__content) {
height: 36px;
}
}
&__center {
flex: 1;
min-width: 0;
min-height: 0;
padding: 16px 16px 0;
.list-icon {
border-radius: 3px;
display: flex;
padding: 5px;
cursor: pointer;
&.select {
@apply text-primary bg-primary-light-8;
}
}
.file-list {
.file-item-wrap {
margin-right: 16px;
line-height: 1.3;
cursor: pointer;
.item-selected {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.5);
box-sizing: border-box;
}
.operation-btns {
height: 28px;
visibility: hidden;
}
&:hover .operation-btns {
visibility: visible;
}
}
}
}
&__right {
@apply border-l border-br flex flex-col;
width: 130px;
.select-lists {
padding: 10px;
.select-item {
width: 100px;
height: 100px;
}
}
}
}
</style>
+283
View File
@@ -0,0 +1,283 @@
<template>
<div class="material-select">
<popup ref="popupRef" width="830px" custom-class="body-padding" :title="`选择${tipsText}`" @confirm="handleConfirm" @close="handleClose">
<template v-if="!hiddenUpload" #trigger>
<div class="clearfix material-select__trigger" @click.stop>
<draggable class="draggable" v-model="fileList" animation="300" item-key="id">
<template v-slot:item="{ element, index }">
<div
class="material-preview"
:class="{
'is-disabled': disabled,
'is-one': limit == 1,
}"
@click="showPopup(index)"
>
<del-wrap @close="deleteImg(index)">
<file-item :uri="element" :file-size="size" :type="type"></file-item>
</del-wrap>
<div class="text-xs text-center operation-btns">
<span>{{ $t('material.edit') }}</span>
|
<span @click.stop="handlePreview(element)">{{ $t('material.view') }}</span>
</div>
</div>
</template>
</draggable>
<div
class="material-upload"
@click="showPopup(-1)"
v-show="showUpload"
:class="{
'is-disabled': disabled,
'is-one': limit == 1,
[uploadClass]: true,
}"
>
<slot name="upload">
<div
class="upload-btn"
:style="{
width: size,
height: size,
}"
>
<el-icon><Plus /></el-icon>
<span>{{ $t('material.add') }}</span>
</div>
</slot>
</div>
</div>
</template>
<el-scrollbar>
<div class="material-wrap">
<material ref="materialRef" :type="type" :file-size="fileSize" :limit="meterialLimit" @change="selectChange" />
</div>
</el-scrollbar>
</popup>
<preview v-model="showPreview" :url="previewUrl" :type="type" />
</div>
</template>
<script lang="ts">
import Draggable from 'vuedraggable';
import Popup from '/@/components/popup/index.vue';
import FileItem from './file.vue';
import Material from './index.vue';
import Preview from './preview.vue';
import { useThrottleFn } from '@vueuse/shared';
export default defineComponent({
components: {
Popup,
Draggable,
FileItem,
Material,
Preview,
},
props: {
modelValue: {
type: [String, Array],
default: () => [],
},
// 文件类型
type: {
type: String,
default: 'image',
},
// 选择器尺寸
size: {
type: String,
default: '100px',
},
// 文件尺寸
fileSize: {
type: String,
default: '100px',
},
// 选择数量限制
limit: {
type: Number,
default: 1,
},
// 禁用选择
disabled: {
type: Boolean,
default: false,
},
// 隐藏上传框*(目前在富文本中使用到)
hiddenUpload: {
type: Boolean,
default: false,
},
uploadClass: {
type: String,
default: '',
},
//选择的url排出域名
excludeDomain: {
type: Boolean,
default: false,
},
},
emits: ['change', 'update:modelValue'],
setup(props, { emit }) {
const popupRef = ref<InstanceType<typeof Popup>>();
const materialRef = ref<InstanceType<typeof Material>>();
const previewUrl = ref('');
const showPreview = ref(false);
const fileList = ref<any[]>([]);
const select = ref<any[]>([]);
const isAdd = ref(true);
const currentIndex = ref(-1);
const { disabled, limit, modelValue } = toRefs(props);
const tipsText = computed(() => {
switch (props.type) {
case 'image':
return '图片';
case 'video':
return '视频';
case 'file':
return '文件';
default:
return '';
}
});
const showUpload = computed(() => {
return props.limit - fileList.value.length > 0;
});
const meterialLimit: any = computed(() => {
if (!isAdd.value) {
return 1;
}
if (limit.value == -1) return null;
return limit.value - fileList.value.length;
});
const handleConfirm = useThrottleFn(
() => {
const selectUri = select.value.map((item) => (props.excludeDomain ? item.path : item.uri));
if (!isAdd.value) {
fileList.value.splice(currentIndex.value, 1, selectUri.shift());
} else {
fileList.value = [...fileList.value, ...selectUri];
}
handleChange();
},
1000,
false
);
const showPopup = (index: number) => {
if (disabled.value) return;
if (index >= 0) {
isAdd.value = false;
currentIndex.value = index;
} else {
isAdd.value = true;
}
popupRef.value?.open();
};
const selectChange = (val: any[]) => {
select.value = val;
};
const handleChange = () => {
const valueImg = limit.value != 1 ? fileList.value : fileList.value[0] || '';
emit('update:modelValue', valueImg);
emit('change', valueImg);
handleClose();
};
const deleteImg = (index: number) => {
fileList.value.splice(index, 1);
handleChange();
};
const handlePreview = (url: string) => {
previewUrl.value = url;
showPreview.value = true;
};
const handleClose = () => {
nextTick(() => {
if (props.hiddenUpload) fileList.value = [];
materialRef.value?.clearSelect();
});
};
watch(
modelValue,
(val: any[] | string) => {
fileList.value = Array.isArray(val) ? val : val == '' ? [] : [val];
},
{
immediate: true,
}
);
provide('limit', props.limit);
provide('hiddenUpload', props.hiddenUpload);
return {
popupRef,
materialRef,
fileList,
tipsText,
handleConfirm,
meterialLimit,
showUpload,
showPopup,
selectChange,
deleteImg,
previewUrl,
showPreview,
handlePreview,
handleClose,
};
},
});
</script>
<style scoped lang="scss">
.material-select {
.material-upload,
.material-preview {
position: relative;
border-radius: 4px;
cursor: pointer;
margin-right: 8px;
margin-bottom: 8px;
box-sizing: border-box;
float: left;
&.is-disabled {
cursor: not-allowed;
}
&.is-one {
margin-bottom: 0;
}
&:hover {
.operation-btns {
display: block;
}
}
.operation-btns {
display: none;
position: absolute;
bottom: 0;
border-radius: 4px;
width: 100%;
line-height: 2;
color: #fff;
background-color: rgba(0, 0, 0, 0.3);
}
}
.material-upload {
:deep(.upload-btn) {
@apply text-tx-secondary box-border rounded border-br border-dashed border flex flex-col justify-center items-center;
}
}
}
.material-wrap {
min-width: 720px;
height: 430px;
@apply border-t border-b border-br;
}
</style>
+93
View File
@@ -0,0 +1,93 @@
<template>
<div v-show="modelValue">
<div v-if="type == 'image'">
<el-image-viewer v-if="previewLists.length" :url-list="previewLists" hide-on-click-modal @close="handleClose"/>
</div>
<div v-if="type == 'video'">
<el-dialog v-model="visible" width="740px" :title="$t('material.preview')" :before-close="handleClose">
<video-player ref="playerRef" :src="url" width="100%" height="450px"/>
</el-dialog>
</div>
<div v-if="type == 'file'">
<el-drawer v-model="visible" size="100%">
<iframe
:src="src"
width="100%" height="100%" frameborder="0" class="h-screen" v-if="src"></iframe>
<span v-else>未配置预览服务器,请参考文档配置</span>
</el-drawer>
</div>
</div>
</template>
<script lang="ts" setup>
import {Base64} from 'js-base64';
import {validateNull} from "/@/utils/validate";
const VideoPlayer = defineAsyncComponent(() => import('/@/components/VideoPlayer/index.vue'));
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
url: {
type: String,
default: '',
},
fileName: {
type: String,
default: '',
},
type: {
type: String,
default: 'image',
},
});
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void;
}>();
const playerRef = shallowRef();
const visible = computed({
get() {
return props.modelValue;
},
set(value) {
emit('update:modelValue', value);
},
});
const handleClose = () => {
emit('update:modelValue', false);
};
const previewLists = ref<any[]>([]);
watch(
() => props.modelValue,
(value) => {
if (value) {
nextTick(() => {
previewLists.value = [props.url];
playerRef.value?.play();
});
} else {
nextTick(() => {
previewLists.value = [];
playerRef.value?.pause();
});
}
}
);
const kkServerURL = import.meta.env.VITE_KK_SERVER_URL
const localURL = import.meta.env.VITE_KK_LOCAL_URL
const src = computed(() => {
if (validateNull(kkServerURL)) {
return undefined;
}
return `${kkServerURL}?url=` + encodeURIComponent(Base64.encode(`${localURL}${props.url}&fullfilename=${props.fileName}`));
});
</script>
+76
View File
@@ -0,0 +1,76 @@
import { isFunction } from 'lodash';
import { reactive, toRaw } from 'vue';
// 分页钩子函数
interface Options {
page?: number;
size?: number;
fetchFun: (_arg: any) => Promise<any>;
params?: Record<any, any>;
firstLoading?: boolean;
beforeRequest?(params: Record<any, any>): Record<any, any>;
afterRequest?(res: Record<any, any>): void;
}
export function usePaging(options: Options) {
const { page = 1, size = 15, fetchFun, params = {}, firstLoading = false, beforeRequest, afterRequest } = options;
// 记录分页初始参数
const paramsInit: Record<any, any> = Object.assign({}, toRaw(params));
// 分页数据
const pager = reactive({
page,
size,
loading: firstLoading,
count: 0,
total: 0,
lists: [] as any[],
extend: {} as Record<any, any>,
});
// 请求分页接口
const getLists = () => {
pager.loading = true;
let requestParams = params;
if (isFunction(beforeRequest)) {
requestParams = beforeRequest(params);
}
return fetchFun({
current: pager.page,
size: pager.size,
...requestParams,
})
.then(({ data }) => {
pager.count = data?.total;
pager.total = data?.total;
pager.lists = data?.records;
pager.extend = data?.extend;
if (isFunction(afterRequest)) {
afterRequest(data);
}
return Promise.resolve(data);
})
.catch((err: any) => {
return Promise.reject(err);
})
.finally(() => {
pager.loading = false;
});
};
// 重置为第一页
const resetPage = () => {
pager.page = 1;
getLists();
};
// 重置参数
const resetParams = () => {
Object.keys(paramsInit).forEach((item) => {
params[item] = paramsInit[item];
});
getLists();
};
return {
pager,
getLists,
resetParams,
resetPage,
};
}
+129
View File
@@ -0,0 +1,129 @@
$d-type: (
flex: flex,
block: block,
none: none,
);
$flex-jc: (
start: flex-start,
end: flex-end,
center: center,
between: space-between,
around: space-around,
);
$flex-ai: (
start: flex-start,
end: flex-end,
center: center,
stretch: stretch,
);
//spacing
$spacing-types: (
m: margin,
p: padding,
);
$spacing-directions: (
t: top,
r: right,
b: bottom,
l: left,
);
$spacing-base-size: 5px;
$spacing-sizes: (
0: 0,
1: 1,
2: 2,
3: 3,
4: 4,
5: 5,
);
@each $key, $value in $d-type {
.d-#{$key} {
display: $value;
}
}
.flex-column {
flex-direction: column;
}
.text-ellipsis {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.w-100 {
width: 100%;
}
.h-100 {
height: 100%;
}
.flex-wrap {
flex-wrap: wrap;
}
.flex-grow-1 {
flex: 1;
}
@each $dir in(top, bottom, right, left) {
.border-#{$dir} {
border-#{$dir}: 1px solid;
}
}
@each $key, $value in $flex-jc {
.jc-#{$key} {
justify-content: $value;
}
}
@each $key, $value in $flex-ai {
.ai-#{$key} {
align-items: $value;
}
}
//text
@each $var in (left, center, right) {
.text-#{$var} {
text-align: $var !important;
}
}
@each $typeKey, $type in $spacing-types {
@each $sizeKey, $size in $spacing-sizes {
.#{$typeKey}-#{$sizeKey} {
#{$type}: $size * $spacing-base-size;
}
}
@each $sizeKey, $size in $spacing-sizes {
.#{$typeKey}x-#{$sizeKey} {
#{$type}-left: $size * $spacing-base-size;
#{$type}-right: $size * $spacing-base-size;
}
.#{$typeKey}y-#{$sizeKey} {
#{$type}-top: $size * $spacing-base-size;
#{$type}-bottom: $size * $spacing-base-size;
}
}
@each $directionKey, $direction in $spacing-directions {
@each $sizeKey, $size in $spacing-sizes {
.#{$typeKey}#{$directionKey}-#{$sizeKey} {
#{$type}-#{$direction}: $size * $spacing-base-size;
}
}
}
}
+105
View File
@@ -0,0 +1,105 @@
<template>
<div :class="mode == 'square' ? 'chatface' : 'brround avatar cover-image'" :style="transform" style="overflow: hidden; width: 40px; height: 40px">
<img v-if="faceUrl && !num" :src="faceUrl" class="w-100 h-100" />
<div v-else :style="styles" class="w-100 h-100 d-flex ai-center jc-center">{{ text }}</div>
</div>
</template>
<script>
export default {
name: 'nameAvatar',
props: {
scale: {
type: String,
default: '1',
},
num: [Number, String],
name: String,
mode: {
type: String,
default: '',
},
fontColor: {
type: String,
default: '#fff',
},
backgroundColor: {
type: String,
default: '#3696F2',
},
faceUrl: {
type: String,
default: '',
},
},
data() {
return {};
},
watch: {},
computed: {
// eslint-disable-next-line vue/return-in-computed-property
text() {
if (this.num !== undefined) {
return `+${this.num}`;
} else {
if (this.name) {
return this.name.slice(-2);
}
}
},
transform() {
let style = {};
if (this.scale) {
style['transform'] = `scale(${this.scale}, ${this.scale})`;
}
return style;
},
styles() {
let style = {};
if (this.size) {
style['font-size'] = '12px';
}
if (this.fontColor) {
style.color = this.fontColor;
}
if (this.backgroundColor) {
style['background'] = this.backgroundColor;
}
return style;
},
},
methods: {},
};
</script>
<style lang="scss" scoped>
@import './base.scss';
.avatar {
display: inline-block;
position: relative;
text-align: center;
vertical-align: bottom;
font-size: 8px;
user-select: none;
z-index: 10;
&:hover {
z-index: 100;
}
}
.brround {
border-radius: 50%;
}
.chatface {
display: block;
border-radius: 8px;
overflow: hidden;
img {
width: 100%;
height: 100%;
}
}
</style>
+191
View File
@@ -0,0 +1,191 @@
<template>
<div class="notice-bar" :style="{ background, height: `${height}px` }" v-show="!state.isMode">
<div class="notice-bar-warp" :style="{ color, fontSize: `${size}px` }">
<i v-if="leftIcon" class="notice-bar-warp-left-icon" :class="leftIcon"></i>
<div class="notice-bar-warp-text-box" ref="noticeBarWarpRef">
<div class="notice-bar-warp-text" ref="noticeBarTextRef" v-if="!scrollable">{{ text }}</div>
<div class="notice-bar-warp-slot" v-else><slot /></div>
</div>
<SvgIcon :name="rightIcon" v-if="rightIcon" class="notice-bar-warp-right-icon" @click="onRightIconClick" />
</div>
</div>
</template>
<script setup lang="ts" name="noticeBar">
import { reactive, ref, onMounted, nextTick } from 'vue';
// 定义父组件传过来的值
const props = defineProps({
// 通知栏模式,可选值为 closeable link
mode: {
type: String,
default: () => '',
},
// 通知文本内容
text: {
type: String,
default: () => '',
},
// 通知文本颜色
color: {
type: String,
default: () => 'var(--el-color-warning)',
},
// 通知背景色
background: {
type: String,
default: () => 'var(--el-color-warning-light-9)',
},
// 字体大小,单位px
size: {
type: [Number, String],
default: () => 14,
},
// 通知栏高度,单位px
height: {
type: Number,
default: () => 40,
},
// 动画延迟时间 (s)
delay: {
type: Number,
default: () => 1,
},
// 滚动速率 (px/s)
speed: {
type: Number,
default: () => 100,
},
// 是否开启垂直滚动
scrollable: {
type: Boolean,
default: () => false,
},
// 自定义左侧图标
leftIcon: {
type: String,
default: () => '',
},
// 自定义右侧图标
rightIcon: {
type: String,
default: () => '',
},
});
// 定义子组件向父组件传值/事件
const emit = defineEmits(['close', 'link']);
// 定义变量内容
const noticeBarWarpRef = ref();
const noticeBarTextRef = ref();
const state = reactive({
order: 1,
oneTime: 0,
twoTime: 0,
warpOWidth: 0,
textOWidth: 0,
isMode: false,
});
// 初始化 animation 各项参数
const initAnimation = () => {
nextTick(() => {
state.warpOWidth = noticeBarWarpRef.value.offsetWidth;
state.textOWidth = noticeBarTextRef.value.offsetWidth;
document.styleSheets[0].insertRule(`@keyframes oneAnimation {0% {left: 0px;} 100% {left: -${state.textOWidth}px;}}`);
document.styleSheets[0].insertRule(`@keyframes twoAnimation {0% {left: ${state.warpOWidth}px;} 100% {left: -${state.textOWidth}px;}}`);
computeAnimationTime();
setTimeout(() => {
changeAnimation();
}, props.delay * 1000);
});
};
// 计算 animation 滚动时长
const computeAnimationTime = () => {
state.oneTime = state.textOWidth / props.speed;
state.twoTime = (state.textOWidth + state.warpOWidth) / props.speed;
};
// 改变 animation 动画调用
const changeAnimation = () => {
if (state.order === 1) {
noticeBarTextRef.value.style.cssText = `animation: oneAnimation ${state.oneTime}s linear; opactity: 1;}`;
state.order = 2;
} else {
noticeBarTextRef.value.style.cssText = `animation: twoAnimation ${state.twoTime}s linear infinite; opacity: 1;`;
}
};
// 监听 animation 动画的结束
const listenerAnimationend = () => {
noticeBarTextRef.value.addEventListener(
'animationend',
() => {
changeAnimation();
},
false
);
};
// 右侧 icon 图标点击
const onRightIconClick = () => {
if (!props.mode) return false;
if (props.mode === 'closeable') {
state.isMode = true;
emit('close');
} else if (props.mode === 'link') {
emit('link');
}
};
// 页面加载时
onMounted(() => {
if (props.scrollable) return false;
initAnimation();
listenerAnimationend();
});
</script>
<style scoped lang="scss">
.notice-bar {
padding: 0 15px;
width: 100%;
border-radius: 4px;
.notice-bar-warp {
display: flex;
align-items: center;
width: 100%;
height: inherit;
.notice-bar-warp-text-box {
flex: 1;
height: inherit;
display: flex;
align-items: center;
overflow: hidden;
position: relative;
.notice-bar-warp-text {
white-space: nowrap;
position: absolute;
left: 0;
}
.notice-bar-warp-slot {
width: 100%;
white-space: nowrap;
:deep(.el-carousel__item) {
display: flex;
align-items: center;
}
}
}
.notice-bar-warp-left-icon {
width: 24px;
font-size: inherit !important;
}
.notice-bar-warp-right-icon {
width: 24px;
text-align: right;
font-size: inherit !important;
&:hover {
cursor: pointer;
}
}
}
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+42
View File
@@ -0,0 +1,42 @@
/*
* @Date: 2022-08-29 14:00:42
* @LastEditors: StavinLi 495727881@qq.com
* @LastEditTime: 2023-03-29 15:53:05
* @FilePath: /Workflow-Vue3/src/components/dialog/common.js
*/
import {deptRoleList} from '/@/api/admin/role';
import {orgTree, orgTreeSearcheUser} from '/@/api/admin/dept';
export const searchVal = ref('');
export const departments = ref({
titleDepartments: [], childDepartments: [], roleList: [], employees: [],
});
export const roles = ref({});
export const getRoleList = async () => {
let {
data: {list},
} = await deptRoleList();
roles.value = list;
};
export const getDepartmentList = async (parentId = 0, type = 'org') => {
// let { data } = await getDepartments({ parentId })
let {data} = await orgTree(type, parentId);
departments.value = data;
};
export const getDebounceData = async (event: any, type = 1) => {
if (event) {
let data = {
username: event, pageNum: 1, pageSize: 30,
};
if (type === 1) {
departments.value.childDepartments = [];
let res = await orgTreeSearcheUser(data);
departments.value.employees = res.data;
}
} else {
type === 1 ? await getDepartmentList() : await getRoleList();
}
};
+30
View File
@@ -0,0 +1,30 @@
.person_body {
border: 1px solid #f5f5f5;
height: 500px;
display: flex;
}
.tree_nav span {
display: inline-block;
padding-right: 10px;
margin-right: 5px;
max-width: 6em;
color: #38adff;
font-size: 12px;
cursor: pointer;
background: url(./assets/jiaojiao.png) no-repeat right center;
}
.tree_nav span:last-of-type {
background: none;
}
.person_tree {
padding: 10px 12px 0 8px;
width: 280px;
height: 100%;
border-right: 1px solid #f5f5f5;
}
.l {
float: left;
}
@@ -0,0 +1,179 @@
<template>
<el-dialog :title="$t('orgSelecotr.select') + $t(`orgSelecotr.${props.type}`)" v-model="visibleDialog" :width="600" append-to-body class="promoter_person">
<div class="person_body clear">
<div class="person_tree l">
<selectBox ref="selectBoxRef" :selectSelf="selectSelf" :list="list" :multiple="multiple"
v-model:selectedList="selectedList" :type="type"/>
</div>
<selectResult :total="total" @del="delList" :list="resList"/>
</div>
<template #footer>
<el-button @click="$emit('update:visible', false)">{{ $t('common.cancelButtonText') }}</el-button>
<el-button type="primary" @click="saveDialog">{{ $t('common.confirmButtonText') }}</el-button>
</template>
</el-dialog>
</template>
<script setup>
import selectBox from './selectBox.vue';
import selectResult from './selectResult.vue';
import {computed, watch, ref, onMounted} from 'vue';
import {departments, searchVal} from './common';
import other from '/@/utils/other';
import {useI18n} from "vue-i18n";
const selectBoxRef = ref();
let props = defineProps({
visible: {
type: Boolean,
default: false,
},
data: {
type: Array,
default: () => [],
},
type: {
type: String,
default: 'user',
},
multiple: {
type: Boolean,
default: true,
},
selectSelf: {
type: Boolean,
default: true,
},
});
//已选择的集合
let selectedList = ref([]);
let emits = defineEmits(['update:visible', 'change']);
let visibleDialog = computed({
get() {
return props.visible;
},
set() {
closeDialog();
},
});
const isChecked = (id, type) => {
return selectedList.value.filter((res) => res.id === id && res.type === type).length > 0;
};
let list = computed(() => {
let value = departments.value;
return [
{
type: 'dept',
data: value === undefined ? [] : value.childDepartments,
},
{
type: 'role',
data: value === undefined ? [] : value.roleList,
},
{
type: 'user',
data: value === undefined ? [] : value.employees,
change: (item) => {
if (!isChecked(item.id, item.type)) {
if (!props.multiple) {
//单选
selectedList.value = [];
}
selectedList.value.push(item);
} else {
selectedList.value = selectedList.value.filter((res) => !(res.id === item.id && res.type === item.type));
}
},
},
];
});
let resList = computed(() => {
let userData = selectedList.value.filter((res) => res.type === 'user');
let deptData = selectedList.value.filter((res) => res.type === 'dept');
let roleData = selectedList.value.filter((res) => res.type === 'role');
let data = [
{
type: 'user',
data: userData,
cancel: (item) => {
item.selected = false;
selectBoxRef.value.changeEvent(item);
},
},
];
if (props.type === 'org' || props.type === 'dept') {
data.unshift({
type: 'dept',
data: deptData,
cancel: (item) => {
item.selected = false;
selectBoxRef.value.changeEvent(item);
},
});
}
if (props.type === 'role') {
data.unshift({
type: 'role',
data: roleData,
cancel: (item) => {
item.selected = false;
selectBoxRef.value.changeEvent(item);
},
});
}
return data;
});
watch(
() => props.visible,
(val) => {
if (val) {
selectedList.value = props.data;
searchVal.value = '';
}
}
);
const closeDialog = () => {
emits('update:visible', false);
};
let total = computed(() => {
let v = departments.value;
if (!v) {
return 0;
}
return selectedList.value.length;
});
const {proxy} = getCurrentInstance();
let saveDialog = () => {
const v = selectedList.value;
let checkedList = other.deepClone(v).map((item) => ({
type: item.type,
id: item.id,
name: item.name,
avatar: item.avatar,
}));
emits('change', checkedList);
//selectedList.value=[]
};
const delList = () => {
for (const item of other.deepClone(selectedList.value)) {
item.selected = false;
selectBoxRef.value.changeEvent(item);
}
selectedList.value = [];
};
</script>
<style scoped>
@import './dialog.css';
</style>
+10
View File
@@ -0,0 +1,10 @@
export default {
orgSelecotr: {
org: 'org',
user: 'user',
dept: 'dept',
role: 'role',
select: 'select',
search: 'search'
},
};
+10
View File
@@ -0,0 +1,10 @@
export default {
orgSelecotr: {
org: '组织',
user: '用户',
dept: '部门',
role: '角色',
select: '选择',
search: '搜索'
},
};
+62
View File
@@ -0,0 +1,62 @@
<template>
<div>
<employees-dialog
v-model:visible="selectUserDialogVisible"
:data="defaultValue"
:type="type"
:multiple="multiple"
:selectSelf="selectSelf"
@change="afterSelectUser"
/>
</div>
<el-button :disabled="disabled" icon="Plus" circle size="large" @click="selectUserDialogVisible = true"> </el-button>
<div style="width: 100%; margin-top: 10px; text-align: left">
<org-item v-model:data="defaultValue" :disabled="disabled" />
</div>
</template>
<script setup lang="ts">
import employeesDialog from './employeesDialog.vue';
import orgItem from './orgItem.vue';
let selectUserDialogVisible = ref(false);
const afterSelectUser = (data) => {
//选择人员变化
selectUserDialogVisible.value = false;
emits('update:orgList', data);
};
var defaultValue = computed({
get: () => {
return props.orgList || [];
},
set: (r) => {
emits('update:orgList', r);
},
});
let emits = defineEmits(['update:orgList']);
let props = defineProps({
orgList: {
type: Array,
default: () => [],
},
type: {
type: String,
default: 'user',
},
multiple: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
selectSelf: {
type: Boolean,
default: true,
},
});
</script>
+37
View File
@@ -0,0 +1,37 @@
<template>
<div>
<el-tag
v-for="(item, index) in data"
style="margin-right: 5px; margin-top: 5px"
:key="item.id"
:closable="!disabled"
@close="removeItem(index, item.id, item.type)"
:type="item.type === 'dept' ? 'primary' : item.type === 'user' ? 'warning' : 'success'"
size="large"
>
{{ item.name }}
</el-tag>
</div>
</template>
<script setup>
let emits = defineEmits(['update:data']);
let props = defineProps({
data: {
type: Array,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
});
const removeItem = (index, id, type) => {
emits(
'update:data',
props.data.filter((res) => !(res.id === id && res.type === type))
);
};
</script>
+120
View File
@@ -0,0 +1,120 @@
<!--
* @Date: 2022-08-25 14:05:59
* * @LastEditors: StavinLi 495727881@qq.com
* @LastEditTime: 2023-03-15 14:59:19
* @FilePath: /Workflow-Vue3/src/components/dialog/roleDialog.vue
-->
<template>
<el-dialog title="选择角色" v-model="visibleDialog" :width="600" append-to-body class="promoter_person">
<div class="person_body clear">
<div class="person_tree l">
<input type="text" placeholder="搜索角色" v-model="searchVal" @input="getDebounceData($event, 2)"/>
<selectBox :list="list"/>
</div>
<selectResult :total="total" @del="delList" :list="resList"/>
</div>
<template #footer>
<el-button @click="closeDialog">取 消</el-button>
<el-button type="primary" @click="saveDialog">确 定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import selectBox from './selectBox.vue';
import selectResult from './selectResult.vue';
import {computed, watch, ref} from 'vue';
import {roles, getDebounceData, getRoleList, searchVal} from './common';
let props = defineProps({
visible: {
type: Boolean,
default: false,
},
data: {
type: Array,
default: () => [],
},
});
let checkedRoleList = ref([]);
let emits = defineEmits(['update:visible', 'change']);
let list = computed(() => {
return [
{
type: 'role',
not: true,
data: roles.value,
isActive: (item) => toggleClass(checkedRoleList.value, item, 'roleId'),
change: (item) => {
checkedRoleList.value = [item];
},
},
];
});
let resList = computed(() => {
return [
{
type: 'role',
data: checkedRoleList.value,
cancel: (item) => removeEle(checkedRoleList.value, item, 'roleId'),
},
];
});
let visibleDialog = computed({
get() {
return props.visible;
},
set(val) {
closeDialog();
},
});
watch(
() => props.visible,
(val) => {
if (val) {
getRoleList();
searchVal.value = '';
checkedRoleList.value = props.data.map(({name, targetId}) => ({
roleName: name,
roleId: targetId,
}));
}
}
);
let total = computed(() => checkedRoleList.value.length);
const saveDialog = () => {
let checkedList = checkedRoleList.value.map((item) => ({
type: 2,
targetId: item.roleId,
name: item.roleName,
}));
emits('change', checkedList);
};
const delList = () => {
checkedRoleList.value = [];
};
const closeDialog = () => {
emits('update:visible', false);
};
const toggleClass = (arr, elem, key = 'id') => {
return arr.some((item) => {
return item[key] === elem[key];
});
}
const removeEle = (arr, elem, key = 'id') => {
let includesIndex;
arr.map((item, index) => {
if (item[key] === elem[key]) {
includesIndex = index;
}
});
arr.splice(includesIndex, 1);
}
</script>
<style>
@import './dialog.css';
</style>
+291
View File
@@ -0,0 +1,291 @@
<template>
<div>
<el-input
v-model="searchVal"
class="w-50 m-2"
style="width: 100%"
v-if="type === 'user'"
:placeholder="$t('orgSelecotr.search')"
@input="getDebounceData($event)"
:prefix-icon="Search"
/>
<p class="ellipsis tree_nav" v-if="!searchVal && type !== 'role'">
<span @click="queryData(0)" class="ellipsis">根节点</span>
<span v-for="(item, index) in departments.titleDepartments" class="ellipsis" :key="index + 'a'" @click="queryData(item.id)">{{
item.name
}}</span>
</p>
<ul class="select-box">
<template v-for="(elem, i) in dataList" :key="i">
<template v-if="elem.type === 'role'">
<li v-for="item in elem.data" :key="item.id">
<el-checkbox v-model="item.selected" @change="changeEvent(item)" :disabled="item.status === 0">
<div style="display: flex; flex-direction: row">
<div class="f11">
<el-icon style="font-size: 20px">
<Share />
</el-icon>
</div>
<div class="f12">{{ item.name }}</div>
</div>
</el-checkbox>
</li>
</template>
<template v-if="elem.type === 'dept' && (type === 'org' || type === 'dept' || type === 'user')">
<li v-for="item in elem.data" :key="item.id">
<div style="display: flex; flex-direction: row">
<div class="d11">
<el-checkbox v-model="item.selected" @change="changeEvent(item)" :disabled="!(type === 'org' || type === 'dept') || item.status == 0">
<div style="display: flex; flex-direction: row">
<div class="f11">
<el-icon style="font-size: 20px">
<Grid />
</el-icon>
</div>
<div class="f12">{{ item.name }}</div>
</div>
</el-checkbox>
</div>
<div class="d22" @click="queryData(item.id)">下级</div>
</div>
</li>
</template>
<template v-if="elem.type === 'user' && (type === 'org' || type === 'user')">
<li v-for="item in elem.data" :key="item.id" class="check_box">
<el-checkbox
v-model="item.selected"
:disabled="item.status === 0 || (!selectSelf && currentUserId === item.id)"
@change="changeEvent(item)"
>
<div style="display: flex; flex-direction: row">
<div class="f11">
<upload-img v-model:image-url="item.avatar" disabled width="20px" height="20px"/>
</div>
<div class="f12">{{ item.name }}</div>
</div>
</el-checkbox>
</li>
</template>
</template>
</ul>
</div>
</template>
<script setup>
import {useUserInfo} from '/@/stores/userInfo';
import {departments, getDebounceData, getDepartmentList, searchVal} from './common';
import other from '/@/utils/other';
import UploadImg from "/@/components/Upload/Image.vue";
import {Grid, Search, Share} from '@element-plus/icons-vue';
var props = defineProps({
selectedList: {
type: Array,
default: () => [],
},
type: {
type: String,
default: 'org',
},
multiple: {
type: Boolean,
default: true,
},
selectSelf: {
type: Boolean,
default: true,
},
});
const currentUserId = computed(() => {
return useUserInfo().userInfos.user.userId;
});
const queryData = (pid) => {
getDepartmentList(pid, props.type).then((res) => {
let selectedList = props.selectedList;
for (const it of dataList.value) {
for (const item of it.data) {
item.selected = selectedList.filter((res) => res.id === item.id && res.type === item.type).length > 0;
}
}
});
};
let deptList = computed(() => {
return departments.value.childDepartments;
});
let userList = computed(() => {
return departments.value.employees;
});
let roleList = computed(() => {
return departments.value.roleList;
});
const dataList = computed(() => {
return [
{
type: 'dept',
data: deptList.value,
},
{
type: 'user',
data: userList.value,
},
{
type: 'role',
data: roleList.value,
}
];
});
const { proxy } = getCurrentInstance();
let emits = defineEmits(['update:selectedList']);
onMounted(() => {
queryData(0);
});
const changeEvent = (e) => {
let selectedList = other.deepClone(props.selectedList);
if (e.selected) {
if (!props.multiple) {
userList.value.forEach((res) => (res.selected = false));
selectedList = [];
}
e.selected = true;
selectedList.push(e);
} else {
for (const it of dataList.value) {
let filter = it.data.filter((res) => res.id === e.id && res.type === e.type);
if (filter.length > 0) {
filter[0].selected = false;
}
}
selectedList = selectedList.filter((res) => !(res.id === e.id && res.type === e.type));
}
emits('update:selectedList', selectedList);
};
const refreshData = () => {
let selectedList = props.selectedList;
for (var it of dataList.value) {
for (var item of it.data) {
var b = selectedList.filter((res) => res.id === item.id && res.type === item.type).length > 0;
item.selected = b;
}
}
};
defineExpose({ queryData, changeEvent, refreshData });
watch(
() => props.selectedList,
(val) => {
refreshData();
}
);
</script>
<style scoped lang="scss">
@import './dialog.css';
.select-box {
height: 420px;
overflow-y: auto;
li {
padding: 5px 0;
}
}
.radio_box a,
.check_box a {
font-size: 12px;
position: relative;
padding-left: 20px;
margin-right: 30px;
cursor: pointer;
color: #333;
white-space: pre;
}
.check_box.not a:hover {
color: #333;
}
.check_box.not a::before,
.check_box.not a:hover::before {
border: none;
}
.check_box.not.active {
background: #f3f3f3;
}
.radio_box a:hover::before,
.check_box a:hover::before {
border: 1px solid #46a6fe;
}
.radio_box a::before,
.check_box a::before {
position: absolute;
width: 14px;
height: 14px;
border: 1px solid #dcdfe6;
border-radius: 2px;
left: 0;
top: 1px;
content: '';
}
.radio_box a::before {
border-radius: 50%;
}
.check-dot.active::after,
.radio_box a.active::after,
.check_box a.active::after {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
top: 3px;
left: 3px;
content: '';
}
.radio_box a.active::after {
background: #46a6fe;
}
.check_box a.active::after {
background: url(./assets/check_box.png) no-repeat center;
}
.f11 {
width: 30px;
}
.f12 {
width: calc(100% - 30px);
height: 20px;
line-height: 20px;
}
.d11 {
width: calc(100% - 30px);
}
.d22 {
width: 30px;
line-height: 41px;
cursor: pointer;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<!--
* @Date: 2022-08-26 16:29:24
* @LastEditors: StavinLi 495727881@qq.com
* @LastEditTime: 2022-09-21 14:36:30
* @FilePath: /Workflow-Vue3/src/components/selectResult.vue
-->
<template>
<div class="select-result l">
<p class="clear">
已选({{ total }})
<a @click="emits('del')">清空</a>
</p>
<ul>
<template v-for="{ type, data, cancel } in list" :key="type">
<template v-if="type === 'role'">
<li v-for="item in data" :key="item.id">
<div style="display: flex; flex-direction: row">
<div class="f11">
<el-icon style="font-size: 20px">
<Share />
</el-icon>
</div>
<div class="f12">{{ item.name }}</div>
<div class="f13">
<el-button size="small" text @click="cancel(item)" :icon="CircleClose"></el-button>
</div>
</div>
</li>
</template>
<template v-if="type === 'dept'">
<li v-for="item in data" :key="item.id">
<div style="display: flex; flex-direction: row">
<div class="f11">
<el-icon style="font-size: 20px">
<Grid />
</el-icon>
</div>
<div class="f12">{{ item.name }}</div>
<div class="f13">
<el-button size="small" text @click="cancel(item)" :icon="CircleClose"></el-button>
</div>
</div>
</li>
</template>
<template v-if="type === 'user'">
<li v-for="item in data" :key="item.id">
<div style="display: flex; flex-direction: row">
<div class="f11">
<upload-img v-model:image-url="item.avatar" disabled width="20px" height="20px" />
</div>
<div class="f12">{{ item.name }}</div>
<div class="f13">
<el-button size="small" text @click="cancel(item)" :icon="CircleClose"></el-button>
</div>
</div>
</li>
</template>
</template>
</ul>
</div>
</template>
<script setup>
import { CircleClose, Grid, Share } from '@element-plus/icons-vue';
import UploadImg from '/@/components/Upload/Image.vue';
defineProps({
total: {
type: Number,
default: 0,
},
list: {
type: Array,
default: () => [{ type: 'role', data, cancel }],
},
});
let emits = defineEmits(['del']);
</script>
<style scoped lang="scss">
.select-result {
width: 276px;
height: 100%;
font-size: 12px;
ul {
height: 460px;
overflow-y: auto;
li {
margin: 11px 26px 13px 19px;
line-height: 17px;
span {
vertical-align: middle;
}
img:first-of-type {
width: 14px;
vertical-align: middle;
margin-right: 5px;
}
img:last-of-type {
float: right;
margin-top: 2px;
width: 14px;
}
}
}
p {
padding-left: 19px;
padding-right: 20px;
line-height: 37px;
border-bottom: 1px solid #f2f2f2;
a {
float: right;
}
}
}
.f11 {
width: 30px;
}
.f12 {
width: calc(100% - 60px);
height: 25px;
line-height: 25px;
}
.f13 {
width: 30px;
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<template>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
class="mt15"
:pager-count="5"
:page-sizes="props.pageSizes"
:current-page="props.current"
background
:page-size="props.size"
:layout="props.layout"
:total="props.total"
>
</el-pagination>
</template>
<script setup lang="ts" name="pagination">
const emit = defineEmits(['sizeChange', 'currentChange']);
const props = defineProps({
current: {
type: Number,
default: 1,
},
size: {
type: Number,
default: 10,
},
total: {
type: Number,
default: 0,
},
pageSizes: {
type: Array as () => number[],
default: () => {
return [1, 10, 20, 50, 100, 200];
},
},
layout: {
type: String,
default: 'total, sizes, prev, pager, next, jumper',
},
});
// 分页改变
const sizeChangeHandle = (val: number) => {
emit('sizeChange', val);
};
// 分页改变
const currentChangeHandle = (val: number) => {
emit('currentChange', val);
};
</script>
+128
View File
@@ -0,0 +1,128 @@
<template>
<div @mouseenter="inPopover = true" @mouseleave="inPopover = false">
<el-popover
placement="top"
v-model:visible="visible"
:width="width"
trigger="contextmenu"
class="popover-input"
:teleported="teleported"
:persistent="false"
popper-class="!p-0"
>
<div class="flex p-3" @click.stop="">
<div class="popover-input__input mr-[10px] flex-1">
<el-select class="flex-1" :size="size" v-if="type == 'select'" v-model="inputValue" :teleported="teleported">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value"></el-option>
</el-select>
<el-input
v-else
v-model.trim="inputValue"
:maxlength="maxlength"
:show-word-limit="showLimit"
:type="type"
:size="size"
clearable
:placeholder="placeholder"
/>
</div>
<div class="flex-none popover-input__btns">
<el-button link @click="close">{{ $t('common.cancelButtonText') }}</el-button>
<el-button type="primary" :size="size" @click="handleConfirm">{{ $t('common.confirmButtonText') }}</el-button>
</div>
</div>
<template #reference>
<div class="inline" @click.stop="handleOpen">
<slot></slot>
</div>
</template>
</el-popover>
</div>
</template>
<script lang="ts" setup>
import { useEventListener } from '@vueuse/core';
import type { PropType } from 'vue';
const props = defineProps({
modelValue: {
type: String,
},
type: {
type: String,
default: 'text',
},
width: {
type: [Number, String],
default: '300px',
},
placeholder: String,
disabled: {
type: Boolean,
default: false,
},
options: {
type: Array as PropType<any[]>,
default: () => [],
},
size: {
type: String as PropType<'default' | 'small' | 'large'>,
default: 'default',
},
limit: {
type: Number,
default: 200,
},
maxlength: {
type: Number,
default: 20,
},
showLimit: {
type: Boolean,
default: false,
},
teleported: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['confirm', 'update:modelValue']);
const visible = ref(false);
const inPopover = ref(false);
const inputValue = ref();
const handleConfirm = () => {
close();
emit('confirm', inputValue.value);
emit('update:modelValue', inputValue.value);
};
const handleOpen = () => {
if (props.disabled) {
return;
}
visible.value = true;
inputValue.value = '';
};
const close = () => {
visible.value = false;
};
watch(
() => inputValue.value,
(value) => {
emit('update:modelValue', value);
},
{
immediate: true,
}
);
useEventListener(document.documentElement, 'click', () => {
if (inPopover.value) return;
close();
});
</script>
<style scoped lang="scss"></style>
+118
View File
@@ -0,0 +1,118 @@
<template>
<div class="dialog">
<div class="dialog__trigger" @click="open">
<!-- 触发弹窗 -->
<slot name="trigger"></slot>
</div>
<el-dialog
v-model="visible"
:custom-class="customClass"
:center="center"
:append-to-body="true"
:width="width"
:close-on-click-modal="clickModalClose"
@closed="close"
>
<!-- 弹窗内容 -->
<template v-if="title" #header>{{ title }}</template>
<!-- 自定义内容 -->
<slot>{{ content }}</slot>
<!-- 底部弹窗页脚 -->
<template #footer>
<div class="dialog-footer">
<el-button @click="handleEvent('cancel')">
{{ $t('common.cancelButtonText') }}
</el-button>
<el-button type="primary" @click="handleEvent('confirm')">
{{ $t('common.confirmButtonText') }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default defineComponent({
props: {
title: {
// 弹窗标题
type: String,
default: '',
},
content: {
// 弹窗内容
type: String,
default: '',
},
width: {
// 弹窗的宽度
type: String,
default: '400px',
},
disabled: {
// 是否禁用
type: Boolean,
default: false,
},
async: {
// 是否开启异步关闭
type: Boolean,
default: false,
},
clickModalClose: {
// 点击遮罩层关闭对话窗口
type: Boolean,
default: false,
},
center: {
// 是否居中布局
type: Boolean,
default: false,
},
customClass: {
type: String,
default: '',
},
},
emits: ['confirm', 'cancel', 'close', 'open'],
setup(props, { emit }) {
const visible = ref(false);
const handleEvent = (type: 'confirm' | 'cancel') => {
emit(type);
if (!props.async || type === 'cancel') {
close();
}
};
const close = () => {
visible.value = false;
nextTick(() => {
emit('close');
});
};
const open = () => {
if (props.disabled) {
return;
}
emit('open');
visible.value = true;
};
provide('visible', visible);
return {
visible,
handleEvent,
close,
open,
};
},
});
</script>
<style scoped lang="scss">
.dialog-body {
white-space: pre-line;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
export default {
queryTree: {
hideSearch: 'hideSearch',
displayTheSearch: 'displayTheSearch',
refresh: 'refresh',
print: 'print',
view: 'view'
},
};
+9
View File
@@ -0,0 +1,9 @@
export default {
queryTree: {
hideSearch: '隐藏搜索',
displayTheSearch: '显示搜索',
refresh: '刷新',
print: '打印',
view: '视图'
},
};
+188
View File
@@ -0,0 +1,188 @@
<template>
<div class="head-container">
<div class="head-container-header">
<div class="head-container-header-input">
<el-input v-model="searchName" suffix-icon="search" :placeholder="placeholder" clearable @change="getdeptTree" />
</div>
<div class="head-container-header-dropdown" v-if="showExpand">
<el-dropdown :hide-on-click="false">
<el-icon style="transform: rotate(90deg)">
<MoreFilled />
</el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>
<el-button
:class="buttonClass"
link
type="primary"
:icon="isExpand ? 'expand' : 'fold'"
@click="toggleRowExpansionAll(isExpand ? false : true)"
>
{{ isExpand ? '折叠' : '展开' }}
</el-button>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<el-tree
class="mt20"
:data="state.List"
:props="props.props"
:expand-on-click-node="false"
ref="deptTreeRef"
:loading="state.localLoading"
node-key="id"
highlight-current
default-expand-all
@node-click="handleNodeClick"
>
<template #default="{ node, data }" v-if="$slots.default">
<slot :node="node" :data="data"></slot>
</template>
</el-tree>
</div>
</template>
<script setup lang="ts" name="query-tree">
import { useMessage } from '/@/hooks/message';
const emit = defineEmits(['search', 'nodeClick']);
const props = defineProps({
/**
* 树结构属性配置。
*
* @default { label: 'name', children: 'children', value: 'id' }
*/
props: {
type: Object,
default: () => {
return {
label: 'name',
children: 'children',
value: 'id',
};
},
},
/**
* 输入框占位符。
*
* @default ''
*/
placeholder: {
type: String,
default: '',
},
/**
* 是否显示加载中状态。
*
* @default false
*/
loading: {
type: Boolean,
default: false,
},
/**
* 查询函数,必须返回 Promise 类型数据。
*/
query: {
type: Function,
required: true,
},
/**
* 是否显示折叠控制
*/
showExpand: {
type: Boolean,
default: false,
},
});
const state = reactive({
List: [], // 树形结构列表数据
localLoading: props.loading, // 是否加载中
});
const deptTreeRef = ref(); // 部门树形结构组件实例引用
const searchName = ref(); // 查询关键字
const isExpand = ref(true); // 是否展开所有节点
const buttonClass = computed(() => {
return ['!h-[20px]', 'reset-margin', '!text-gray-500', 'dark:!text-white', 'dark:hover:!text-primary'];
});
/**
* 点击树形结构节点触发的事件。
*
* @param item 被点击的节点数据。
*/
const handleNodeClick = (item: any) => {
emit('nodeClick', item);
};
/**
* 获取部门树形结构数据。
*/
const getdeptTree = () => {
if (props.query instanceof Function) {
state.localLoading = true;
// 调用传入的查询函数,并将查询关键字作为参数传入
const result = props.query(unref(searchName));
// 如果查询结果为 Promise 类型,则进行后续处理
if ((typeof result === 'object' || typeof result === 'function') && typeof result.then === 'function') {
result
.then((r: any) => {
state.List = r.data;
})
.catch((err) => {
useMessage().error(err.msg);
});
}
}
};
/**
* 切换所有节点的展开/收起状态。
*
* @param status 目标状态,true 为展开,false 为收起。
*/
const toggleRowExpansionAll = (status) => {
isExpand.value = status;
const nodes = deptTreeRef.value.store._getAllNodes();
for (let i = 0; i < nodes.length; i++) {
nodes[i].expanded = status;
}
};
onMounted(() => {
getdeptTree();
});
// 方便父组件调用刷新树方法
defineExpose({
getdeptTree,
});
</script>
<style lang="scss" scoped>
.head-container {
&-header {
display: flex;
align-items: center;
&-input {
width: 90%;
}
&-dropdown {
flex: 1;
margin-left: 5%;
}
}
}
</style>
+119
View File
@@ -0,0 +1,119 @@
<template>
<div class="top-right-btn" :style="style">
<el-row>
<!-- 搜索框控制 -->
<el-tooltip
class="item"
effect="dark"
:content="showSearch ? $t('queryTree.hideSearch') : $t('queryTree.displayTheSearch')"
placement="top"
v-if="search"
>
<el-button circle icon="Search" @click="toggleSearch()" />
</el-tooltip>
<!-- 导出 -->
<el-tooltip class="item" effect="dark" :content="$t('common.exportBtn')" placement="top" v-if="isExport()">
<el-button circle icon="Download" @click="handleExport()" />
</el-tooltip>
<!-- 刷新功能 -->
<el-tooltip class="item" effect="dark" :content="$t('queryTree.refresh')" placement="top">
<el-button circle icon="Refresh" @click="handleRefresh()" />
</el-tooltip>
<!-- 插槽 -->
<slot></slot>
</el-row>
</div>
</template>
<script setup name="right-toolbar">
import { auth } from '/@/utils/authFunction';
/**
* 通过 defineProps 函数定义组件 props
*/
const props = defineProps({
/**
* 是否显示搜索框
*/
showSearch: {
type: Boolean,
default: true,
},
/**
* 是否导出
*/
export: {
type: [String, Boolean],
default: null,
},
/**
* 是否显示搜索框
*/
search: {
type: Boolean,
default: true,
},
/**
* 列表项之间的间距
*/
gutter: {
type: Number,
default: 10,
},
});
const emits = defineEmits(['update:showSearch', 'queryTable', 'exportExcel']);
const style = computed(() => {
const ret = {};
// 如果props中有传入gutter属性,则计算出marginRight
if (props.gutter) {
ret.marginRight = `${props.gutter / 2}px`;
}
return ret; // 返回计算后的样式对象
});
// 搜索
const toggleSearch = () => {
emits('update:showSearch', !props.showSearch);
};
// 刷新
const handleRefresh = () => {
emits('queryTable');
};
// 导出excel
const handleExport = () => {
emits('exportExcel');
};
// 是否导出
const isExport = () => {
if (props.export === true) {
return true;
}
// 字符串鉴权
return props.export && auth(props.export);
};
</script>
<style lang="scss" scoped>
:deep(.el-transfer__button) {
border-radius: 50%;
display: block;
margin-left: 0px;
}
:deep(.el-transfer__button:first-child) {
margin-bottom: 10px;
}
.my-el-transfer {
text-align: center;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<template>
<div></div>
</template>
<script setup lang="ts" name="global-sse">
import { Session } from '/@/utils/storage';
import { useEventSource } from '@vueuse/core';
import { ElNotification } from 'element-plus';
const props = defineProps({
uri: {
type: String,
},
});
/**
* 从会话存储中获取访问令牌
* @returns {string} 访问令牌
*/
const token = computed(() => {
return Session.getToken();
});
/**
* 从会话存储中获取访问租户
* @returns {string} 租户
*/
const tenant = computed(() => {
return Session.getTenant();
});
/**
* SSE 消息提醒
* @param {string} message - 收到的消息内容
*/
const showSseNotification = (message) => {
ElNotification.warning({
title: '消息提醒',
dangerouslyUseHTMLString: true,
message: message + '请及时处理',
offset: 0,
});
};
/**
* 初始化 SSE 连接
* @param {string} uri - SSE 服务端 URI
* @param {string} token - 访问令牌
* @param {string} tenant - 租户信息
*/
const initSseConnection = (uri, token, tenant) => {
// baseURL
const baseURL = import.meta.env.VITE_API_URL;
const { eventSource, error } = useEventSource(`${baseURL}${uri}?access_token=${token}&TENANT-ID=${tenant}`);
if (!error.value) {
eventSource.value?.addEventListener('message', (message) => {
if (eventSource.value?.OPEN && message.data !== 'pong') {
showSseNotification(message.data);
}
});
}
};
// 是否开启sseEnable
const sseEnable = ref(import.meta.env.VITE_SSE_ENABLE === 'true');
if (sseEnable.value) {
initSseConnection(props.uri, token.value, tenant.value);
}
</script>
+90
View File
@@ -0,0 +1,90 @@
<template>
<div class="container">
<el-tag :color="randomColor()" class="container-tag">
<SvgIcon :name="props.icon" :size="25" color="#ffffff" />
</el-tag>
<span class="container-span">{{ $t(props.label) }}</span>
</div>
</template>
<script setup name="shortcut">
const props = defineProps({
icon: {
type: String,
default: () => 'menu-outlined',
required: false,
},
label: {
type: String,
default: () => '快捷方式',
required: false,
},
color: {
type: String,
default: () => '',
required: false,
},
});
// 颜色列表
const colorList = ['#7265E6', '#FFBF00', '#00A2AE', '#F56A00', '#1890FF', '#606D80'];
// 获取随机颜色
const randomColor = () => {
if (props.color) {
return props.color;
}
return colorList[randomNum(0, colorList.length - 1)];
};
// 获取minNum到maxNum内的随机数
const randomNum = (minNum, maxNum) => {
switch (arguments.length) {
case 1:
return parseInt(Math.random() * minNum + 1, 10);
// eslint-disable-next-line no-unreachable
break;
case 2:
return parseInt(Math.random() * (maxNum - minNum + 1) + minNum, 10);
// eslint-disable-next-line no-unreachable
break;
default:
return 0;
// eslint-disable-next-line no-unreachable
break;
}
};
</script>
<style scoped>
.container {
height: 60px;
/*border:1px solid var(--border-color-split);*/
border-radius: 5px;
display: flex;
align-items: center;
cursor: pointer;
/*实现渐变(时间变化效果)*/
-webkit-transition: all 0.5s;
-moz-transition: all 0.5s;
-ms-transition: all 0.5s;
-o-transition: all 0.5s;
transition: all 0.5s;
}
.container:hover {
background: var(--border-color-split);
}
.container-tag {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
margin-left: 10px;
font-size: 24px;
}
.container-span {
max-width: 60%;
font-weight: 500;
margin-left: 10px;
color: #6d6b6b;
}
</style>
+284
View File
@@ -0,0 +1,284 @@
<template>
<canvas
id="canvas"
@mousedown="mouseDown"
@mousemove="mouseMove"
@mouseup="mouseUp"
@touchstart="touchStart"
@touchmove="touchMove"
@touchend="touchEnd"
></canvas>
</template>
<script lang="ts" setup>
const props = defineProps({
width: {
type: Number,
default: 800,
},
height: {
type: Number,
default: 300,
},
lineWidth: {
type: Number,
default: 4,
},
lineColor: {
type: String,
default: '#000000',
},
bgColor: {
type: String,
default: '',
},
isCrop: {
type: Boolean,
default: false,
},
isClearBgColor: {
type: Boolean,
default: true,
},
});
let canvas: any = null; //document.getElementById('canvas')
let hasDrew = false;
let resultImg = '';
let points: any = [];
let canvasTxt: any = null;
let startX = 0;
let startY = 0;
let isDrawing = false;
let sratio: any = 1;
onBeforeMount(() => {
window.addEventListener('resize', resizeHandler);
});
onUnmounted(() => {
window.removeEventListener('resize', resizeHandler);
});
onMounted(() => {
// const canvas = this.$refs.canvas
canvas = document.getElementById('canvas');
canvas.height = props.height;
canvas.width = props.width;
canvas.style.background = myBg.value;
resizeHandler();
// 在画板以外松开鼠标后冻结画笔
document.onmouseup = () => {
isDrawing = false;
};
});
const ratio: any = computed(() => {
return props.height / props.width;
});
const stageInfo: any = computed(() => {
return canvas.getBoundingClientRect();
});
const myBg = computed(() => {
return props.bgColor ? props.bgColor : 'rgba(255, 255, 255, 0)';
});
watch(myBg, (newVal) => {
console.log('bg change', newVal);
canvas.style.background = newVal;
});
const resizeHandler = () => {
canvas.style.width = props.width + 'px';
const realw = parseFloat(window.getComputedStyle(canvas).width);
canvas.style.height = ratio.value * realw + 'px';
canvasTxt = canvas.getContext('2d');
canvasTxt.scale(1 * sratio, 1 * sratio);
sratio = realw / props.width;
canvasTxt.scale(1 / sratio, 1 / sratio);
};
// pc
const mouseDown = (e) => {
e = e || event;
e.preventDefault();
isDrawing = true;
hasDrew = true;
let obj = {
x: e.offsetX,
y: e.offsetY,
};
drawStart(obj);
};
const mouseMove = (e) => {
e = e || event;
e.preventDefault();
if (isDrawing) {
let obj = {
x: e.offsetX,
y: e.offsetY,
};
drawMove(obj);
}
};
const mouseUp = (e) => {
e = e || event;
e.preventDefault();
let obj = {
x: e.offsetX,
y: e.offsetY,
};
drawEnd(obj);
isDrawing = false;
};
// mobile
const touchStart = (e) => {
e = e || event;
e.preventDefault();
hasDrew = true;
if (e.touches.length === 1) {
let obj = {
x: e.targetTouches[0].clientX - canvas.getBoundingClientRect().left,
y: e.targetTouches[0].clientY - canvas.getBoundingClientRect().top,
};
drawStart(obj);
}
};
const touchMove = (e) => {
e = e || event;
e.preventDefault();
if (e.touches.length === 1) {
let obj = {
x: e.targetTouches[0].clientX - canvas.getBoundingClientRect().left,
y: e.targetTouches[0].clientY - canvas.getBoundingClientRect().top,
};
drawMove(obj);
}
};
const touchEnd = (e) => {
e = e || event;
e.preventDefault();
if (e.touches.length === 1) {
let obj = {
x: e.targetTouches[0].clientX - canvas.getBoundingClientRect().left,
y: e.targetTouches[0].clientY - canvas.getBoundingClientRect().top,
};
drawEnd(obj);
}
};
// 绘制
const drawStart = (obj) => {
startX = obj.x;
startY = obj.y;
canvasTxt.beginPath();
canvasTxt.moveTo(startX, startY);
canvasTxt.lineTo(obj.x, obj.y);
canvasTxt.lineCap = 'round';
canvasTxt.lineJoin = 'round';
canvasTxt.lineWidth = props.lineWidth * sratio;
canvasTxt.stroke();
canvasTxt.closePath();
points.push(obj);
};
const drawMove = (obj) => {
canvasTxt.beginPath();
canvasTxt.moveTo(startX, startY);
canvasTxt.lineTo(obj.x, obj.y);
canvasTxt.strokeStyle = props.lineColor;
canvasTxt.lineWidth = props.lineWidth * sratio;
canvasTxt.lineCap = 'round';
canvasTxt.lineJoin = 'round';
canvasTxt.stroke();
canvasTxt.closePath();
startY = obj.y;
startX = obj.x;
points.push(obj);
};
const drawEnd = (obj) => {
canvasTxt.beginPath();
canvasTxt.moveTo(startX, startY);
canvasTxt.lineCap = 'round';
canvasTxt.lineJoin = 'round';
canvasTxt.stroke();
canvasTxt.closePath();
points.push(obj);
points.push({ x: -1, y: -1 });
};
// 操作
const generate = () => {
const pm = new Promise((resolve, reject) => {
if (!hasDrew) {
reject(`Warning: Not Signned!`);
return;
}
var resImgData = canvasTxt.getImageData(0, 0, canvas.width, canvas.height);
canvasTxt.globalCompositeOperation = 'destination-over';
canvasTxt.fillStyle = myBg.value;
canvasTxt.fillRect(0, 0, canvas.width, canvas.height);
resultImg = canvas.toDataURL();
var resultImg = resultImg;
canvasTxt.clearRect(0, 0, canvas.width, canvas.height);
canvasTxt.putImageData(resImgData, 0, 0);
canvasTxt.globalCompositeOperation = 'source-over';
if (props.isCrop) {
const crop_area = getCropArea(resImgData.data);
var crop_canvas: any = document.createElement('canvas');
const crop_ctx: any = crop_canvas.getContext('2d');
crop_canvas.width = crop_area[2] - crop_area[0];
crop_canvas.height = crop_area[3] - crop_area[1];
const crop_imgData = canvasTxt.getImageData(...crop_area);
crop_ctx.globalCompositeOperation = 'destination-over';
crop_ctx.putImageData(crop_imgData, 0, 0);
crop_ctx.fillStyle = myBg.value;
crop_ctx.fillRect(0, 0, crop_canvas.width, crop_canvas.height);
resultImg = crop_canvas.toDataURL();
crop_canvas = null;
}
resolve(resultImg);
});
return pm;
};
const emits = defineEmits(['update:bgColor']);
const reset = () => {
canvasTxt.clearRect(0, 0, canvas.width, canvas.height);
if (props.isClearBgColor) {
// this.$emit('update:bgColor', '')
emits('update:bgColor', '');
canvas.style.background = 'rgba(255, 255, 255, 0)';
}
points = [];
hasDrew = false;
resultImg = '';
};
const getCropArea = (imgData) => {
var topX = canvas.width;
var btmX = 0;
var topY = canvas.height;
var btnY = 0;
for (var i = 0; i < canvas.width; i++) {
for (var j = 0; j < canvas.height; j++) {
var pos = (i + canvas.width * j) * 4;
if (imgData[pos] > 0 || imgData[pos + 1] > 0 || imgData[pos + 2] || imgData[pos + 3] > 0) {
btnY = Math.max(j, btnY);
btmX = Math.max(i, btmX);
topY = Math.min(j, topY);
topX = Math.min(i, topX);
}
}
}
topX++;
btmX++;
topY++;
btnY++;
const data = [topX, topY, btmX, btnY];
return data;
};
defineExpose({ reset, generate });
</script>
<style scoped>
canvas {
max-width: 100%;
display: block;
}
</style>
+118
View File
@@ -0,0 +1,118 @@
<template>
<div class="prefixCls relative" style="width: 100%">
<el-input v-model="innerValueRef" v-bind="$attrs" type="password" show-password @change="handleChange" style="width: 100%">
<template #[item]="data" v-for="item in Object.keys($slots)">
<slot :name="item" v-bind="data || {}"></slot>
</template>
</el-input>
<div class="prefixCls-bar">
<div class="prefixCls-bar--fill" :data-score="getPasswordStrength"></div>
</div>
</div>
</template>
<script setup lang="ts" name="StrengthMeter">
import { verifyPasswordStrength } from '/@/utils/toolsValidate';
const props = defineProps({
value: {
type: String,
},
showInput: {
type: Boolean,
default: () => {
return true;
},
},
disabled: {
type: Boolean,
},
});
const emit = defineEmits(['score', 'change', 'update:value']);
// 计算密码强度
const getPasswordStrength = computed(() => {
const { disabled } = props;
if (disabled) return -1;
const innerValue = unref(innerValueRef);
const score = innerValue ? verifyPasswordStrength(innerValue) : -1;
emit('score', score);
return score;
});
const innerValueRef = ref();
const handleChange = (e: any) => {
innerValueRef.value = e;
};
watchEffect(() => {
innerValueRef.value = props.value || '';
});
watch(
() => unref(innerValueRef),
(val) => {
emit('update:value', val);
emit('change', val);
}
);
</script>
<style scoped lang="scss">
.prefixCls {
&-bar {
position: relative;
height: 6px;
margin: 10px auto 6px;
background-color: grey;
border-radius: 6px;
&::before,
&::after {
position: absolute;
z-index: 10;
display: block;
width: 33%;
height: inherit;
background-color: transparent;
border-color: white;
border-style: solid;
border-width: 0 5px;
content: '';
}
&::before {
left: 33%;
}
//
//&::after {
// right: 33%;
//}
&--fill {
position: absolute;
width: 0;
height: inherit;
background-color: transparent;
border-radius: inherit;
transition: width 0.5s ease-in-out, background 0.25s;
&[data-score='1'] {
width: 33%;
background-color: var(--el-color-danger);
}
&[data-score='2'] {
width: 67%;
background-color: var(--el-color-warning);
}
&[data-score='3'] {
width: 100%;
background-color: var(--el-color-success);
}
}
}
}
</style>
+68
View File
@@ -0,0 +1,68 @@
<template>
<i v-if="isShowIconSvg" class="el-icon" :style="setIconSvgStyle">
<component :is="getIconName" />
</i>
<div v-else-if="isShowIconImg" :style="setIconImgOutStyle">
<img :src="getIconName" :style="setIconSvgInsStyle" />
</div>
<svg v-else-if="isShowLocalSvg" class="svg-icon icon" :style="setIconImgOutStyle">
<use :href="`#${getIconName}`" :fill="color"/>
</svg>
<i v-else :class="getIconName" :style="setIconSvgStyle" />
</template>
<script setup lang="ts" name="svgIcon">
import { computed } from 'vue';
// 定义父组件传过来的值
const props = defineProps({
// svg 图标组件名字
name: {
type: String,
},
// svg 大小
size: {
type: Number,
default: () => 14,
},
// svg 颜色
color: {
type: String
},
});
// 在线链接、本地引入地址前缀
const linesString = ['https', 'http', '/src', '/assets', 'data:image', import.meta.env.VITE_PUBLIC_PATH];
// 获取 icon 图标名称
const getIconName = computed(() => {
return props?.name;
});
// 用于判断 element plus 自带 svg 图标的显示、隐藏
const isShowIconSvg = computed(() => {
return props?.name?.startsWith('ele-');
});
// 用于判断在线链接、本地引入等图标显示、隐藏
const isShowIconImg = computed(() => {
return linesString.find((str) => props.name?.startsWith(str));
});
const isShowLocalSvg = computed(() => {
return props?.name?.startsWith('local-');
});
// 设置图标样式
const setIconSvgStyle = computed(() => {
return `font-size: ${props.size}px;color: ${props.color};`;
});
// 设置图片样式
const setIconImgOutStyle = computed(() => {
return `width: ${props.size}px;height: ${props.size}px;display: inline-block;overflow: hidden;`;
});
// 设置图片样式
const setIconSvgInsStyle = computed(() => {
const filterStyle: string[] = [];
const compatibles: string[] = ['-webkit', '-ms', '-o', '-moz'];
compatibles.forEach((j) => filterStyle.push(`${j}-filter: drop-shadow(${props.color} 30px 0);`));
return `width: ${props.size}px;height: ${props.size}px;position: relative;left: -${props.size}px;${filterStyle.join('')}`;
});
</script>
+82
View File
@@ -0,0 +1,82 @@
<template>
<div class="flex flex-wrap items-center gap-2 max-w-full">
<el-tag
v-for="tag in tags"
:key="tag"
:type="tagType"
closable
:disable-transitions="false"
@close="handleClose(tag)"
>
{{ tag }}
</el-tag>
<el-input
v-if="inputVisible"
ref="InputRef"
v-model="inputValue"
class="w-20"
size="small"
@keyup.enter="handleInputConfirm"
@blur="handleInputConfirm"
/>
<el-button v-else class="button-new-tag" size="small" @click="showInput">
{{ buttonText }}
</el-button>
</div>
</template>
<script lang="ts" setup>
import {ref, nextTick, defineProps, defineEmits} from 'vue';
import {ElInput} from 'element-plus';
const props = defineProps({
modelValue: {
type: Array as () => string[],
default: () => []
},
buttonText: {
type: String,
default: '+ New Tag'
},
tagType: {
type: String,
default: 'primary',
validator: (value: string) => {
return ['primary', 'success', 'info', 'warning', 'danger'].includes(value);
}
}
});
const emits = defineEmits(['update:modelValue']);
const inputValue = ref('');
const inputVisible = ref(false);
const InputRef = ref<InstanceType<typeof ElInput>>();
const tags = ref([...props.modelValue]);
const handleClose = (tag: string) => {
tags.value.splice(tags.value.indexOf(tag), 1);
emits('update:modelValue', tags.value);
};
watch(() => props.modelValue, (val) => {
tags.value = val;
});
const showInput = () => {
inputVisible.value = true;
nextTick(() => {
InputRef.value!.input!.focus();
});
};
const handleInputConfirm = () => {
if (inputValue.value) {
tags.value.push(inputValue.value);
emits('update:modelValue', tags.value);
}
inputVisible.value = false;
inputValue.value = '';
};
</script>
+18
View File
@@ -0,0 +1,18 @@
<template>
<el-tooltip class="box-item" effect="dark" :content="props.content" :placement="props.placement">
<el-icon><QuestionFilled /></el-icon>
<slot></slot>
</el-tooltip>
</template>
<script setup lang="ts" name="tip">
const props = defineProps({
content: {
type: String,
},
placement: {
type: String,
default: 'top-start',
},
});
</script>
+181
View File
@@ -0,0 +1,181 @@
<template>
<div class="el-tree-select">
<el-select
style="width: 100%"
v-model="valueId"
ref="treeSelect"
:filterable="true"
:clearable="true"
@clear="clearHandle"
:filter-method="selectFilterData"
:placeholder="placeholder"
>
<el-option :value="valueId" :label="valueTitle">
<el-tree
id="tree-option"
ref="selectTree"
:accordion="accordion"
:data="options"
:props="objMap"
:node-key="objMap.value"
:expand-on-click-node="false"
:default-expanded-keys="defaultExpandedKey"
:filter-node-method="filterNode"
@node-click="handleNodeClick"
></el-tree>
</el-option>
</el-select>
</div>
</template>
<script setup>
const { proxy } = getCurrentInstance();
const props = defineProps({
/* 配置项 */
objMap: {
type: Object,
default: () => {
return {
value: 'id', // ID字段名
label: 'label', // 显示名称
children: 'children', // 子级字段名
};
},
},
/* 自动收起 */
accordion: {
type: Boolean,
default: () => {
return false;
},
},
/**当前双向数据绑定的值 */
value: {
type: [String, Number],
default: '',
},
/**当前的数据 */
options: {
type: Array,
default: () => [],
},
/**输入框内部的文字 */
placeholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:value']);
const valueId = computed({
get: () => props.value,
set: (val) => {
emit('update:value', val);
},
});
const valueTitle = ref('');
const defaultExpandedKey = ref([]);
/**
* 初始化下拉菜单选择器的默认值,并设置默认选中和默认展开。
*/
function initHandle() {
nextTick(() => {
const selectedValue = valueId.value;
if (selectedValue !== null && typeof selectedValue !== 'undefined') {
const node = proxy.$refs.selectTree.getNode(selectedValue);
if (node) {
valueTitle.value = node.data[props.objMap.label];
proxy.$refs.selectTree.setCurrentKey(selectedValue); // 设置默认选中
defaultExpandedKey.value = [selectedValue]; // 设置默认展开
}
} else {
clearHandle();
}
});
}
/**
* 点击某一节点时触发的事件,更新当前的选中值和展开状态。
* @param {Object} node - 被点击的节点对象
*/
function handleNodeClick(node) {
valueTitle.value = node[props.objMap.label];
valueId.value = node[props.objMap.value];
defaultExpandedKey.value = [];
proxy.$refs.treeSelect.blur();
selectFilterData('');
}
/**
* 搜索过滤函数,根据输入的值来过滤显示的节点。
* @param {String} val - 输入框内的搜索关键字
*/
function selectFilterData(val) {
proxy.$refs.selectTree.filter(val);
}
/**
* 根据输入的值来判断节点是否需要显示。
* @param {String} value - 输入框内的搜索关键字
* @param {Object} data - 当前处理的节点数据
* @returns {Boolean} - 是否需要显示此节点
*/
function filterNode(value, data) {
if (!value) return true;
return data[props.objMap['label']].indexOf(value) !== -1;
}
/**
* 清空当前的选中状态,并重置展开状态。
*/
function clearHandle() {
valueTitle.value = '';
valueId.value = '';
defaultExpandedKey.value = [];
clearSelected();
}
/**
* 删除所有选中状态的节点。
*/
function clearSelected() {
const allNode = document.querySelectorAll('#tree-option .el-tree-node');
allNode.forEach((element) => element.classList.remove('is-current'));
}
onMounted(() => {
initHandle();
});
watch(valueId, () => {
initHandle();
});
</script>
<style lang="scss" scoped>
@import '/@/assets/styles/variables.module.scss';
.el-scrollbar .el-scrollbar__view .el-select-dropdown__item {
padding: 0;
background-color: #fff;
height: auto;
}
.el-select-dropdown__item.selected {
font-weight: normal;
}
ul li .el-tree .el-tree-node__content {
height: auto;
padding: 0 20px;
box-sizing: border-box;
}
:deep(.el-tree-node__content:hover),
:deep(.el-tree-node__content:active),
:deep(.is-current > div:first-child),
:deep(.el-tree-node__content:focus) {
background-color: mix(#fff, $--color-primary, 90%);
color: $--color-primary;
}
</style>
+165
View File
@@ -0,0 +1,165 @@
<!-- excel 导入组件 -->
<template>
<el-dialog :title="prop.title" v-model="state.upload.open" :close-on-click-modal="false" draggable>
<el-upload
ref="uploadRef"
:limit="1"
accept=".xlsx, .xls"
:headers="headers"
:action="baseURL + other.adaptationUrl(url)"
:disabled="state.upload.isUploading"
:on-progress="handleFileUploadProgress"
:on-success="handleFileSuccess"
:on-error="handleFileError"
:auto-upload="false"
drag
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">
{{ $t('excel.operationNotice') }}
<em>{{ $t('excel.clickUpload') }}</em>
</div>
<template #tip>
<div class="el-upload__tip text-center">
<span>{{ $t('excel.fileFormat') }}</span>
<el-link type="primary" :underline="false" style="font-size: 12px; vertical-align: baseline"
@click="downExcelTemp" v-if="tempUrl"
>{{ $t('excel.downloadTemplate') }}
</el-link>
</div>
</template>
</el-upload>
<template #footer>
<el-button type="primary" @click="submitFileForm">{{ $t('common.confirmButtonText') }}</el-button>
<el-button @click="state.upload.open = false">{{ $t('common.cancelButtonText') }}</el-button>
</template>
</el-dialog>
<!--校验失败错误数据-->
<el-dialog :title="$t('excel.validationFailureData')" v-model="state.errorVisible">
<el-table :data="state.errorData">
<el-table-column property="lineNum" :label="$t('excel.lineNumbers')" width="100"></el-table-column>
<el-table-column property="errors" :label="$t('excel.misDescription')" show-overflow-tooltip>
<template v-slot="scope">
<el-tag type="danger" v-for="error in scope.row.errors" :key="error">{{ error }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-dialog>
</template>
<script setup lang="ts" name="upload-excel">
import {useMessage} from '/@/hooks/message';
import other from '/@/utils/other';
import {Session} from '/@/utils/storage';
const emit = defineEmits(['sizeChange', 'refreshDataList']);
const prop = defineProps({
url: {
type: String,
},
title: {
type: String,
},
tempUrl: {
type: String,
},
});
const uploadRef = ref();
const state = reactive({
errorVisible: false,
errorData: [],
dialog: {
title: '',
isShowDialog: false,
},
upload: {
open: false,
isUploading: false,
},
});
/**
* 下载模板文件
*/
const downExcelTemp = async () => {
try {
await other.downBlobFile(other.adaptationUrl(prop.tempUrl), {}, 'temp.xlsx');
} catch (error) {
useMessage().error('模板下载失败,请先维护模板文件');
}
};
/**
* 上传进度条变化事件
*/
const handleFileUploadProgress = () => {
state.upload.isUploading = true;
};
/**
* 上传失败事件处理
*/
const handleFileError = () => {
useMessage().error('上传失败,数据格式不合法!');
state.upload.open = false;
};
/**
* 上传成功事件处理
* @param {any} response - 上传成功的响应结果
*/
const handleFileSuccess = (response: any) => {
state.upload.isUploading = false;
state.upload.open = false;
uploadRef.value.clearFiles();
// 校验失败
if (response.code === 1) {
useMessage().error('导入失败,以下数据不合法');
state.errorVisible = true;
state.errorData = response.data;
uploadRef.value.clearFiles();
// 刷新表格
emit?.('refreshDataList');
} else {
useMessage().success(response.msg ? response.msg : '导入成功');
// 刷新表格
emit?.('refreshDataList');
}
};
/**
* 提交表单,触发上传
*/
const submitFileForm = () => {
uploadRef.value.submit();
};
/**
* 显示上传文件对话框,并清除上传信息
*/
const show = () => {
state.upload.isUploading = false;
state.upload.open = true;
};
/**
* 计算请求头部信息
*/
const headers = computed(() => {
return {
Authorization: 'Bearer ' + Session.getToken(),
'TENANT-ID': Session.getTenant(),
};
});
// 暴露变量
defineExpose({
show,
});
</script>
<style scoped></style>
+319
View File
@@ -0,0 +1,319 @@
<template>
<div class="upload-box">
<el-upload
action="#"
:id="uuid"
:class="['upload', self_disabled ? 'disabled' : '', drag ? 'no-border' : '']"
:multiple="false"
:disabled="self_disabled"
:show-file-list="false"
:http-request="handleHttpUpload"
:before-upload="beforeUpload"
:on-success="uploadSuccess"
:on-error="uploadError"
:drag="drag"
:accept="fileType.join(',')"
>
<template v-if="imageUrl">
<!-- 如果返回的是OSS 地址则不需要增加 baseURL -->
<img :src="imageUrl.includes('http') ? imageUrl : baseURL + imageUrl" class="upload-image" />
<div class="upload-handle" @click.stop>
<div class="handle-icon" @click="editImg" v-if="!self_disabled">
<el-icon :size="props.iconSize"><Edit /></el-icon>
<span v-if="!props.iconSize">{{ $t('common.editBtn') }}</span>
</div>
<div class="handle-icon" @click="imgViewVisible = true">
<el-icon :size="props.iconSize"><ZoomIn /></el-icon>
<span v-if="!props.iconSize">{{ $t('common.viewBtn') }}</span>
</div>
<div class="handle-icon" @click="deleteImg" v-if="!self_disabled">
<el-icon :size="props.iconSize"><Delete /></el-icon>
<span v-if="!props.iconSize">{{ $t('common.delBtn') }}</span>
</div>
</div>
</template>
<template v-else>
<div class="upload-empty">
<slot name="empty">
<el-icon><Plus /></el-icon>
<!-- <span>请上传图片</span> -->
</slot>
</div>
</template>
</el-upload>
<div class="el-upload__tip">
<slot name="tip"></slot>
</div>
<el-image-viewer
:teleported="true"
v-if="imgViewVisible"
@close="imgViewVisible = false"
:url-list="[imageUrl.includes('http') ? imageUrl : baseURL + imageUrl]"
/>
</div>
</template>
<script setup lang="ts" name="UploadImg">
import { ref, computed, inject } from 'vue';
import { ElNotification, formContextKey, formItemContextKey } from 'element-plus';
import type { UploadProps, UploadRequestOptions } from 'element-plus';
import { generateUUID } from '/@/utils/other';
import request from '/@/utils/request';
interface UploadFileProps {
imageUrl?: string; // 图片地址 ==> 必传
uploadFileUrl?: string; // 上传图片的 api 方法,一般项目上传都是同一个 api 方法,在组件里直接引入即可 ==> 非必传
drag?: boolean; // 是否支持拖拽上传 ==> 非必传(默认为 true)
disabled?: boolean; // 是否禁用上传组件 ==> 非必传(默认为 false)
fileSize?: number; // 图片大小限制 ==> 非必传(默认为 5M)
fileType?: File.ImageMimeType[]; // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"])
height?: string; // 组件高度 ==> 非必传(默认为 150px)
width?: string; // 组件宽度 ==> 非必传(默认为 150px)
borderRadius?: string; // 组件边框圆角 ==> 非必传(默认为 8px)
iconSize?: number;
dir?: string; // 文件目录
}
// 接受父组件参数
const props = withDefaults(defineProps<UploadFileProps>(), {
imageUrl: '',
uploadFileUrl: '/admin/sys-file/upload',
drag: true,
disabled: false,
fileSize: 5,
fileType: () => ['image/jpeg', 'image/png', 'image/gif'],
height: '150px',
width: '150px',
borderRadius: '8px',
dir: ''
});
// 生成组件唯一id
const uuid = ref('id-' + generateUUID());
// 查看图片
const imgViewVisible = ref(false);
// 获取 el-form 组件上下文
const formContext = inject(formContextKey, void 0);
// 获取 el-form-item 组件上下文
const formItemContext = inject(formItemContextKey, void 0);
// 判断是否禁用上传和删除
const self_disabled = computed(() => {
return props.disabled || formContext?.disabled;
});
/**
* @description 图片上传
* @param options upload 所有配置项
* */
interface UploadEmits {
(e: 'update:imageUrl', value: string): void;
}
const emit = defineEmits<UploadEmits>();
const handleHttpUpload = async (options: UploadRequestOptions) => {
let formData = new FormData();
formData.append('file', options.file);
formData.append('dir', props.dir);
try {
const { data } = await request({
url: props.uploadFileUrl,
method: 'post',
headers: {
'Content-Type': 'multipart/form-data',
},
data: formData,
});
emit('update:imageUrl', data.url);
// 调用 el-form 内部的校验方法(可自动校验)
formItemContext?.prop && formContext?.validateField([formItemContext.prop as string]);
} catch (error) {
options.onError(error as any);
}
};
/**
* @description 删除图片
* */
const deleteImg = () => {
emit('update:imageUrl', '');
};
/**
* @description 编辑图片
* */
const editImg = () => {
const dom = document.querySelector(`#${uuid.value} .el-upload__input`);
dom && dom.dispatchEvent(new MouseEvent('click'));
};
/**
* @description 文件上传之前判断
* @param rawFile 选择的文件
* */
const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
const imgSize = rawFile.size / 1024 / 1024 < props.fileSize;
const imgType = props.fileType.includes(rawFile.type as File.ImageMimeType);
if (!imgType)
ElNotification({
title: '温馨提示',
message: '上传图片不符合所需的格式!',
type: 'warning',
});
if (!imgSize)
setTimeout(() => {
ElNotification({
title: '温馨提示',
message: `上传图片大小不能超过 ${props.fileSize}M!`,
type: 'warning',
});
}, 0);
return imgType && imgSize;
};
/**
* @description 图片上传成功
* */
const uploadSuccess = () => {
ElNotification({
title: '温馨提示',
message: '图片上传成功!',
type: 'success',
});
};
/**
* @description 图片上传错误
* */
const uploadError = () => {
ElNotification({
title: '温馨提示',
message: '图片上传失败,请您重新上传!',
type: 'error',
});
};
</script>
<style scoped lang="scss">
.is-error {
.upload {
:deep(.el-upload),
:deep(.el-upload-dragger) {
border: 1px dashed var(--el-color-danger) !important;
&:hover {
border-color: var(--el-color-primary) !important;
}
}
}
}
:deep(.disabled) {
.el-upload,
.el-upload-dragger {
cursor: not-allowed !important;
background: var(--el-disabled-bg-color);
border: 1px dashed var(--el-border-color-darker) !important;
&:hover {
border: 1px dashed var(--el-border-color-darker) !important;
}
}
}
.upload-box {
.no-border {
:deep(.el-upload) {
border: none !important;
}
}
:deep(.upload) {
.el-upload {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: v-bind(width);
height: v-bind(height);
overflow: hidden;
border: 1px dashed var(--el-border-color-darker);
border-radius: v-bind(borderRadius);
transition: var(--el-transition-duration-fast);
&:hover {
border-color: var(--el-color-primary);
.upload-handle {
opacity: 1;
}
}
.el-upload-dragger {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 0;
overflow: hidden;
background-color: transparent;
border: 1px dashed var(--el-border-color-darker);
border-radius: v-bind(borderRadius);
&:hover {
border: 1px dashed var(--el-color-primary);
}
}
.el-upload-dragger.is-dragover {
background-color: var(--el-color-primary-light-9);
border: 2px dashed var(--el-color-primary) !important;
}
.upload-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.upload-empty {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 30px;
color: var(--el-color-info);
.el-icon {
font-size: 28px;
color: var(--el-text-color-secondary);
}
}
.upload-handle {
position: absolute;
top: 0;
right: 0;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
cursor: pointer;
background: rgb(0 0 0 / 60%);
opacity: 0;
transition: var(--el-transition-duration-fast);
.handle-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 6%;
color: aliceblue;
.el-icon {
margin-bottom: 40%;
font-size: 130%;
line-height: 130%;
}
span {
font-size: 85%;
line-height: 85%;
}
}
}
}
}
.el-upload__tip {
line-height: 18px;
text-align: center;
}
}
</style>
+19
View File
@@ -0,0 +1,19 @@
export default {
excel: {
downloadTemplate: 'downloading the template',
fileFormat: 'only xls, xlsx format files are allowed',
operationNotice: 'Drag the file here and',
clickUpload: 'click upload',
lineNumbers: 'line numbers',
misDescription: 'misDescription',
validationFailureData: 'validation failure data',
pleaseUpload: 'please upload',
size: 'size not exceeding',
format: 'format',
file: 'file',
sizeErrorText: 'file size error, max ',
typeErrorText: 'file type error, upload ',
uploadLimit: 'Upload limit exceeded. Maximum',
files: 'files allowed',
},
};
+19
View File
@@ -0,0 +1,19 @@
export default {
excel: {
downloadTemplate: '下载模板',
fileFormat: '仅允许导入xls、xlsx格式文件。',
operationNotice: '将文件拖到此处,或',
clickUpload: '点击上传',
lineNumbers: '行号',
misDescription: '错误描述',
validationFailureData: '校验失败数据',
pleaseUpload: '请上传',
size: '大小不超过',
format: '格式为',
file: '的文件',
sizeErrorText: '文件大小不超过',
typeErrorText: '文件类型错误,请上传 ',
uploadLimit: '上传文件数量超出限制,最多允许上传',
files: '个文件',
},
};
+259
View File
@@ -0,0 +1,259 @@
<!--文件上传组件-->
<template>
<div class="w-full upload-file">
<el-upload
ref="fileUpload"
v-if="props.type === 'default'"
:action="baseURL + other.adaptationUrl(props.uploadFileUrl)"
:before-upload="handleBeforeUpload"
:file-list="fileList"
:headers="headers"
:limit="limit"
:on-error="handleUploadError"
:on-remove="handleRemove"
:on-preview="handlePreview"
:on-exceed="handleExceed"
:data="formData"
:auto-upload="autoUpload"
:on-success="handleUploadSuccess"
class="upload-file-uploader"
drag
multiple
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">
{{ $t('excel.operationNotice') }}
<em>{{ $t('excel.clickUpload') }}</em>
</div>
<template #tip>
<div class="el-upload__tip" v-if="props.isShowTip">
{{ $t('excel.pleaseUpload') }}
<template v-if="props.fileSize">
{{ $t('excel.size') }} <b style="color: #f56c6c">{{ props.fileSize }}MB</b></template
>
<template v-if="props.fileType">
{{ $t('excel.format') }} <b style="color: #f56c6c">{{ props.fileType.join('/') }}</b>
</template>
{{ $t('excel.file') }}
</div>
</template>
</el-upload>
<el-upload
ref="fileUpload"
v-if="props.type === 'simple'"
:action="baseURL + other.adaptationUrl(props.uploadFileUrl)"
:before-upload="handleBeforeUpload"
:file-list="fileList"
:headers="headers"
:limit="limit"
:auto-upload="autoUpload"
:on-error="handleUploadError"
:on-remove="handleRemove"
:on-exceed="handleExceed"
:data="formData"
:on-success="handleUploadSuccess"
class="upload-file-uploader"
multiple
>
<el-button type="primary" link>{{ $t('excel.clickUpload') }}</el-button>
</el-upload>
</div>
</template>
<script setup lang="ts" name="upload-file">
import { useMessage } from '/@/hooks/message';
import { Session } from '/@/utils/storage';
import other from '/@/utils/other';
import { useI18n } from 'vue-i18n';
const props = defineProps({
modelValue: [String, Array],
// 数量限制
limit: {
type: Number,
default: 5,
},
// 大小限制(MB)
fileSize: {
type: Number,
default: 5,
},
fileType: {
type: Array,
default: () => ['png', 'jpg', 'jpeg', 'doc', 'xls', 'ppt', 'txt', 'pdf', 'docx', 'xlsx', 'pptx'],
},
// 是否显示提示
isShowTip: {
type: Boolean,
default: true,
},
uploadFileUrl: {
type: String,
default: '/admin/sys-file/upload',
},
type: {
type: String,
default: 'default',
validator: (value: string) => {
return ['default', 'simple'].includes(value);
},
},
data: {
type: Object,
default: () => ({}),
},
dir: {
type: String,
default: '',
},
autoUpload: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update:modelValue', 'change']);
const number = ref(0);
const fileList = ref<{ name?: string; url?: string; uid?: number }[]>([]);
const uploadList = ref<{ name: string; url: string; fileUrl: string; fileSize: number; fileName: string; fileType: string }[]>([]);
const fileUpload = ref();
const { t } = useI18n();
// 请求头处理
const headers = computed(() => {
return {
Authorization: 'Bearer ' + Session.get('token'),
'TENANT-ID': Session.getTenant(),
};
});
// 请求参数处理
const formData = computed(() => {
return Object.assign(props.data, { dir: props.dir });
});
// 上传前校检格式和大小
const handleBeforeUpload = (file: File) => {
// 校检文件类型
if (props.fileType.length) {
const fileName = file.name.split('.');
const fileExt = fileName[fileName.length - 1];
const isTypeOk = props.fileType.indexOf(fileExt) >= 0;
if (!isTypeOk) {
useMessage().error(`${t('excel.typeErrorText')} ${props.fileType.join('/')}!`);
return false;
}
}
// 校检文件大小
if (props.fileSize) {
const isLt = file.size / 1024 / 1024 < props.fileSize;
if (!isLt) {
useMessage().error(`${t('excel.sizeErrorText')} ${props.fileSize} MB!`);
return false;
}
}
number.value++;
return true;
};
// 上传成功回调
function handleUploadSuccess(res: any, file: any) {
if (res.code === 0) {
uploadList.value.push({
name: file.name,
url: res.data.url,
fileUrl: res.data.fileName,
fileSize: file.size,
fileName: file.name,
fileType: file.raw.type,
});
uploadedSuccessfully();
} else {
number.value--;
useMessage().error(res.msg);
fileUpload.value.handleRemove(file);
uploadedSuccessfully();
}
}
// 上传结束处理
const uploadedSuccessfully = () => {
if (number.value > 0 && uploadList.value.length === number.value) {
fileList.value = fileList.value.filter((f) => f.url !== undefined).concat(uploadList.value);
uploadList.value = [];
number.value = 0;
emit('change', listToString(fileList.value), fileList.value);
emit('update:modelValue', listToString(fileList.value));
}
};
const handleRemove = (file: { url: string }) => {
fileList.value = fileList.value.filter((f) => f.url !== file.url);
emit('change', listToString(fileList.value));
emit('update:modelValue', listToString(fileList.value));
};
const handlePreview = (file: any) => {
other.downBlobFile(file.url, {}, file.name);
};
// 添加 handleExceed 函数
const handleExceed = (files: File[]) => {
useMessage().warning(`${t('excel.uploadLimit')} ${props.limit} ${t('excel.files')}`);
};
/**
* 将对象数组转为字符串,以逗号分隔。
* @param list 待转换的对象数组。
* @param separator 分隔符,默认为逗号。
* @returns {string} 返回转换后的字符串。
*/
const listToString = (list: { url: string }[], separator = ','): string => {
let strs = '';
separator = separator || ',';
for (let i in list) {
if (list[i].url) {
strs += list[i].url + separator;
}
}
return strs !== '' ? strs.substr(0, strs.length - 1) : '';
};
const handleUploadError = () => {
useMessage().error('上传文件失败');
};
/**
* 监听 props 中的 modelValue 值变化,更新 fileList。
*/
watch(
() => props.modelValue,
(val) => {
if (val) {
let temp = 1;
// 首先将值转为数组
const list = Array.isArray(val) ? val : (props.modelValue as string).split(',');
// 然后将数组转为对象数组
fileList.value = list.map((item) => {
if (typeof item === 'string') {
item = { name: item.split('fileName=')[1], url: item };
}
item.uid = item.uid || new Date().getTime() + temp++;
return item;
});
} else {
fileList.value = [];
}
},
{ deep: true, immediate: true }
);
const submit = () => {
fileUpload.value.submit();
};
defineExpose({
submit,
});
</script>
+524
View File
@@ -0,0 +1,524 @@
<template>
<div v-show="showBox" :class="mode == 'pop' ? 'mask' : ''" @click="handleMaskClick">
<div :class="mode == 'pop' ? 'verifybox' : ''" :style="{ 'max-width': parseInt(imgSize.width) + 30 + 'px' }" @click.stop>
<div v-if="mode == 'pop'" class="verifybox-top">
{{ t('verify.complete') }}
<span class="verifybox-close" @click="closeBox">
<i class="iconfont icon-close"></i>
</span>
</div>
<div :style="{ padding: mode == 'pop' ? '15px' : '0' }" class="verifybox-bottom">
<!-- 验证码容器 -->
<component
:is="componentType"
v-if="componentType"
ref="instance"
:arith="arith"
:barSize="barSize"
:blockSize="blockSize"
:captchaType="captchaType"
:explain="explain"
:figure="figure"
:imgSize="imgSize"
:mode="mode"
:type="verifyType"
:vSpace="vSpace"
></component>
</div>
</div>
</div>
</template>
<script>
/**
* Verify 验证码组件
* @description 分发验证码使用
* */
import { computed, ref, toRefs, watchEffect, defineAsyncComponent } from 'vue';
import { useI18n } from 'vue-i18n';
const VerifySlide = defineAsyncComponent(() => import('/@/components/Verifition/Verify/VerifySlide.vue'));
const VerifyPoints = defineAsyncComponent(() => import('/@/components/Verifition/Verify/VerifyPoints.vue'));
export default {
name: 'Vue2Verify',
components: {
VerifySlide,
VerifyPoints,
},
props: {
captchaType: {
type: String,
required: true,
},
figure: {
type: Number,
},
arith: {
type: Number,
},
mode: {
type: String,
default: 'pop',
},
vSpace: {
type: Number,
},
explain: {
type: String,
},
imgSize: {
type: Object,
default() {
return {
width: '310px',
height: '155px',
};
},
},
blockSize: {
type: Object,
},
barSize: {
type: Object,
},
},
setup(props) {
const { t } = useI18n();
const { captchaType, mode } = toRefs(props);
const clickShow = ref(false);
const verifyType = ref(undefined);
const componentType = ref(undefined);
const instance = ref({});
const showBox = computed(() => {
if (mode.value == 'pop') {
return clickShow.value;
} else {
return true;
}
});
/**
* refresh
* @description 刷新
* */
const refresh = () => {
if (instance.value.refresh) {
instance.value.refresh();
}
};
const closeBox = () => {
clickShow.value = false;
refresh();
};
const show = () => {
if (mode.value == 'pop') {
clickShow.value = true;
}
};
watchEffect(() => {
switch (captchaType.value) {
case 'blockPuzzle':
verifyType.value = '2';
componentType.value = 'VerifySlide';
break;
case 'clickWord':
verifyType.value = '';
componentType.value = 'VerifyPoints';
break;
}
});
const handleMaskClick = (e) => {
if (e.target.classList.contains('mask')) {
closeBox();
}
};
return {
t,
clickShow,
verifyType,
componentType,
instance,
showBox,
closeBox,
show,
handleMaskClick,
};
},
};
</script>
<style>
.verifybox {
position: fixed;
box-sizing: border-box;
border-radius: 2px;
border: 1px solid #e4e7eb;
background-color: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
margin: auto;
height: 280px;
overflow: hidden;
min-width: 320px;
z-index: 1002;
}
.verifybox-top {
padding: 0 15px;
height: 50px;
line-height: 50px;
text-align: left;
font-size: 16px;
color: #45494c;
border-bottom: 1px solid #e4e7eb;
box-sizing: border-box;
}
.verifybox-bottom {
padding: 15px;
box-sizing: border-box;
height: calc(100% - 50px);
overflow: hidden;
}
.verifybox-close {
position: absolute;
top: 13px;
right: 9px;
width: 24px;
height: 24px;
text-align: center;
cursor: pointer;
}
.mask {
position: fixed;
top: 0;
left: 0;
z-index: 1001;
width: 100%;
height: 100vh;
background: transparent;
pointer-events: auto;
}
.verify-tips {
position: absolute;
left: 0px;
bottom: 0px;
width: 100%;
height: 30px;
line-height: 30px;
color: #fff;
}
.suc-bg {
background-color: rgba(92, 184, 92, 0.5);
filter: progid:DXImageTransform.Microsoft.gradient(startcolorstr=#7f5CB85C, endcolorstr=#7f5CB85C);
}
.err-bg {
background-color: rgba(217, 83, 79, 0.5);
filter: progid:DXImageTransform.Microsoft.gradient(startcolorstr=#7fD9534F, endcolorstr=#7fD9534F);
}
/* ---------------------------- */
/*常规验证码*/
.verify-code {
font-size: 20px;
text-align: center;
cursor: pointer;
margin-bottom: 5px;
border: 1px solid #ddd;
}
.cerify-code-panel {
height: 100%;
overflow: hidden;
}
.verify-code-area {
float: left;
}
.verify-input-area {
float: left;
width: 60%;
padding-right: 10px;
}
.verify-change-area {
line-height: 30px;
float: left;
}
.varify-input-code {
display: inline-block;
width: 100%;
height: 25px;
}
.verify-change-code {
color: #337ab7;
cursor: pointer;
}
.verify-btn {
width: 200px;
height: 30px;
background-color: #337ab7;
color: #ffffff;
border: none;
margin-top: 10px;
}
/*滑动验证码*/
.verify-bar-area {
position: relative;
background: #ffffff;
text-align: center;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
border: 1px solid #ddd;
-webkit-border-radius: 4px;
}
.verify-bar-area .verify-move-block {
position: absolute;
top: 0px;
left: 0;
background: #fff;
cursor: pointer;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
box-shadow: 0 0 2px #888888;
-webkit-border-radius: 1px;
transition: none;
}
.verify-bar-area .verify-move-block:hover {
background-color: #337ab7;
color: #ffffff;
}
.verify-bar-area .verify-left-bar {
position: absolute;
top: -1px;
left: -1px;
background: #f0fff0;
cursor: pointer;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
border: 1px solid #ddd;
transition: none;
}
.verify-img-panel {
margin: 0;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
border-radius: 3px;
position: relative;
}
.verify-img-panel .verify-refresh {
width: 25px;
height: 25px;
text-align: center;
padding: 5px;
cursor: pointer;
position: absolute;
top: 0;
right: 0;
z-index: 2;
}
.verify-img-panel .icon-refresh {
font-size: 20px;
color: #fff;
}
.verify-img-panel .verify-gap {
background-color: #fff;
position: relative;
z-index: 2;
border: 1px solid #fff;
}
.verify-bar-area .verify-move-block .verify-sub-block {
position: absolute;
text-align: center;
z-index: 3;
/* border: 1px solid #fff; */
}
.verify-bar-area .verify-move-block .verify-icon {
font-size: 18px;
}
.verify-bar-area .verify-msg {
z-index: 3;
}
.iconfont {
font-family: 'iconfont' !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-check:before {
content: ' ';
display: block;
width: 16px;
height: 16px;
position: absolute;
margin: auto;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 9999;
background-size: contain;
}
.icon-close:before {
content: ' ';
display: block;
width: 16px;
height: 16px;
position: absolute;
margin: auto;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 9999;
background-size: contain;
}
.icon-right:before {
content: ' ';
display: block;
width: 16px;
height: 16px;
position: absolute;
margin: auto;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-size: cover;
z-index: 9999;
background-size: contain;
}
.icon-refresh:before {
content: ' ';
display: block;
width: 16px;
height: 16px;
position: absolute;
margin: auto;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 9999;
background-size: contain;
}
/* 在 <style> 标签内添加暗黑模式样式 */
html.dark {
.verifybox {
background-color: var(--el-bg-color);
border: none;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
}
.verifybox-top {
color: var(--el-text-color-primary);
border-bottom: 1px solid var(--el-border-color-darker);
background-color: var(--el-bg-color-darker);
}
.verify-bar-area {
background: var(--el-bg-color-darker);
border: none;
}
.verify-bar-area .verify-move-block {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.1);
}
.verify-bar-area .verify-left-bar {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.2));
border: none;
opacity: 1;
}
.verify-img-panel {
border: none;
background-color: var(--el-bg-color-darker);
}
.verify-img-panel .verify-gap {
background-color: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.2);
}
/* 暗黑模式下的图标颜色 */
.icon-refresh:before,
.icon-right:before,
.icon-close:before,
.verify-bar-area .verify-move-block:after {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23ffffff" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/></svg>');
opacity: 0.8;
}
.mask {
background: transparent;
}
}
/* 修改图标样式 */
.icon-refresh:before {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23666" d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>');
}
.icon-right:before {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23666" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/></svg>');
}
.icon-close:before {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23666" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>');
}
.verify-bar-area .verify-move-block {
display: flex;
align-items: center;
justify-content: center;
}
.verify-bar-area .verify-move-block:after {
content: '';
width: 16px;
height: 16px;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23666" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/></svg>');
background-size: contain;
background-repeat: no-repeat;
}
</style>
@@ -0,0 +1,274 @@
<template>
<div style="position: relative">
<div class="verify-img-out">
<div
class="verify-img-panel"
:style="{
width: setSize.imgWidth,
height: setSize.imgHeight,
'background-size': setSize.imgWidth + ' ' + setSize.imgHeight,
'margin-bottom': vSpace + 'px',
}"
>
<div class="verify-refresh" style="z-index: 3" @click="refresh" v-show="showRefresh">
<i class="iconfont icon-refresh"></i>
</div>
<img
:src="'data:image/png;base64,' + pointBackImgBase"
ref="canvas"
alt=""
style="width: 100%; height: 100%; display: block"
@click="bindingClick ? canvasClick($event) : undefined"
/>
<div
v-for="(tempPoint, index) in tempPoints"
:key="index"
class="point-area"
:style="{
'background-color': '#1abd6c',
color: '#fff',
'z-index': 9999,
width: '20px',
height: '20px',
'text-align': 'center',
'line-height': '20px',
'border-radius': '50%',
position: 'absolute',
top: parseInt(tempPoint.y - 10) + 'px',
left: parseInt(tempPoint.x - 10) + 'px',
}"
>
{{ index + 1 }}
</div>
</div>
</div>
<!-- 'height': this.barSize.height, -->
<div
class="verify-bar-area"
:style="{ width: setSize.imgWidth, color: this.barAreaColor, 'border-color': this.barAreaBorderColor, 'line-height': this.barSize.height }"
>
<span class="verify-msg">{{ text }}</span>
</div>
</div>
</template>
<script type="text/babel">
/**
* VerifyPoints
* @description 点选
* */
import { resetSize } from '../utils/util';
import { aesEncrypt } from '../utils/ase';
import { reqGet, reqCheck } from '../api/index';
import { onMounted, reactive, ref, nextTick, toRefs, getCurrentInstance } from 'vue';
export default {
name: 'VerifyPoints',
props: {
//弹出式pop,固定fixed
mode: {
type: String,
default: 'fixed',
},
captchaType: {
type: String,
},
//间隔
vSpace: {
type: Number,
default: 5,
},
imgSize: {
type: Object,
default() {
return {
width: '310px',
height: '155px',
};
},
},
barSize: {
type: Object,
default() {
return {
width: '310px',
height: '40px',
};
},
},
},
setup(props) {
const { mode, captchaType } = toRefs(props);
const { proxy } = getCurrentInstance();
let secretKey = ref(''), //后端返回的ase加密秘钥
checkNum = ref(3), //默认需要点击的字数
fontPos = reactive([]), //选中的坐标信息
checkPosArr = reactive([]), //用户点击的坐标
num = ref(1), //点击的记数
pointBackImgBase = ref(''), //后端获取到的背景图片
poinTextList = reactive([]), //后端返回的点击字体顺序
backToken = ref(''), //后端返回的token值
setSize = reactive({
imgHeight: 0,
imgWidth: 0,
barHeight: 0,
barWidth: 0,
}),
tempPoints = reactive([]),
text = ref(''),
barAreaColor = ref(undefined),
barAreaBorderColor = ref(undefined),
showRefresh = ref(true),
bindingClick = ref(true);
const init = () => {
//加载页面
fontPos.splice(0, fontPos.length);
checkPosArr.splice(0, checkPosArr.length);
num.value = 1;
getPictrue();
nextTick(() => {
let { imgHeight, imgWidth, barHeight, barWidth } = resetSize(proxy);
setSize.imgHeight = imgHeight;
setSize.imgWidth = imgWidth;
setSize.barHeight = barHeight;
setSize.barWidth = barWidth;
proxy.$parent.$emit('ready', proxy);
});
};
onMounted(() => {
// 禁止拖拽
init();
proxy.$el.onselectstart = function () {
return false;
};
});
const canvas = ref(null);
const canvasClick = (e) => {
checkPosArr.push(getMousePos(canvas, e));
if (num.value == checkNum.value) {
num.value = createPoint(getMousePos(canvas, e));
//按比例转换坐标值
let arr = pointTransfrom(checkPosArr, setSize);
checkPosArr.length = 0;
checkPosArr.push(...arr);
//等创建坐标执行完
setTimeout(() => {
// var flag = this.comparePos(this.fontPos, this.checkPosArr);
//发送后端请求
var captchaVerification = secretKey.value
? aesEncrypt(backToken.value + '---' + JSON.stringify(checkPosArr), secretKey.value)
: backToken.value + '---' + JSON.stringify(checkPosArr);
let data = {
captchaType: captchaType.value,
pointJson: secretKey.value ? aesEncrypt(JSON.stringify(checkPosArr), secretKey.value) : JSON.stringify(checkPosArr),
token: backToken.value,
};
reqCheck(data).then((response) => {
let res = response.data;
if (res.repCode == '0000') {
barAreaColor.value = '#4cae4c';
barAreaBorderColor.value = '#5cb85c';
text.value = '验证成功';
bindingClick.value = false;
if (mode.value == 'pop') {
setTimeout(() => {
proxy.$parent.clickShow = false;
refresh();
}, 1500);
}
proxy.$parent.$emit('success', { captchaVerification });
} else {
proxy.$parent.$emit('error', proxy);
barAreaColor.value = '#d9534f';
barAreaBorderColor.value = '#d9534f';
text.value = '验证失败';
setTimeout(() => {
refresh();
}, 700);
}
});
}, 400);
}
if (num.value < checkNum.value) {
num.value = createPoint(getMousePos(canvas, e));
}
};
//获取坐标
const getMousePos = function (obj, e) {
var x = e.offsetX;
var y = e.offsetY;
return { x, y };
};
//创建坐标点
const createPoint = function (pos) {
tempPoints.push(Object.assign({}, pos));
return num.value + 1;
};
const refresh = function () {
tempPoints.splice(0, tempPoints.length);
barAreaColor.value = '#000';
barAreaBorderColor.value = '#ddd';
bindingClick.value = true;
fontPos.splice(0, fontPos.length);
checkPosArr.splice(0, checkPosArr.length);
num.value = 1;
getPictrue();
text.value = '验证失败';
showRefresh.value = true;
};
// 请求背景图片和验证图片
function getPictrue() {
let data = {
captchaType: captchaType.value,
};
reqGet(data).then((response) => {
let res = response.data;
if (res.repCode == '0000') {
pointBackImgBase.value = res.repData.originalImageBase64;
backToken.value = res.repData.token;
secretKey.value = res.repData.secretKey;
poinTextList.value = res.repData.wordList;
text.value = '请依次点击【' + poinTextList.value.join(',') + '】';
} else {
text.value = res.repMsg;
}
});
}
//坐标转换函数
const pointTransfrom = function (pointArr, imgSize) {
var newPointArr = pointArr.map((p) => {
let x = Math.round((310 * p.x) / parseInt(imgSize.imgWidth));
let y = Math.round((155 * p.y) / parseInt(imgSize.imgHeight));
return { x, y };
});
return newPointArr;
};
return {
secretKey,
checkNum,
fontPos,
checkPosArr,
num,
pointBackImgBase,
poinTextList,
backToken,
setSize,
tempPoints,
text,
barAreaColor,
barAreaBorderColor,
showRefresh,
bindingClick,
init,
canvas,
canvasClick,
getMousePos,
createPoint,
refresh,
getPictrue,
pointTransfrom,
};
},
};
</script>
@@ -0,0 +1,516 @@
<template>
<div style="position: relative">
<div v-if="type === '2'" :style="{ height: parseInt(setSize.imgHeight) + vSpace + 'px' }" class="verify-img-out">
<div :style="{ width: setSize.imgWidth, height: setSize.imgHeight }" class="verify-img-panel modern-shadow">
<img :src="'data:image/png;base64,' + backImgBase" alt="" style="width: 100%; height: 100%; display: block" />
<div v-show="showRefresh" class="verify-refresh" @click="refresh"><i class="iconfont icon-refresh"></i></div>
<transition name="tips">
<span v-if="tipWords" :class="passFlag ? 'suc-bg' : 'err-bg'" class="verify-tips">{{ tipWords }}</span>
</transition>
</div>
</div>
<!-- 公共部分 -->
<div :style="{ width: setSize.imgWidth, height: barSize.height, 'line-height': barSize.height }" class="verify-bar-area modern-bar">
<span class="verify-msg" v-text="text"></span>
<div
:style="{
width: leftBarWidth !== undefined ? leftBarWidth : barSize.height,
height: barSize.height,
'border-color': leftBarBorderColor,
transaction: transitionWidth,
}"
class="verify-left-bar"
>
<span class="verify-msg" v-text="finishText"></span>
<div
:style="{
width: barSize.height,
height: barSize.height,
'background-color': moveBlockBackgroundColor,
left: moveBlockLeft,
transition: transitionLeft,
}"
class="verify-move-block"
@mousedown="start"
@touchstart="start"
>
<i :class="['verify-icon iconfont', iconClass]" :style="{ color: iconColor }"></i>
<div
v-if="type === '2'"
:style="{
width: Math.floor((parseInt(setSize.imgWidth) * 47) / 310) + 'px',
height: setSize.imgHeight,
top: '-' + (parseInt(setSize.imgHeight) + vSpace) + 'px',
'background-size': setSize.imgWidth + ' ' + setSize.imgHeight,
}"
class="verify-sub-block"
>
<img
:src="'data:image/png;base64,' + blockBackImgBase"
alt=""
style="width: 100%; height: 100%; display: block; -webkit-user-drag: none"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script type="text/babel">
/**
* VerifySlide
* @description 滑块
* */
import { aesEncrypt } from '../utils/ase';
import { resetSize } from '../utils/util';
import { reqCheck, reqGet } from '../api/index';
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, toRefs, watch } from 'vue';
import { useI18n } from 'vue-i18n';
export default {
name: 'VerifySlide',
props: {
captchaType: {
type: String,
},
type: {
type: String,
default: '1',
},
//弹出式pop,固定fixed
mode: {
type: String,
default: 'fixed',
},
vSpace: {
type: Number,
default: 5,
},
explain: {
type: String,
default: '向右滑动完成验证',
/** @deprecated 使用 i18n 国际化替代 */
},
imgSize: {
type: Object,
default() {
return {
width: '310px',
height: '155px',
};
},
},
blockSize: {
type: Object,
default() {
return {
width: '50px',
height: '50px',
};
},
},
barSize: {
type: Object,
default() {
return {
width: '310px',
height: '40px',
};
},
},
},
setup(props) {
const { t } = useI18n();
const { mode, captchaType, type, blockSize } = toRefs(props);
const { proxy } = getCurrentInstance();
let secretKey = ref(''), //后端返回的ase加密秘钥
passFlag = ref(''), //是否通过的标识
backImgBase = ref(''), //验证码背景图片
blockBackImgBase = ref(''), //验证滑块的背景图片
backToken = ref(''), //后端返回的唯一token值
startMoveTime = ref(''), //移动开始的时间
endMovetime = ref(''), //移动结束的时间
tipsBackColor = ref(''), //提示词的背景颜色
tipWords = ref(''),
text = ref(''),
finishText = ref(''),
setSize = reactive({
imgHeight: 0,
imgWidth: 0,
barHeight: 0,
barWidth: 0,
}),
top = ref(0),
left = ref(0),
moveBlockLeft = ref(undefined),
leftBarWidth = ref(undefined),
// 移动中样式
moveBlockBackgroundColor = ref(undefined),
leftBarBorderColor = ref('#ddd'),
iconColor = ref(undefined),
iconClass = ref('icon-right'),
status = ref(false), //鼠标状态
isEnd = ref(false), //是够验证完成
showRefresh = ref(true),
transitionLeft = ref(''),
transitionWidth = ref(''),
startLeft = ref(0);
const barArea = computed(() => {
return proxy.$el.querySelector('.verify-bar-area');
});
function init() {
text.value = t('verify.slide.explain');
getPictrue();
nextTick(() => {
let { imgHeight, imgWidth, barHeight, barWidth } = resetSize(proxy);
setSize.imgHeight = imgHeight;
setSize.imgWidth = imgWidth;
setSize.barHeight = barHeight;
setSize.barWidth = barWidth;
proxy.$parent.$emit('ready', proxy);
});
window.removeEventListener('touchmove', function (e) {
move(e);
});
window.removeEventListener('mousemove', function (e) {
move(e);
});
//鼠标松开
window.removeEventListener('touchend', function () {
end();
});
window.removeEventListener('mouseup', function () {
end();
});
window.addEventListener('touchmove', function (e) {
move(e);
});
window.addEventListener('mousemove', function (e) {
move(e);
});
//鼠标松开
window.addEventListener('touchend', function () {
end();
});
window.addEventListener('mouseup', function () {
end();
});
}
watch(type, () => {
init();
});
onMounted(() => {
// 禁止拖拽
init();
proxy.$el.onselectstart = function () {
return false;
};
});
//鼠标按下
function start(e) {
e = e || window.event;
let x = null;
if (!e.touches) {
//兼容PC端
x = e.clientX;
} else {
//兼容移动端
x = e.touches[0].pageX;
}
startLeft.value = Math.floor(x - barArea.value.getBoundingClientRect().left);
startMoveTime.value = +new Date(); //开始滑动的时间
if (isEnd.value == false) {
text.value = '';
moveBlockBackgroundColor.value = '#337ab7';
leftBarBorderColor.value = '#337AB7';
iconColor.value = '#fff';
e.stopPropagation();
status.value = true;
}
}
//鼠标移动
function move(e) {
e = e || window.event;
if (status.value && isEnd.value == false) {
let x = null;
if (!e.touches) {
//兼容PC端
x = e.clientX;
} else {
//兼容移动端
x = e.touches[0].pageX;
}
var bar_area_left = barArea.value.getBoundingClientRect().left;
var move_block_left = x - bar_area_left; //小方块相对于父元素的left值
if (move_block_left >= barArea.value.offsetWidth - parseInt(parseInt(blockSize.value.width) / 2) - 2) {
move_block_left = barArea.value.offsetWidth - parseInt(parseInt(blockSize.value.width) / 2) - 2;
}
if (move_block_left <= 0) {
move_block_left = parseInt(parseInt(blockSize.value.width) / 2);
}
//拖动后小方块的left值
moveBlockLeft.value = move_block_left - startLeft.value + 'px';
leftBarWidth.value = move_block_left - startLeft.value + 'px';
}
}
//鼠标松开
function end() {
endMovetime.value = +new Date();
//判断是否重合
if (status.value && isEnd.value == false) {
var moveLeftDistance = parseInt((moveBlockLeft.value || '').replace('px', ''));
moveLeftDistance = (moveLeftDistance * 310) / parseInt(setSize.imgWidth);
let data = {
captchaType: captchaType.value,
pointJson: secretKey.value
? aesEncrypt(
JSON.stringify({
x: moveLeftDistance,
y: 5.0,
}),
secretKey.value
// eslint-disable-next-line no-mixed-spaces-and-tabs
)
: JSON.stringify({ x: moveLeftDistance, y: 5.0 }),
token: backToken.value,
};
reqCheck(data).then((response) => {
let res = response.data;
if (res.repCode == '0000') {
moveBlockBackgroundColor.value = '#5cb85c';
leftBarBorderColor.value = '#5cb85c';
iconColor.value = '#fff';
iconClass.value = 'icon-check';
showRefresh.value = false;
isEnd.value = true;
if (mode.value == 'pop') {
setTimeout(() => {
proxy.$parent.clickShow = false;
refresh();
}, 1500);
}
passFlag.value = true;
const time = ((endMovetime.value - startMoveTime.value) / 1000).toFixed(2);
tipWords.value = t('verify.slide.time', { time });
var captchaVerification = secretKey.value
? aesEncrypt(
backToken.value +
'---' +
JSON.stringify({
x: moveLeftDistance,
y: 5.0,
}),
secretKey.value
// eslint-disable-next-line no-mixed-spaces-and-tabs
)
: backToken.value + '---' + JSON.stringify({ x: moveLeftDistance, y: 5.0 });
setTimeout(() => {
tipWords.value = '';
proxy.$parent.$parent.closeBox();
proxy.$parent.$parent.$emit('success', { captchaVerification });
}, 1000);
} else {
moveBlockBackgroundColor.value = '#d9534f';
leftBarBorderColor.value = '#d9534f';
iconColor.value = '#fff';
iconClass.value = 'icon-close';
passFlag.value = false;
setTimeout(function () {
refresh();
}, 1000);
proxy.$parent.$emit('error', proxy);
tipWords.value = t('verify.slide.fail');
setTimeout(() => {
tipWords.value = '';
}, 1000);
}
});
status.value = false;
}
}
const refresh = () => {
showRefresh.value = true;
finishText.value = '';
transitionLeft.value = 'left .3s';
moveBlockLeft.value = 0;
leftBarWidth.value = undefined;
transitionWidth.value = 'width .3s';
leftBarBorderColor.value = '#ddd';
moveBlockBackgroundColor.value = '#fff';
iconColor.value = '#000';
iconClass.value = 'icon-right';
isEnd.value = false;
getPictrue();
setTimeout(() => {
transitionWidth.value = '';
transitionLeft.value = '';
text.value = t('verify.slide.explain');
}, 300);
};
// 请求背景图片和验证图片
function getPictrue() {
let data = {
captchaType: captchaType.value,
};
reqGet(data).then((response) => {
let res = response.data;
if (res.repCode == '0000') {
backImgBase.value = res.repData.originalImageBase64;
blockBackImgBase.value = res.repData.jigsawImageBase64;
backToken.value = res.repData.token;
secretKey.value = res.repData.secretKey;
} else {
tipWords.value = res.repMsg;
}
});
}
return {
secretKey, //后端返回的ase加密秘钥
passFlag, //是否通过的标识
backImgBase, //验证码背景图片
blockBackImgBase, //验证滑块的背景图片
backToken, //后端返回的唯一token值
startMoveTime, //移动开始的时间
endMovetime, //移动结束的时间
tipsBackColor, //提示词的背景颜色
tipWords,
text,
finishText,
setSize,
top,
left,
moveBlockLeft,
leftBarWidth,
// 移动中样式
moveBlockBackgroundColor,
leftBarBorderColor,
iconColor,
iconClass,
status, //鼠标状态
isEnd, //是够验证完成
showRefresh,
transitionLeft,
transitionWidth,
barArea,
refresh,
start,
t,
};
},
};
</script>
<style>
/* 现代化阴影效果 */
.modern-shadow {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
border-radius: 8px;
overflow: hidden;
border: none;
}
/* 现代化滑动条样式 */
.modern-bar {
border-radius: 20px !important;
background: #f7f9fc !important;
border: 1px solid #edf2f7 !important;
}
.modern-bar:hover {
border-color: #e2e8f0 !important;
background: #f1f5f9 !important;
}
.verify-bar-area {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.verify-bar-area .verify-move-block {
border-radius: 50% !important;
background: linear-gradient(145deg, #ffffff, #f5f7fa) !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1) !important;
}
.verify-bar-area .verify-move-block:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
.verify-bar-area .verify-left-bar {
border-radius: 0 20px 20px 0 !important;
background: linear-gradient(90deg, #60a5fa20, #60a5fa40) !important;
border: none !important;
}
.verify-img-panel .verify-refresh {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
border-radius: 50%;
width: 32px !important;
height: 32px !important;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin: 8px;
}
.verify-img-panel .verify-refresh:hover {
background: rgba(255, 255, 255, 1);
}
.verify-tips {
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border-radius: 4px;
padding: 4px 12px;
font-size: 14px;
font-weight: 500;
}
.suc-bg {
background: rgba(34, 197, 94, 0.9) !important;
}
.err-bg {
background: rgba(239, 68, 68, 0.9) !important;
}
/* 暗黑模式适配 */
html.dark {
.modern-bar {
background: rgba(30, 41, 59, 0.5) !important;
border-color: rgba(51, 65, 85, 0.5) !important;
}
.verify-move-block {
background: linear-gradient(145deg, #1e293b, #0f172a) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
}
.verify-left-bar {
background: linear-gradient(90deg, rgba(96, 165, 250, 0.1), rgba(96, 165, 250, 0.2)) !important;
}
.verify-refresh {
background: rgba(30, 41, 59, 0.9) !important;
}
}
</style>
+23
View File
@@ -0,0 +1,23 @@
/**
* 此处可直接引用自己项目封装好的 axios 配合后端联调
*/
import request from '/@/utils/request';
//获取验证图片 以及token
export function reqGet(data: Object) {
return request({
url: '/auth/code/create',
method: 'get',
data,
});
}
//滑动或者点选验证
export function reqCheck(data: Object) {
return request({
url: '/auth/code/check',
method: 'post',
params: data,
});
}
+11
View File
@@ -0,0 +1,11 @@
export default {
verify: {
complete: 'Please complete security verification',
slide: {
explain: 'Slide right to verify',
success: 'Verification successful',
fail: 'Verification failed',
time: 'Verified in {time}s'
}
}
};
+11
View File
@@ -0,0 +1,11 @@
export default {
verify: {
complete: '请完成安全验证',
slide: {
explain: '向右滑动完成验证',
success: '验证成功',
fail: '验证失败',
time: '{time}s验证成功'
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import CryptoJS from 'crypto-js';
/**
* @word 要加密的内容
* @keyWord String 服务器随机返回的关键字
* */
export function aesEncrypt(word, keyWord = 'XwKsGlMcdPMEhR1B') {
var key = CryptoJS.enc.Utf8.parse(keyWord);
var srcs = CryptoJS.enc.Utf8.parse(word);
var encrypted = CryptoJS.AES.encrypt(srcs, key, { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 });
return encrypted.toString();
}
+97
View File
@@ -0,0 +1,97 @@
export function resetSize(vm) {
var img_width, img_height, bar_width, bar_height; //图片的宽度、高度,移动条的宽度、高度
var parentWidth = vm.$el.parentNode.offsetWidth || window.offsetWidth;
var parentHeight = vm.$el.parentNode.offsetHeight || window.offsetHeight;
if (vm.imgSize.width.indexOf('%') != -1) {
img_width = (parseInt(vm.imgSize.width) / 100) * parentWidth + 'px';
} else {
img_width = vm.imgSize.width;
}
if (vm.imgSize.height.indexOf('%') != -1) {
img_height = (parseInt(vm.imgSize.height) / 100) * parentHeight + 'px';
} else {
img_height = vm.imgSize.height;
}
if (vm.barSize.width.indexOf('%') != -1) {
bar_width = (parseInt(vm.barSize.width) / 100) * parentWidth + 'px';
} else {
bar_width = vm.barSize.width;
}
if (vm.barSize.height.indexOf('%') != -1) {
bar_height = (parseInt(vm.barSize.height) / 100) * parentHeight + 'px';
} else {
bar_height = vm.barSize.height;
}
return { imgWidth: img_width, imgHeight: img_height, barWidth: bar_width, barHeight: bar_height };
}
export const _code_chars = [
1,
2,
3,
4,
5,
6,
7,
8,
9,
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
];
export const _code_color1 = ['#fffff0', '#f0ffff', '#f0fff0', '#fff0f0'];
export const _code_color2 = ['#FF0033', '#006699', '#993366', '#FF9900', '#66CC66', '#FF33CC'];
+72
View File
@@ -0,0 +1,72 @@
<template>
<div>
<video-play
ref="playerRef"
v-bind="options"
:src="src"
@play="onPlay"
@pause="onPause"
@timeupdate="onTimeupdate"
@canplay="onCanplay"
/>
</div>
</template>
<script setup lang="ts">
import { reactive, shallowRef } from 'vue'
import 'vue3-video-play/dist/style.css'
import VideoPlay from 'vue3-video-play'
const props = defineProps({
src: {
type: String,
required: true
},
width: String,
height: String,
poster: String
})
const playerRef = shallowRef()
const options = reactive({
color: 'var(--el-color-primary)', //主题色
muted: false, //静音
webFullScreen: false,
speedRate: ['0.75', '1.0', '1.25', '1.5', '2.0'], //播放倍速
autoPlay: true, //自动播放
loop: false, //循环播放
mirror: false, //镜像画面
ligthOff: false, //关灯模式
volume: 0.3, //默认音量大小
control: true, //是否显示控制器
title: '', //视频名称
poster: '', //封面
...props
})
const play = () => {
playerRef.value.play()
}
const pause = () => {
playerRef.value.pause()
}
const onPlay = (event: any) => {
console.log(event, '播放')
}
const onPause = (event: any) => {
console.log(event, '暂停')
}
const onTimeupdate = (event: any) => {
console.log(event, '时间更新')
}
const onCanplay = (event: any) => {
console.log(event, '可以播放')
}
defineExpose({
play,
pause
})
</script>
+160
View File
@@ -0,0 +1,160 @@
<template>
<div></div>
</template>
<script setup lang="ts" name="global-websocket">
import { ElNotification } from 'element-plus';
import { Session } from '/@/utils/storage';
import other from "/@/utils/other";
const emit = defineEmits(['rollback']);
const props = defineProps({
uri: {
type: String,
},
});
const state = reactive({
webSocket: ref(), // webSocket实例
lockReconnect: false, // 重连锁,避免多次重连
maxReconnect: 6, // 最大重连次数, -1 标识无限重连
reconnectTime: 0, // 重连尝试次数
heartbeat: {
interval: 30 * 1000, // 心跳间隔时间
timeout: 10 * 1000, // 响应超时时间
pingTimeoutObj: ref(), // 延时发送心跳的定时器
pongTimeoutObj: ref(), // 接收心跳响应的定时器
pingMessage: JSON.stringify({ type: 'ping' }), // 心跳请求信息
},
});
const token = computed(() => {
return Session.getToken();
});
const tenant = computed(() => {
return Session.getTenant();
});
onMounted(() => {
initWebSocket();
});
onUnmounted(() => {
state.webSocket.close();
clearTimeoutObj(state.heartbeat);
});
const initWebSocket = () => {
// ws地址
let host = window.location.host;
// baseURL
let baseURL = import.meta.env.VITE_API_URL;
let wsUri = `ws://${host}${baseURL}${other.adaptationUrl(props.uri)}?access_token=${token.value}&TENANT-ID=${tenant.value}`;
// 建立连接
state.webSocket = new WebSocket(wsUri);
// 连接成功
state.webSocket.onopen = onOpen;
// 连接错误
state.webSocket.onerror = onError;
// 接收信息
state.webSocket.onmessage = onMessage;
// 连接关闭
state.webSocket.onclose = onClose;
};
const reconnect = () => {
if (!token) {
return;
}
if (state.lockReconnect || (state.maxReconnect !== -1 && state.reconnectTime > state.maxReconnect)) {
return;
}
state.lockReconnect = true;
setTimeout(() => {
state.reconnectTime++;
// 建立新连接
initWebSocket();
state.lockReconnect = false;
}, 5000);
};
/**
* 清空定时器
*/
const clearTimeoutObj = (heartbeat: any) => {
heartbeat.pingTimeoutObj && clearTimeout(heartbeat.pingTimeoutObj);
heartbeat.pongTimeoutObj && clearTimeout(heartbeat.pongTimeoutObj);
};
/**
* 开启心跳
*/
const startHeartbeat = () => {
const webSocket = state.webSocket;
const heartbeat = state.heartbeat;
// 清空定时器
clearTimeoutObj(heartbeat);
// 延时发送下一次心跳
heartbeat.pingTimeoutObj = setTimeout(() => {
// 如果连接正常
if (webSocket.readyState === 1) {
//这里发送一个心跳,后端收到后,返回一个心跳消息,
webSocket.send(heartbeat.pingMessage);
// 心跳发送后,如果服务器超时未响应则断开,如果响应了会被重置心跳定时器
heartbeat.pongTimeoutObj = setTimeout(() => {
webSocket.close();
}, heartbeat.timeout);
} else {
// 否则重连
reconnect();
}
}, heartbeat.interval);
};
/**
* 连接成功事件
*/
const onOpen = () => {
//开启心跳
startHeartbeat();
state.reconnectTime = 0;
};
/**
* 连接失败事件
* @param e
*/
const onError = () => {
//重连
reconnect();
};
/**
* 连接关闭事件
* @param e
*/
const onClose = () => {
//重连
reconnect();
};
/**
* 接收服务器推送的信息
* @param msgEvent
*/
const onMessage = (msgEvent: any) => {
//收到服务器信息,心跳重置并发送
startHeartbeat();
const text = msgEvent.data;
if (text.indexOf('pong') > 0) {
return;
}
ElNotification.warning({
title: '消息提醒',
dangerouslyUseHTMLString: true,
message: text + '请及时处理',
offset: 60,
});
emit('rollback', text);
};
</script>
@@ -0,0 +1,95 @@
<template>
<el-upload
ref="fileUpload"
:action="actionUrl"
:headers="headers"
multiple
:limit="1"
:on-success="handleUploadSuccess"
:file-list="fileList"
:before-upload="beforeThumbImageUpload"
:auto-upload="autoUpload"
:data="uploadData"
>
<template #tip>
<div class="el-upload__tip" v-if="props.type.length > 0">支持{{ props.type.join('/') }}格式,大小不超过2M</div>
</template>
<el-button type="primary">本地上传</el-button>
</el-upload>
</template>
<script setup lang="ts" name="wx-file-upload">
import { Session } from '/@/utils/storage';
import { useMessage } from '/@/hooks/message';
const actionUrl = ref('/admin/wx-material/materialFileUpload');
const fileUpload = ref();
const headers = computed(() => {
const tenantId = Session.getTenant();
return {
Authorization: 'Bearer ' + Session.getToken(),
'TENANT-ID': tenantId,
};
});
// 定义刷新表格emit
const emit = defineEmits(['success']);
const fileList = ref([]);
const props = defineProps({
uploadData: {
type: Object,
default: () => {
return {
appId: '',
mediaType: 'image',
title: '',
introduction: '',
};
},
},
autoUpload: {
type: Boolean,
default: true,
},
type: {
type: Array,
default: () => {
return [];
},
},
});
const beforeThumbImageUpload = (file: any) => {
let isType = true;
if (props.type?.length > 0) {
isType = props.type?.includes(file.type);
}
const isLt = file.size / 1024 / 1024 < 2;
if (!isType) {
useMessage().error('上传文件格式不对!');
}
if (!isLt) {
useMessage().error('上传文件大小不能超过2M!!');
}
return isType && isLt;
};
const handleUploadSuccess = (response, file, fileList) => {
fileList.value = [];
emit('success', response, file, fileList);
};
const submit = () => {
return new Promise((resolve) => {
fileUpload.value.submit();
resolve('');
});
};
defineExpose({
submit,
});
</script>
<style scoped></style>
@@ -0,0 +1,166 @@
<template>
<el-dialog title="选择图文" v-model="visible" :close-on-click-modal="false" draggable width="80%">
<div v-if="objData.type === 'image'">
<div class="waterfall" v-loading="state.loading">
<div class="waterfall-item" v-for="item in state.dataList" :key="item.mediaId">
<img class="material-img" :src="item.url" />
<p class="item-name">{{ item.name }}</p>
<el-row class="ope-row">
<el-button type="success" @click="selectMaterial(item)"
>选择
<el-icon class="el-icon--right"></el-icon>
</el-button>
</el-row>
</div>
</div>
<pagination v-bind="state.pagination" @size-change="sizeChangeHandle" @current-change="currentChangeHandle" />
</div>
<div v-else-if="objData.type === 'voice'">
<!-- 列表 -->
<el-table v-loading="state.loading" :data="state.dataList">
<el-table-column label="编号" align="center" prop="mediaId" />
<el-table-column label="文件名" align="center" prop="name" />
<el-table-column label="语音" align="center" prop="url"> </el-table-column>
<el-table-column label="上传时间" align="center" prop="createTime" width="180">
<template v-slot="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" fixed="right" class-name="small-padding fixed-width">
<template v-slot="scope">
<el-button icon="el-icon-circle-plus" @click="selectMaterial(scope.row)">选择</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-bind="state.pagination" @size-change="sizeChangeHandle" @current-change="currentChangeHandle" />
</div>
<div v-else-if="objData.type === 'video'">
<!-- 列表 -->
<el-table v-loading="state.loading" :data="state.dataList">
<el-table-column label="编号" align="center" prop="mediaId" />
<el-table-column label="文件名" align="center" prop="name" />
<el-table-column label="标题" align="center" prop="title" />
<el-table-column label="介绍" align="center" prop="introduction" />
<el-table-column label="视频" align="center" prop="url"> </el-table-column>
<el-table-column label="上传时间" align="center" prop="createTime" width="180">
<template v-slot="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" fixed="right" class-name="small-padding fixed-width">
<template v-slot="scope">
<el-button @click="selectMaterial(scope.row)">选择</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-bind="state.pagination" @size-change="sizeChangeHandle" @current-change="currentChangeHandle" />
</div>
<div v-else-if="objData.type === 'news'">
<div class="waterfall" v-loading="state.loading">
<template v-for="item in state.dataList">
<div v-if="item.content && item.content.newsItem" class="waterfall-item" :key="item.id">
<wx-news :obj-data="item.content.newsItem"></wx-news>
<el-row class="ope-row">
<el-button type="success" @click="selectMaterial(item)"> 选择<el-icon class="el-icon--right" /> </el-button>
</el-row>
</div>
</template>
</div>
<pagination v-bind="state.pagination" @size-change="sizeChangeHandle" @current-change="currentChangeHandle" />
</div>
</el-dialog>
</template>
<script setup lang="ts" name="wx-material-select">
import { BasicTableProps, useTable } from '/@/hooks/table';
import { getPage } from '/@/api/mp/wx-material';
const WxNews = defineAsyncComponent(() => import('../wx-news/index.vue'));
const emit = defineEmits(['selectMaterial']);
const objData = reactive({
repType: '',
accountId: '',
type: '',
});
const visible = ref(false);
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {
type: '',
appId: '',
},
pageList: getPage,
createdIsNeed: false,
props: {
item: 'items',
totalCount: 'totalCount',
},
});
const { getDataList, currentChangeHandle, sizeChangeHandle } = useTable(state);
const selectMaterial = (item: any) => {
visible.value = false;
emit('selectMaterial', item, objData.accountId);
};
const openDialog = (data: any) => {
state.queryForm.type = data.type;
state.queryForm.appId = data.accountId;
objData.type = data.type;
objData.accountId = data.accountId;
visible.value = true;
getDataList();
};
// 暴露变量
defineExpose({
openDialog,
});
</script>
<style lang="scss" scoped>
/*瀑布流样式*/
.waterfall {
width: 100%;
column-gap: 10px;
column-count: 5;
margin: 0 auto;
}
.waterfall-item {
padding: 10px;
margin-bottom: 10px;
break-inside: avoid;
border: 1px solid #eaeaea;
}
.material-img {
width: 100%;
}
p {
line-height: 30px;
}
@media (min-width: 992px) and (max-width: 1300px) {
.waterfall {
column-count: 3;
}
p {
color: red;
}
}
@media (min-width: 768px) and (max-width: 991px) {
.waterfall {
column-count: 2;
}
p {
color: orange;
}
}
@media (max-width: 767px) {
.waterfall {
column-count: 1;
}
}
/*瀑布流样式*/
</style>
+101
View File
@@ -0,0 +1,101 @@
.avue-card {
&__item {
margin-bottom: 16px;
border: 1px solid #e8e8e8;
background-color: #fff;
box-sizing: border-box;
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5;
list-style: none;
font-feature-settings: 'tnum';
cursor: pointer;
height: 200px;
&:hover {
border-color: rgba(0, 0, 0, 0.09);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
}
&--add {
border: 1px dashed #000;
width: 100%;
color: rgba(0, 0, 0, 0.45);
background-color: #fff;
border-color: #d9d9d9;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
i {
margin-right: 10px;
}
&:hover {
color: #40a9ff;
background-color: #fff;
border-color: #40a9ff;
}
}
}
&__body {
display: flex;
padding: 24px;
}
&__detail {
flex: 1;
}
&__avatar {
width: 48px;
height: 48px;
border-radius: 48px;
overflow: hidden;
margin-right: 12px;
img {
width: 100%;
height: 100%;
}
}
&__title {
color: rgba(0, 0, 0, 0.85);
margin-bottom: 12px;
font-size: 16px;
&:hover {
color: #1890ff;
}
}
&__info {
color: rgba(0, 0, 0, 0.45);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
height: 64px;
}
&__menu {
display: flex;
justify-content: space-around;
height: 50px;
background: #f7f9fa;
color: rgba(0, 0, 0, 0.45);
text-align: center;
line-height: 50px;
&:hover {
color: #1890ff;
}
}
}
/** joolun 额外加的 */
.avue-comment__main {
flex: unset !important;
border-radius: 5px !important;
margin: 0 8px !important;
}
.avue-comment__header {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.avue-comment__body {
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
}
+92
View File
@@ -0,0 +1,92 @@
/* 来自 https://github.com/nmxiaowei/avue/blob/master/styles/src/element-ui/comment.scss */
.avue-comment {
margin-bottom: 30px;
display: flex;
align-items: flex-start;
&--reverse {
flex-direction: row-reverse;
.avue-comment__main {
&:before,
&:after {
left: auto;
right: -8px;
border-width: 8px 0 8px 8px;
}
&:before {
border-left-color: #dedede;
}
&:after {
border-left-color: #f8f8f8;
margin-right: 1px;
margin-left: auto;
}
}
}
&__avatar {
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid transparent;
box-sizing: border-box;
vertical-align: middle;
}
&__header {
padding: 5px 15px;
background: #f8f8f8;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
}
&__author {
font-weight: 700;
font-size: 14px;
color: #999;
}
&__main {
flex: 1;
margin: 0 20px;
position: relative;
border: 1px solid #dedede;
border-radius: 2px;
&:before,
&:after {
position: absolute;
top: 10px;
left: -8px;
right: 100%;
width: 0;
height: 0;
display: block;
content: ' ';
border-color: transparent;
border-style: solid solid outset;
border-width: 8px 8px 8px 0;
pointer-events: none;
}
&:before {
border-right-color: #dedede;
z-index: 1;
}
&:after {
border-right-color: #f8f8f8;
margin-left: 1px;
z-index: 2;
}
}
&__body {
padding: 15px;
overflow: hidden;
background: #fff;
font-family: Segoe UI, Lucida Grande, Helvetica, Arial, Microsoft YaHei, FreeSans, Arimo, Droid Sans, wenquanyi micro hei, Hiragino Sans GB,
Hiragino Sans GB W3, FontAwesome, sans-serif;
color: #333;
font-size: 14px;
}
blockquote {
margin: 0;
font-family: Georgia, Times New Roman, Times, Kai, Kaiti SC, KaiTi, BiauKai, FontAwesome, serif;
padding: 1px 0 1px 15px;
border-left: 4px solid #ddd;
}
}
+258
View File
@@ -0,0 +1,258 @@
<template>
<el-dialog title="用户消息" v-model="visible" :close-on-click-modal="false" draggable>
<div v-loading="mainLoading" class="msg-main">
<div id="msg-div" class="msg-div">
<div v-if="!tableLoading">
<div v-if="loadMore" class="el-table__empty-block" @click="loadingMore"><span class="el-table__empty-text">点击加载更多</span></div>
<div v-if="!loadMore" class="el-table__empty-block"><span class="el-table__empty-text">没有更多了</span></div>
</div>
<div v-for="item in tableData" :key="item.id" class="execution" id="msgTable">
<div class="avue-comment" :class="item.type === '2' ? 'avue-comment--reverse' : ''">
<div class="avatar-div">
<name-avatar v-if="item.type === '1'" scale="2" :name="item.nickName" />
<name-avatar v-if="item.type !== '1'" scale="2" :face-url="item.appLogo" />
</div>
<div class="avue-comment__main">
<div class="avue-comment__header">
<div class="avue-comment__create_time">{{ item.createTime }}</div>
</div>
<div class="avue-comment__body" :style="item.type === '2' ? 'background: #6BED72;' : ''">
<div v-if="item.repType === 'event' && item.repEvent === 'subscribe'">
<el-tag type="success" size="mini">关注</el-tag>
</div>
<div v-if="item.repType === 'event' && item.repEvent === 'unsubscribe'">
<el-tag type="danger" size="mini">取消关注</el-tag>
</div>
<div v-if="item.repType === 'event' && item.repEvent === 'CLICK'">
<el-tag size="mini">点击菜单</el-tag>
:【{{ item.repName }}】
</div>
<div v-if="item.repType === 'event' && item.repEvent === 'VIEW'">
<el-tag size="mini">点击菜单链接</el-tag>
:【{{ item.repUrl }}】
</div>
<div v-if="item.repType === 'event' && item.repEvent === 'scancode_waitmsg'">
<el-tag size="mini">扫码结果:</el-tag>
:【{{ item.repContent }}】
</div>
<div v-if="item.repType === 'text'">{{ item.repContent }}</div>
<div v-if="item.repType === 'image'">
<a target="_blank" :href="item.repUrl"><img :src="item.repUrl" style="width: 100px" /></a>
</div>
<div v-if="item.repType === 'voice'">
<SvgIcon name="local-wx-voice" :size="80" @click="loadVideo(item)"></SvgIcon>
</div>
<div v-if="item.repType === 'video'" style="text-align: center">
<SvgIcon name="local-wx-video" :size="80" @click="loadVideo(item)"></SvgIcon>
</div>
<div v-if="item.repType === 'shortvideo'" style="text-align: center">
<svg-icon name="local-wx-video" :size="80" @click="loadVideo(item)"></svg-icon>
</div>
<div v-if="item.repType === 'location'">
<el-link
type="primary"
target="_blank"
:href="
'https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=' +
item.repLocationY +
'&pointy=' +
item.repLocationX +
'&name=' +
item.repContent +
'&ref=joolun'
"
>
<img
:src="
'https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|' +
item.repLocationX +
',' +
item.repLocationY +
'&key=PFFBZ-RBM3V-IEEPP-UH6KE-6QUQE-C4BVJ&size=250*180'
"
/>
<p />
<i class="el-icon-map-location"></i>{{ item.repContent }}
</el-link>
</div>
<div v-if="item.repType === 'link'" class="avue-card__detail">
<el-link type="success" :underline="false" target="_blank" :href="item.repUrl">
<div class="avue-card__title"><i class="el-icon-link"></i>{{ item.repName }}</div>
</el-link>
<div class="avue-card__info" style="height: unset">{{ item.repDesc }}</div>
</div>
<div v-if="item.repType === 'news'" style="width: 300px">
<wx-news :obj-data="JSON.parse(item.content)"></wx-news>
</div>
<div v-if="item.repType === 'music'">
<el-link type="success" :underline="false" target="_blank" :href="item.repUrl">
<div class="avue-card__body" style="padding: 10px; background-color: #fff; border-radius: 5px">
<div class="avue-card__avatar"><img :src="item.repThumbUrl" alt="" /></div>
<div class="avue-card__detail">
<div class="avue-card__title" style="margin-bottom: unset">{{ item.repName }}</div>
<div class="avue-card__info" style="height: unset">{{ item.repDesc }}</div>
</div>
</div>
</el-link>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-loading="sendLoading" class="msg-send" @keyup.enter="sendMsg">
<wx-reply :objData="objData"></wx-reply>
<el-button type="success" class="send-but" @click="sendMsg">发送(S)</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts" name="wx-msg">
import { fetchList, addObj } from '/@/api/mp/wx-fans-msg';
import { useMessage } from '/@/hooks/message';
import { getMaterialVideo } from '/@/api/mp/wx-material';
const WxReply = defineAsyncComponent(() => import('../wx-reply/index.vue'));
const WxNews = defineAsyncComponent(() => import('../wx-news/index.vue'));
const NameAvatar = defineAsyncComponent(() => import('/@/components/NameAvatar/index.vue'));
const visible = ref(false);
// 各种loading
const mainLoading = ref(false);
const sendLoading = ref(false);
const loadMore = ref(false);
const objData = ref({
repType: 'text',
appId: '',
}) as any;
const page = reactive({
total: 0, // 总页数
currentPage: 1, // 当前页数
pageSize: 10, // 每页显示多少条
ascs: [], //升序字段
descs: 'create_time', //降序字段
});
const wxData = reactive({
appId: '',
wxUserId: '',
});
const sendMsg = () => {
if (objData.value) {
if (objData.value.repType === 'news') {
if (JSON.parse(objData.value.content).length > 1) {
useMessage().error('图文消息条数限制在1条以内,已默认发送第一条');
objData.value.content = JSON.parse(objData.value.content)[0];
}
}
sendLoading.value = true;
addObj(
Object.assign(
{
wxUserId: wxData.wxUserId,
appId: wxData.appId,
},
objData.value
)
)
.then(() => {
tableData.value = [];
getData().then(() => {
//box-container是添加overflow的父div,也就是出现滚动条的div
var scrollTarget = document.getElementById('msg-div');
//scrollTarget.scrollHeight是获取dom元素的高度,然后设置scrollTop
scrollTarget.scrollTop = scrollTarget.scrollHeight;
});
})
.finally(() => {
sendLoading.value = false;
});
}
};
const tableData = ref([] as any);
const tableLoading = ref(false);
const openDialog = (data: any) => {
wxData.wxUserId = data.wxUserId;
wxData.appId = data.appId;
objData.value.appId = data.appId;
getData();
visible.value = true;
};
const getData = () => {
tableLoading.value = true;
return fetchList({
...page,
...wxData,
}).then((res) => {
const data = res.data.records.reverse();
tableData.value = [...data, ...tableData.value];
page.total = res.data.total;
tableLoading.value = false;
if (data.length < page.pageSize || data.length === 0) {
loadMore.value = false;
}
});
};
const loadVideo = (item) => {
getMaterialVideo({
mediaId: item.repMediaId,
appId: item.appId,
}).then((response) => {
const data = response.data;
window.open(data.downUrl, 'target', '');
});
};
const loadingMore = () => {
page.currentPage = page.currentPage + 1;
getData();
};
// 暴露变量
defineExpose({
openDialog,
});
</script>
<style lang="scss" scoped>
@import './comment.scss';
@import './card.scss';
.msg-main {
margin-top: -30px;
padding: 10px;
}
.msg-div {
height: 50vh;
overflow: auto;
background-color: #eaeaea;
margin-left: 10px;
margin-right: 10px;
}
.msg-send {
padding: 10px;
}
.avatar-div {
text-align: center;
width: 80px;
}
.send-but {
float: right;
margin-top: 8px !important;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<template>
<div class="news-home">
<div v-for="(news, index) in props.objData" :key="index" class="news-div">
<a v-if="index === 0" target="_blank" :href="news.url">
<div class="news-main">
<div class="news-content">
<img class="material-img" :src="news.thumbUrl" style="width: 280; height: 120" />
<div class="news-content-title">
<span>{{ news.title }}</span>
</div>
</div>
</div>
</a>
<a v-if="index > 0" target="_blank" :href="news.url">
<div class="news-main-item">
<div class="news-content-item">
<div class="news-content-item-title">{{ news.title }}</div>
<div class="news-content-item-img">
<img class="material-img" :src="news.thumbUrl" height="100%" />
</div>
</div>
</div>
</a>
</div>
</div>
</template>
<script setup lang="ts" name="wx-news">
const props = defineProps({
objData: {
type: Array,
default: () => [],
},
});
</script>
<style lang="scss" scoped>
.news-home {
background-color: #ffffff;
width: 100%;
margin: auto;
}
.news-main {
width: 100%;
margin: auto;
}
.news-content {
background-color: #acadae;
width: 100%;
position: relative;
}
.news-content-title {
display: inline-block;
font-size: 12px;
color: #ffffff;
position: absolute;
left: 0px;
bottom: 0px;
background-color: black;
width: 98%;
padding: 1%;
opacity: 0.65;
white-space: normal;
box-sizing: unset !important;
}
.news-main-item {
background-color: #ffffff;
padding: 5px 0px;
border-top: 1px solid #eaeaea;
}
.news-content-item {
position: relative;
}
.news-content-item-title {
display: inline-block;
font-size: 10px;
width: 70%;
margin-left: 1%;
white-space: normal;
}
.news-content-item-img {
display: inline-block;
width: 25%;
background-color: #acadae;
margin-right: 1%;
}
.material-img {
width: 100%;
}
</style>
+309
View File
@@ -0,0 +1,309 @@
<template>
<el-tabs v-model="objData.repType" type="border-card" @tab-click="handleClick" style="width: 100%">
<el-tab-pane name="text" label="text">
<template #label><i class="el-icon-document"></i> 文本</template>
<el-input v-model="objData.repContent" type="textarea" :rows="5" placeholder="请输入内容"> </el-input>
</el-tab-pane>
<el-tab-pane name="image" label="image">
<template #label><i class="el-icon-picture"></i> 图片</template>
<el-row>
<div v-if="objData.repUrl" class="select-item">
<img class="material-img" :src="objData.repUrl" />
<p v-if="objData.repName" class="item-name">{{ objData.repName }}</p>
<el-row class="ope-row">
<el-button type="danger" icon="el-icon-delete" circle @click="deleteObj"></el-button>
</el-row>
</div>
<div v-if="!objData.repUrl" style="width: 100%">
<el-row style="text-align: center">
<el-col :span="12" class="col-select">
<el-button type="success" @click="openMaterial({ type: 'image', accountId: props.objData.appId })"
>素材库选择<i class="fansel-icon--right"></i>
</el-button>
</el-col>
<el-col :span="12" class="col-add">
<wx-file-upload :data="uploadData" @success="handelUpload"></wx-file-upload>
</el-col>
</el-row>
</div>
</el-row>
</el-tab-pane>
<el-tab-pane name="voice" label="voice">
<template #label><i class="el-icon-phone"></i> 语音</template>
<el-row>
<div v-if="objData.repName" class="select-item">
<p class="item-name">{{ objData.repName }}</p>
<div class="item-infos">
<img :src="WxVoice" style="width: 100px" @click="loadVideo(item)" />
</div>
<el-row class="ope-row">
<el-button type="danger" icon="el-icon-delete" circle @click="deleteObj"></el-button>
</el-row>
</div>
<div v-if="!objData.repName" style="width: 100%">
<el-row style="text-align: center">
<el-col :span="12" class="col-select">
<el-button type="success" @click="openMaterial({ type: 'voice', accountId: props.objData.appId })"
>素材库选择<i class="fansel-icon--right"></i>
</el-button>
</el-col>
<el-col :span="12" class="col-add">
<wx-file-upload :data="uploadData" @success="handelUpload"></wx-file-upload>
</el-col>
</el-row>
</div>
</el-row>
</el-tab-pane>
<el-tab-pane name="video" label="video">
<template #label><i class="el-icon-share"></i> 视频</template>
<el-row style="text-align: center; flex: 1">
<el-input v-if="objData.repUrl" v-model="objData.repName" placeholder="请输入标题" style="margin: 10px"></el-input>
<el-input v-if="objData.repUrl" v-model="objData.repDesc" placeholder="请输入描述" style="margin: 10px"></el-input>
</el-row>
<el-row style="text-align: center">
<el-col :span="12" class="col-select">
<a v-if="objData.repUrl" target="_blank" :href="objData.repUrl"> <SvgIcon name="local-wx-video" :size="45" /> </a
></el-col>
<el-col :span="12" class="col-add">
<el-button type="success" @click="openMaterial({ type: 'video', accountId: props.objData.appId })">素材库选择 </el-button>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane name="news" label="news">
<template #label><i class="el-icon-news"></i> 图文</template>
<el-row>
<div v-if="objData.content" class="select-item">
<wx-news :obj-data="JSON.parse(objData.content)"></wx-news>
<el-row class="ope-row">
<el-button type="danger" icon="el-icon-delete" circle @click="deleteObj"></el-button>
</el-row>
</div>
<div v-if="!objData.content" style="width: 100%">
<el-row style="text-align: center">
<el-col :span="24" class="col-select2">
<el-button type="success" @click="openMaterial({ type: 'news', accountId: props.objData.appId })"
>素材库选择<i class="fansel-icon--right"></i>
</el-button>
</el-col>
</el-row>
</div>
</el-row>
</el-tab-pane>
</el-tabs>
<wx-material-select ref="dialogNewsRef" @selectMaterial="selectMaterial"></wx-material-select>
</template>
<script setup lang="ts" name="wx-reply">
import { getMaterialVideo } from '/@/api/mp/wx-material';
import { useMessage } from '/@/hooks/message';
const WxMaterialSelect = defineAsyncComponent(() => import('/@/components/Wechat/wx-material-select/main.vue'));
const WxFileUpload = defineAsyncComponent(() => import('/@/components/Wechat/fileUpload/index.vue'));
const WxNews = defineAsyncComponent(() => import('/@/components/Wechat/wx-news/index.vue'));
const props = defineProps({
objData: {
type: Object,
default: () => {
return {
repType: '',
repContent: '',
repName: '',
repDesc: '',
repUrl: '',
};
},
},
});
const uploadData = reactive({
mediaType: props.objData.repType,
title: '',
introduction: '',
appId: props.objData.appId,
});
const tempObj = ref({}) as any;
const handleClick = (tab) => {
uploadData.mediaType = tab.paneName;
uploadData.appId = props.objData.appId;
const tempObjItem = tempObj.value[tab.paneName];
if (tempObjItem) {
props.objData.repName = tempObjItem.repName ? tempObjItem.repName : null;
props.objData.repMediaId = tempObjItem.repMediaId ? tempObjItem.repMediaId : null;
props.objData.media_id = tempObjItem.media_id ? tempObjItem.media_id : null;
props.objData.repUrl = tempObjItem.repUrl ? tempObjItem.repUrl : null;
props.objData.content = tempObjItem.content ? tempObjItem.content : null;
props.objData.repDesc = tempObjItem.repDesc ? tempObjItem.repDesc : null;
} else {
props.objData.repName = '';
props.objData.repMediaId = '';
props.objData.media_id = '';
props.objData.repUrl = '';
props.objData.content = '';
props.objData.repDesc = '';
}
};
const deleteObj = () => {
props.objData.repName = '';
props.objData.repUrl = '';
props.objData.content = '';
};
const openMaterial = (data: any) => {
dialogNewsRef.value.openDialog(data);
};
const dialogNewsRef = ref();
const selectMaterial = (item, appId) => {
let tempObjItem = {
repType: '',
repMediaId: '',
media_id: '',
content: '',
} as any;
tempObjItem.repType = props.objData.repType;
tempObjItem.repMediaId = item.mediaId;
tempObjItem.media_id = item.mediaId;
tempObjItem.content = item.content;
props.objData.repMediaId = item.mediaId;
props.objData.media_id = item.mediaId;
props.objData.content = item.content;
if (props.objData.repType === 'music') {
tempObjItem.repThumbMediaId = item.mediaId;
tempObjItem.repThumbUrl = item.url;
props.objData.repThumbMediaId = item.mediaId;
props.objData.repThumbUrl = item.url;
} else {
tempObjItem.repName = item.name;
tempObjItem.repUrl = item.url;
props.objData.repName = item.name;
props.objData.repUrl = item.url;
}
if (props.objData.repType === 'video') {
getMaterialVideo({
mediaId: item.mediaId,
appId: appId,
}).then((response) => {
const data = response.data;
tempObjItem.repDesc = data.description || '';
tempObjItem.repUrl = data.downUrl;
props.objData.repName = data.title;
props.objData.repDesc = data.description || '';
props.objData.repUrl = data.downUrl;
});
}
if (props.objData.repType === 'news') {
props.objData.content = JSON.stringify(item.content.newsItem);
}
tempObj.value[props.objData.repType] = tempObjItem;
};
const handelUpload = (response) => {
if (response.code === 0) {
const item = response.data;
selectMaterial(item, props.objData.appId);
} else {
useMessage().error('上传错误' + response.msg);
}
};
const loadVideo = (item) => {
getMaterialVideo({
mediaId: item.repMediaId,
appId: item.appId,
}).then((response) => {
const data = response.data;
window.open(data.downUrl, 'target', '');
});
};
</script>
<style scoped lang="scss">
.select-item {
width: 280px;
padding: 10px;
margin: 0 auto 10px auto;
border: 1px solid #eaeaea;
}
.select-item2 {
padding: 10px;
margin: 0 auto 10px auto;
border: 1px solid #eaeaea;
}
.ope-row {
padding-top: 10px;
text-align: center;
}
.item-name {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.el-form-item__content {
line-height: unset !important;
}
.col-select {
border: 1px solid rgb(234, 234, 234);
padding: 50px 0px;
height: 160px;
width: 49.5%;
}
.col-select2 {
border: 1px solid rgb(234, 234, 234);
padding: 50px 0px;
height: 160px;
}
.col-add {
border: 1px solid rgb(234, 234, 234);
padding: 50px 0px;
height: 160px;
width: 49.5%;
float: right;
}
.avatar-uploader-icon {
border: 1px solid #d9d9d9;
font-size: 28px;
color: #8c939d;
width: 100px !important;
height: 100px !important;
line-height: 100px !important;
text-align: center;
}
.material-img {
width: 100%;
}
.thumb-div {
display: inline-block;
text-align: center;
}
.item-infos {
width: 30%;
margin: auto;
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<template>
<slot v-if="getUserAuthBtnList" />
</template>
<script setup lang="ts" name="auth">
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useUserInfo } from '/@/stores/userInfo';
// 定义父组件传过来的值
const props = defineProps({
value: {
type: String,
default: () => '',
},
});
// 定义变量内容
const stores = useUserInfo();
const { userInfos } = storeToRefs(stores);
// 获取 pinia 中的用户权限
const getUserAuthBtnList = computed(() => {
return userInfos.value.authBtnList.some((v: string) => v === props.value);
});
</script>
+27
View File
@@ -0,0 +1,27 @@
<template>
<slot v-if="getUserAuthBtnList" />
</template>
<script setup lang="ts" name="authAll">
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useUserInfo } from '/@/stores/userInfo';
import { judementSameArr } from '/@/utils/arrayOperation';
// 定义父组件传过来的值
const props = defineProps({
value: {
type: Array,
default: () => [],
},
});
// 定义变量内容
const stores = useUserInfo();
const { userInfos } = storeToRefs(stores);
// 获取 pinia 中的用户权限
const getUserAuthBtnList = computed(() => {
return judementSameArr(props.value, userInfos.value.authBtnList);
});
</script>
+32
View File
@@ -0,0 +1,32 @@
<template>
<slot v-if="getUserAuthBtnList" />
</template>
<script setup lang="ts" name="auths">
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useUserInfo } from '/@/stores/userInfo';
// 定义父组件传过来的值
const props = defineProps({
value: {
type: Array,
default: () => [],
},
});
// 定义变量内容
const stores = useUserInfo();
const { userInfos } = storeToRefs(stores);
// 获取 pinia 中的用户权限
const getUserAuthBtnList = computed(() => {
let flag = false;
userInfos.value.authBtnList.map((val: string) => {
props.value.map((v) => {
if (val === v) flag = true;
});
});
return flag;
});
</script>
+60
View File
@@ -0,0 +1,60 @@
import Pagination from '/@/components/Pagination/index.vue';
import RightToolbar from '/@/components/RightToolbar/index.vue';
import DictTag from '/@/components/DictTag/index.vue';
import UploadExcel from '/@/components/Upload/Excel.vue';
import UploadFile from '/@/components/Upload/index.vue';
import UploadImg from '/@/components/Upload/Image.vue';
import DelWrap from '/@/components/DelWrap/index.vue';
import Editor from '/@/components/Editor/index.vue';
import Tip from '/@/components/Tip/index.vue';
import TagList from '/@/components/TagList/index.vue';
import SvgIcon from '/@/components/SvgIcon/index.vue';
// 第三方组件
import ElementPlus from 'element-plus';
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css';
import {Pane, Splitpanes} from 'splitpanes';
import 'splitpanes/dist/splitpanes.css';
// 日历组件
import {setupCalendar} from 'v-calendar';
// @ts-ignore 引入VForm3库
import VForm3 from 'form-designer-plus'; //
import 'form-designer-plus/dist/designer.style.css'; //引入VForm3样式
// 部门树组件
import vue3TreeOrg from 'vue3-tree-org';
import 'vue3-tree-org/lib/vue3-tree-org.css';
// 导入声明
import {App} from "vue";
export default {
install(app: App) {
app.component('DictTag', DictTag);
app.component('Pagination', Pagination);
app.component('RightToolbar', RightToolbar);
app.component('uploadExcel', UploadExcel);
app.component('UploadFile', UploadFile);
app.component('UploadImg', UploadImg);
app.component('Editor', Editor);
app.component('Tip', Tip);
app.component('DelWrap', DelWrap);
app.component('TagList', TagList);
app.component('SvgIcon', SvgIcon);
// 导入全部的elmenet-plus的图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
// 兼容性
app.component(`ele-${key}`, component)
}
// 导入布局插件
app.component('Splitpanes', Splitpanes);
app.component('Pane', Pane);
app.use(ElementPlus) // ELEMENT 组件
app.use(setupCalendar, {}) // 日历组件
app.use(VForm3) // 表单设计
app.use(vue3TreeOrg) // 组织架构组件
}
}