first commit
This commit is contained in:
9
src/components/Chat/chat.ts
Normal file
9
src/components/Chat/chat.ts
Normal 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},
|
||||
});
|
||||
};
|
||||
16
src/components/Chat/i18n/en.ts
Normal file
16
src/components/Chat/i18n/en.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
chat: {
|
||||
send: 'Send',
|
||||
inputPlaceholder: 'Type a message...',
|
||||
title: 'AI Assistant',
|
||||
clearChat: 'Clear Chat',
|
||||
webSearchEnabled: 'Web Search Enabled',
|
||||
webSearchDisabled: 'Web Search Disabled',
|
||||
welcome: 'Hello! I am a general AI model. How can I help you?',
|
||||
thinking: 'Thinking...',
|
||||
thinkingCompleted: 'Thinking Completed',
|
||||
thinkingTime: 'Time taken',
|
||||
connectionError: 'Connection lost, please try again',
|
||||
seconds: 'seconds',
|
||||
},
|
||||
};
|
||||
16
src/components/Chat/i18n/zh-cn.ts
Normal file
16
src/components/Chat/i18n/zh-cn.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
chat: {
|
||||
send: '发送',
|
||||
inputPlaceholder: '请输入消息...',
|
||||
title: 'AI 助手',
|
||||
clearChat: '清空会话',
|
||||
webSearchEnabled: '已开启联网搜索',
|
||||
webSearchDisabled: '已关闭联网搜索',
|
||||
welcome: '您好!我是通用大模型,请问有什么可以帮助您?',
|
||||
thinking: '正在思考...',
|
||||
thinkingCompleted: '已完成思考',
|
||||
thinkingTime: '用时',
|
||||
connectionError: '连接已断开,请重试',
|
||||
seconds: '秒',
|
||||
},
|
||||
};
|
||||
464
src/components/Chat/index.vue
Normal file
464
src/components/Chat/index.vue
Normal file
@@ -0,0 +1,464 @@
|
||||
<template>
|
||||
<div class="z-[1000]" v-if="getThemeConfig.isChat">
|
||||
<button
|
||||
id="open-chat"
|
||||
@click="chatContainer = !chatContainer"
|
||||
class="inline-flex fixed right-4 bottom-4 justify-center items-center p-0 m-0 mr-4 mb-16 w-16 h-16 text-sm font-medium leading-5 normal-case bg-none rounded-full border border-gray-200 cursor-pointer dark:border-gray-600 bg-primary dark:bg-gray-700 hover:bg-gray-700 dark:hover:bg-gray-600 disabled:pointer-events-none disabled:opacity-50"
|
||||
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="block text-white align-middle"
|
||||
>
|
||||
<path d="m3 21 1.9-5.7a8.5 8.5 0 1 1 3.8 3.8z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-show="chatContainer" class="fixed bottom-24 right-4 w-[600px] mb-12">
|
||||
<div class="w-full bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||
<div
|
||||
class="flex justify-between items-center p-4 text-white rounded-t-lg border-b border-gray-200 dark:border-gray-600 bg-primary dark:bg-gray-700"
|
||||
>
|
||||
<p class="text-lg font-semibold">{{ t('chat.title') }}</p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="clearChat"
|
||||
class="flex justify-center items-center w-6 h-6 text-gray-300 focus:outline-none hover:text-gray-400 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
:title="t('chat.clearChat')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="chatContainer = false"
|
||||
class="flex justify-center items-center w-6 h-6 text-gray-300 focus:outline-none hover:text-gray-400 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" 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>
|
||||
<div class="h-[650px] overflow-y-auto p-4 space-y-4" ref="chatboxRef">
|
||||
<!-- Messages -->
|
||||
<div v-for="(message, index) in messageList" :key="index" class="space-y-2">
|
||||
<!-- User Message -->
|
||||
<div v-if="message.inputMessage" class="flex justify-end">
|
||||
<div class="bg-primary text-white rounded-lg py-2 px-4 max-w-[80%]">
|
||||
<p>{{ message.inputMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Response -->
|
||||
<div v-if="message.botMessage || message.reasoningChain || message.isTyping" class="flex flex-col space-y-2">
|
||||
<!-- Reasoning Chain (Collapsible) -->
|
||||
<div v-if="message.reasoningChain" class="overflow-hidden bg-gray-100 rounded-lg dark:bg-gray-700">
|
||||
<div
|
||||
class="flex justify-between items-center p-2 transition-colors cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
@click="toggleReasoning(index)"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg
|
||||
:class="{ 'rotate-90': !isReasoningCollapsed[index] }"
|
||||
class="w-4 h-4 transition-transform duration-200"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<template v-if="message.isTyping">{{ t('chat.thinking') }}</template>
|
||||
<template v-else>{{ t('chat.thinkingCompleted') }} ({{ t('chat.thinkingTime') }} {{ getThinkingTime(index) }})</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-show="!isReasoningCollapsed[index]"
|
||||
class="p-3 text-sm text-gray-600 bg-gray-50 border-t border-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
<div class="whitespace-pre-wrap">{{ message.reasoningChain }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bot Message -->
|
||||
<div class="rounded-lg p-4 max-w-[80%] bg-gray-100 dark:bg-gray-700">
|
||||
<div class="max-w-none prose dark:prose-invert">
|
||||
<div v-html="marked.parse(message.botMessage || '')"></div>
|
||||
<div v-if="message.isTyping" class="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center p-4 space-x-2 border-t border-gray-200 dark:border-gray-600">
|
||||
<button
|
||||
@click="toggleWebAccess"
|
||||
class="flex relative justify-center items-center w-10 h-10 text-gray-400 bg-gray-100 rounded-md transition-colors group dark:bg-gray-700 dark:text-gray-400"
|
||||
:class="{
|
||||
'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-light-3': isWebEnabled,
|
||||
}"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
class="absolute bottom-full left-1/2 invisible px-3 py-2 mb-2 text-sm text-white whitespace-nowrap bg-gray-800 rounded-lg opacity-0 transition-all duration-200 -translate-x-1/2 group-hover:opacity-100 group-hover:visible"
|
||||
>
|
||||
{{ isWebEnabled ? t('chat.webSearchEnabled') : t('chat.webSearchDisabled') }}
|
||||
<div class="absolute top-full left-1/2 -mt-1 border-4 border-transparent -translate-x-1/2 border-t-gray-800"></div>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
v-model="userInput"
|
||||
ref="userInputRef"
|
||||
type="text"
|
||||
:placeholder="t('chat.inputPlaceholder')"
|
||||
:readonly="readonly"
|
||||
class="px-3 py-2 w-full text-gray-900 bg-gray-50 rounded-l-md border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
@click="sendMessage"
|
||||
class="px-4 py-2 text-white rounded-r-md transition duration-300 disabled:opacity-50 bg-primary hover:bg-primary-focus dark:bg-primary-light-3 dark:hover:bg-primary-light-5"
|
||||
:disabled="readonly"
|
||||
>
|
||||
{{ t('chat.send') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="chat">
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import request from '/@/utils/request';
|
||||
import { useThemeConfig } from '/@/stores/themeConfig';
|
||||
import { marked } from 'marked';
|
||||
import { nextTick } from 'vue';
|
||||
import { Session } from '/@/utils/storage';
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
// 定义变量内容
|
||||
const storesThemeConfig = useThemeConfig();
|
||||
const { themeConfig } = storeToRefs(storesThemeConfig);
|
||||
const { t } = useI18n();
|
||||
|
||||
// 获取布局配置信息
|
||||
const getThemeConfig = computed(() => {
|
||||
return themeConfig.value;
|
||||
});
|
||||
|
||||
interface Message {
|
||||
inputMessage?: string;
|
||||
botMessage?: string;
|
||||
reasoningChain?: string;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
isTyping?: boolean;
|
||||
}
|
||||
|
||||
const messageList = ref<Message[]>([{ botMessage: t('chat.welcome') }]);
|
||||
const chatContainer = ref(false);
|
||||
const userInput = ref('');
|
||||
const userInputRef = ref();
|
||||
const chatboxRef = ref();
|
||||
const readonly = ref(false);
|
||||
const controller = ref<AbortController | null>(null);
|
||||
const isReasoningCollapsed = ref<Record<number, boolean>>({});
|
||||
const isWebEnabled = ref(false);
|
||||
|
||||
// 监听消息变化,自动滚动
|
||||
watch([() => messageList.value.length, () => messageList.value[messageList.value.length - 1]?.botMessage], () => {
|
||||
nextTick(() => {
|
||||
if (chatboxRef.value) {
|
||||
chatboxRef.value.scrollTop = chatboxRef.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 从会话存储中获取访问令牌
|
||||
* @returns {string} 访问令牌
|
||||
*/
|
||||
const token = computed(() => {
|
||||
return Session.getToken();
|
||||
});
|
||||
/**
|
||||
* 从会话存储中获取访问租户
|
||||
* @returns {string} 租户
|
||||
*/
|
||||
const tenant = computed(() => {
|
||||
return Session.getTenant();
|
||||
});
|
||||
|
||||
// 解析SSE返回的数据
|
||||
function parseSSEResponse(data: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
// 处理结束信号
|
||||
if (parsed.choices?.[0]?.finish_reason === 'stop') {
|
||||
return {
|
||||
isFinished: true,
|
||||
content: '',
|
||||
reasoning: '',
|
||||
};
|
||||
}
|
||||
|
||||
const delta = parsed.choices?.[0]?.delta;
|
||||
if (!delta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 分别获取思维链和回答内容
|
||||
return {
|
||||
reasoning: delta.reasoning_content || '',
|
||||
content: delta.content || '',
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 切换推理过程的显示/隐藏
|
||||
const toggleReasoning = (index: number) => {
|
||||
isReasoningCollapsed.value[index] = !isReasoningCollapsed.value[index];
|
||||
};
|
||||
|
||||
// 计算思考时间
|
||||
const getThinkingTime = (index: number) => {
|
||||
const message = messageList.value[index];
|
||||
if (message.startTime && message.endTime) {
|
||||
const duration = message.endTime - message.startTime;
|
||||
return `${Math.round(duration / 1000)} 秒`;
|
||||
}
|
||||
return '计算中...';
|
||||
};
|
||||
|
||||
// 切换联网状态
|
||||
const toggleWebAccess = () => {
|
||||
isWebEnabled.value = !isWebEnabled.value;
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = async () => {
|
||||
const userMessage = userInput.value;
|
||||
if (userMessage.trim() === '') return;
|
||||
|
||||
// 如果存在之前的请求,中止它
|
||||
if (controller.value) {
|
||||
controller.value.abort();
|
||||
}
|
||||
|
||||
readonly.value = true;
|
||||
const newMessage: Message = {
|
||||
inputMessage: userMessage,
|
||||
botMessage: '',
|
||||
reasoningChain: '',
|
||||
startTime: Date.now(),
|
||||
endTime: undefined,
|
||||
isTyping: true,
|
||||
};
|
||||
messageList.value.push(newMessage);
|
||||
// 默认折叠推理过程
|
||||
isReasoningCollapsed.value[messageList.value.length - 1] = false;
|
||||
|
||||
// 创建新的 AbortController
|
||||
controller.value = new AbortController();
|
||||
|
||||
try {
|
||||
const fetchOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token.value}`,
|
||||
'TENANT-ID': tenant.value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: userMessage,
|
||||
webSearch: isWebEnabled.value,
|
||||
}),
|
||||
signal: controller.value.signal,
|
||||
async onopen(response: Response) {
|
||||
if (response.ok && response.headers.get('content-type')?.includes('text/event-stream')) {
|
||||
return; // 连接成功
|
||||
}
|
||||
throw new Error(`Failed to connect: ${response.status} ${response.statusText}`);
|
||||
},
|
||||
onmessage(event: { data: string }) {
|
||||
const parsed = parseSSEResponse(event.data);
|
||||
if (!parsed) return;
|
||||
|
||||
const lastMessage = messageList.value[messageList.value.length - 1];
|
||||
|
||||
// 直接更新内容,不需要缓冲
|
||||
if (parsed.content) {
|
||||
if (!lastMessage.botMessage) {
|
||||
lastMessage.botMessage = '';
|
||||
}
|
||||
lastMessage.botMessage += parsed.content;
|
||||
}
|
||||
if (parsed.reasoning) {
|
||||
if (!lastMessage.reasoningChain) {
|
||||
lastMessage.reasoningChain = '';
|
||||
}
|
||||
lastMessage.reasoningChain += parsed.reasoning;
|
||||
}
|
||||
|
||||
// 处理结束信号
|
||||
if (parsed.isFinished) {
|
||||
lastMessage.endTime = Date.now();
|
||||
lastMessage.isTyping = false;
|
||||
// 完成后折叠推理过程
|
||||
isReasoningCollapsed.value[messageList.value.length - 1] = true;
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
// 连接正常关闭
|
||||
readonly.value = false;
|
||||
},
|
||||
onerror(error: Error) {
|
||||
// 处理错误
|
||||
const lastMessage = messageList.value[messageList.value.length - 1];
|
||||
lastMessage.endTime = Date.now();
|
||||
lastMessage.isTyping = false;
|
||||
if (!lastMessage.botMessage) {
|
||||
lastMessage.botMessage = t('chat.connectionError');
|
||||
}
|
||||
// 发生错误时折叠推理过程
|
||||
isReasoningCollapsed.value[messageList.value.length - 1] = true;
|
||||
readonly.value = false;
|
||||
throw error; // 重试或中止
|
||||
},
|
||||
};
|
||||
|
||||
await fetchEventSource(`${request.defaults.baseURL}/admin/ai/chat`, fetchOptions);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
// 请求被中止,不需要特殊处理
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
// 清空输入
|
||||
userInput.value = '';
|
||||
readonly.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听回车键事件
|
||||
onKeyStroke(
|
||||
'Enter',
|
||||
() => {
|
||||
sendMessage();
|
||||
},
|
||||
{ target: userInputRef }
|
||||
);
|
||||
|
||||
// 清空会话
|
||||
const clearChat = () => {
|
||||
// 保留初始欢迎消息
|
||||
messageList.value = [{ botMessage: t('chat.welcome') }];
|
||||
// 重置所有状态
|
||||
isReasoningCollapsed.value = {};
|
||||
readonly.value = false;
|
||||
userInput.value = '';
|
||||
|
||||
// 如果存在进行中的请求,中止它
|
||||
if (controller.value) {
|
||||
controller.value.abort();
|
||||
controller.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.prose {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.prose-invert {
|
||||
--tw-prose-body: theme('colors.gray.300');
|
||||
--tw-prose-headings: theme('colors.white');
|
||||
--tw-prose-links: theme('colors.primary.light-3');
|
||||
--tw-prose-bold: theme('colors.white');
|
||||
--tw-prose-counters: theme('colors.gray.400');
|
||||
--tw-prose-bullets: theme('colors.gray.400');
|
||||
--tw-prose-quotes: theme('colors.gray.100');
|
||||
--tw-prose-quote-borders: theme('colors.gray.700');
|
||||
--tw-prose-captions: theme('colors.gray.400');
|
||||
--tw-prose-code: theme('colors.white');
|
||||
--tw-prose-pre-code: theme('colors.gray.100');
|
||||
--tw-prose-pre-bg: theme('colors.gray.900');
|
||||
--tw-prose-hr: theme('colors.gray.700');
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
animation: typing 1s infinite;
|
||||
}
|
||||
|
||||
:deep(.dark) .typing-indicator span {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
|
||||
:deep(.light) .typing-indicator span {
|
||||
background-color: #666;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
19
src/components/CheckToken/index.vue
Normal file
19
src/components/CheckToken/index.vue
Normal 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>
|
||||
75
src/components/ChinaArea/index.vue
Normal file
75
src/components/ChinaArea/index.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<el-cascader :options="areas" :disabled="disabled" :props="optionProps" v-model="selectedOptions" filterable
|
||||
@change="handleChange">
|
||||
<template #default="{ data }">
|
||||
<span>{{ data.name }}</span>
|
||||
|
||||
<!-- 图标 -->
|
||||
<svg v-if="data.hot === '1'" t="1708145002710" class="ml-4 icon" 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 } from 'element-plus';
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change']);
|
||||
|
||||
// 定义props
|
||||
const props = defineProps({
|
||||
// 当前的值
|
||||
modelValue: String,
|
||||
// 层级
|
||||
type: {
|
||||
type: Number,
|
||||
default: () => 1,
|
||||
},
|
||||
// 是否禁用
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
// 子级是否必选
|
||||
plus: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
// 定义optionProps
|
||||
const optionProps: CascaderProps = {
|
||||
checkStrictly: props.plus,
|
||||
label: 'name',
|
||||
value: 'adcode',
|
||||
};
|
||||
|
||||
// 所有省市区的数据
|
||||
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 () => {
|
||||
const { data } = await fetchTree({ areaType: props.type });
|
||||
areas.value = data;
|
||||
});
|
||||
</script>
|
||||
121
src/components/CodeEditor/index.vue
Normal file
121
src/components/CodeEditor/index.vue
Normal 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
src/components/ColorPicker/index.vue
Normal file
33
src/components/ColorPicker/index.vue
Normal 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
src/components/Crontab/index.vue
Normal file
806
src/components/Crontab/index.vue
Normal 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
src/components/DelWrap/index.vue
Normal file
51
src/components/DelWrap/index.vue
Normal 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>
|
||||
87
src/components/DictTag/Select.vue
Normal file
87
src/components/DictTag/Select.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<el-select
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:multiple="multiple"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
class="w-full"
|
||||
@update:model-value="handleChange"
|
||||
>
|
||||
<el-option v-for="item in dictData" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { getDicts } from '/@/api/admin/dict';
|
||||
|
||||
interface DictOption {
|
||||
label: string;
|
||||
value: string | number;
|
||||
elTagType?: string;
|
||||
elTagClass?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | number | (string | number)[];
|
||||
options?: DictOption[] | string[];
|
||||
placeholder?: string;
|
||||
multiple?: boolean;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
dictType?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: undefined,
|
||||
options: () => [],
|
||||
placeholder: '请选择',
|
||||
multiple: false,
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
dictType: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | number | (string | number)[]): void;
|
||||
(e: 'change', value: string | number | (string | number)[]): void;
|
||||
}>();
|
||||
|
||||
const dictList = ref<DictOption[]>([]);
|
||||
|
||||
async function loadDictData() {
|
||||
if (props.dictType) {
|
||||
const res = await getDicts(props.dictType);
|
||||
dictList.value = res.data.map((p: any) => ({
|
||||
label: p.label,
|
||||
value: p.value
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDictData();
|
||||
});
|
||||
|
||||
const dictData = computed<DictOption[]>(() => {
|
||||
if (props.dictType) {
|
||||
return dictList.value;
|
||||
}
|
||||
|
||||
return props.options.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
label: item,
|
||||
value: item,
|
||||
};
|
||||
}
|
||||
return item as DictOption;
|
||||
});
|
||||
});
|
||||
|
||||
function handleChange(value: string | number | (string | number)[]) {
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
}
|
||||
</script>
|
||||
48
src/components/DictTag/index.vue
Normal file
48
src/components/DictTag/index.vue
Normal 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
src/components/Editor/index.vue
Normal file
141
src/components/Editor/index.vue
Normal 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
src/components/FormTable/index.vue
Normal file
210
src/components/FormTable/index.vue
Normal 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
src/components/IconSelector/index.ts
Normal file
71
src/components/IconSelector/index.ts
Normal 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
src/components/IconSelector/index.vue
Normal file
254
src/components/IconSelector/index.vue
Normal 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
src/components/IconSelector/list.vue
Normal file
84
src/components/IconSelector/list.vue
Normal 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
src/components/Link/custom-link.vue
Normal file
36
src/components/Link/custom-link.vue
Normal 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
src/components/Link/index.ts
Normal file
11
src/components/Link/index.ts
Normal 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
src/components/Link/index.vue
Normal file
92
src/components/Link/index.vue
Normal 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
src/components/Link/picker.vue
Normal file
76
src/components/Link/picker.vue
Normal 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
src/components/Link/shop-pages.vue
Normal file
100
src/components/Link/shop-pages.vue
Normal 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
src/components/Material/file.vue
Normal file
92
src/components/Material/file.vue
Normal 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
src/components/Material/hook.ts
Normal file
200
src/components/Material/hook.ts
Normal 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
src/components/Material/i18n/en.ts
Normal file
18
src/components/Material/i18n/en.ts
Normal 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
src/components/Material/i18n/zh-cn.ts
Normal file
18
src/components/Material/i18n/zh-cn.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export default {
|
||||
material: {
|
||||
uploadFileTip: '上传',
|
||||
addGroup: '新增分组',
|
||||
editGroup: '修改分组',
|
||||
delGroup: '删除分组',
|
||||
moveBtn: '移动',
|
||||
preview: '预览',
|
||||
edit: '修改',
|
||||
view: '查看',
|
||||
add: '添加',
|
||||
allCheck: '全选',
|
||||
rename: '重命名',
|
||||
download: '下载',
|
||||
list: '列表',
|
||||
grid: '平铺',
|
||||
},
|
||||
};
|
||||
518
src/components/Material/index.vue
Normal file
518
src/components/Material/index.vue
Normal file
@@ -0,0 +1,518 @@
|
||||
<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" @current-change="currentChangeHandle" 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 val 新的页码
|
||||
*/
|
||||
const currentChangeHandle = (val: number) => {
|
||||
// 修改state.pagination中的current属性
|
||||
pager.current = val;
|
||||
// 再次发起查询操作
|
||||
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
src/components/Material/picker.vue
Normal file
283
src/components/Material/picker.vue
Normal 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
src/components/Material/preview.vue
Normal file
93
src/components/Material/preview.vue
Normal 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
src/components/Material/usePaging.ts
Normal file
76
src/components/Material/usePaging.ts
Normal 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({
|
||||
current: 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.current,
|
||||
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.current = 1;
|
||||
getLists();
|
||||
};
|
||||
// 重置参数
|
||||
const resetParams = () => {
|
||||
Object.keys(paramsInit).forEach((item) => {
|
||||
params[item] = paramsInit[item];
|
||||
});
|
||||
getLists();
|
||||
};
|
||||
return {
|
||||
pager,
|
||||
getLists,
|
||||
resetParams,
|
||||
resetPage,
|
||||
};
|
||||
}
|
||||
129
src/components/NameAvatar/base.scss
Normal file
129
src/components/NameAvatar/base.scss
Normal 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
src/components/NameAvatar/index.vue
Normal file
105
src/components/NameAvatar/index.vue
Normal 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
src/components/NoticeBar/index.vue
Normal file
191
src/components/NoticeBar/index.vue
Normal 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>
|
||||
BIN
src/components/OrgSelector/assets/check_box.png
Normal file
BIN
src/components/OrgSelector/assets/check_box.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 325 B |
BIN
src/components/OrgSelector/assets/jiaojiao.png
Normal file
BIN
src/components/OrgSelector/assets/jiaojiao.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
42
src/components/OrgSelector/common.ts
Normal file
42
src/components/OrgSelector/common.ts
Normal 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 = {
|
||||
name: event,
|
||||
};
|
||||
if (type === 1) {
|
||||
departments.value.childDepartments = [];
|
||||
let res = await orgTreeSearcheUser(data);
|
||||
departments.value.employees = res.data;
|
||||
}
|
||||
} else {
|
||||
type === 1 ? await getDepartmentList() : await getRoleList();
|
||||
}
|
||||
};
|
||||
30
src/components/OrgSelector/dialog.css
Normal file
30
src/components/OrgSelector/dialog.css
Normal 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: 400px;
|
||||
height: 100%;
|
||||
border-right: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.l {
|
||||
float: left;
|
||||
}
|
||||
179
src/components/OrgSelector/employeesDialog.vue
Normal file
179
src/components/OrgSelector/employeesDialog.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<el-dialog :title="$t('orgSelecotr.select') + $t(`orgSelecotr.${props.type}`)" v-model="visibleDialog" :width="800" 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
src/components/OrgSelector/i18n/en.ts
Normal file
10
src/components/OrgSelector/i18n/en.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
orgSelecotr: {
|
||||
org: 'org',
|
||||
user: 'user',
|
||||
dept: 'dept',
|
||||
role: 'role',
|
||||
select: 'select',
|
||||
search: 'search'
|
||||
},
|
||||
};
|
||||
10
src/components/OrgSelector/i18n/zh-cn.ts
Normal file
10
src/components/OrgSelector/i18n/zh-cn.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
orgSelecotr: {
|
||||
org: '组织',
|
||||
user: '用户',
|
||||
dept: '部门',
|
||||
role: '角色',
|
||||
select: '选择',
|
||||
search: '搜索'
|
||||
},
|
||||
};
|
||||
72
src/components/OrgSelector/index.vue
Normal file
72
src/components/OrgSelector/index.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<employees-dialog
|
||||
v-model:visible="selectUserDialogVisible"
|
||||
:data="modelData"
|
||||
: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="modelData" :disabled="disabled" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import employeesDialog from './employeesDialog.vue';
|
||||
import orgItem from './orgItem.vue';
|
||||
import type { OrgItem } from './types';
|
||||
|
||||
const selectUserDialogVisible = ref(false);
|
||||
|
||||
const emits = defineEmits(['update:orgList', 'update:modelValue']);
|
||||
|
||||
const props = defineProps({
|
||||
orgList: {
|
||||
type: Array as PropType<OrgItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
modelValue: {
|
||||
type: Array as PropType<OrgItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'user',
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectSelf: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const modelData = computed({
|
||||
get: () => {
|
||||
return props.modelValue?.length ? props.modelValue : props.orgList || [];
|
||||
},
|
||||
set: (value: OrgItem[]) => {
|
||||
emits('update:modelValue', value);
|
||||
emits('update:orgList', value);
|
||||
},
|
||||
});
|
||||
|
||||
const afterSelectUser = (data: OrgItem[]) => {
|
||||
selectUserDialogVisible.value = false;
|
||||
emits('update:modelValue', data);
|
||||
emits('update:orgList', data);
|
||||
};
|
||||
</script>
|
||||
37
src/components/OrgSelector/orgItem.vue
Normal file
37
src/components/OrgSelector/orgItem.vue
Normal 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
src/components/OrgSelector/roleDialog.vue
Normal file
120
src/components/OrgSelector/roleDialog.vue
Normal 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>
|
||||
295
src/components/OrgSelector/selectBox.vue
Normal file
295
src/components/OrgSelector/selectBox.vue
Normal file
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div id="select-user-box-id">
|
||||
<el-input
|
||||
v-model="searchVal"
|
||||
class="w-50 m-2"
|
||||
style="width: 100%"
|
||||
v-if="type === 'user'"
|
||||
placeholder="搜索中..."
|
||||
@input="getDebounceData($event)"
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
<p class="ellipsis tree_nav" v-if="!searchVal && type !== 'role'">
|
||||
<span @click="queryData(rootId)" 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 text-[#38ADFF]" @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';
|
||||
|
||||
const rootId = import.meta.env.VITE_DEPT_ROOT_ID
|
||||
|
||||
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(rootId);
|
||||
});
|
||||
|
||||
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 lang="scss">
|
||||
@import './dialog.css';
|
||||
#select-user-box-id {
|
||||
.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
src/components/OrgSelector/selectResult.vue
Normal file
136
src/components/OrgSelector/selectResult.vue
Normal 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>
|
||||
5
src/components/OrgSelector/types.ts
Normal file
5
src/components/OrgSelector/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface OrgItem {
|
||||
id: string | number;
|
||||
name: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
52
src/components/Pagination/index.vue
Normal file
52
src/components/Pagination/index.vue
Normal 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
src/components/PopoverInput/index.vue
Normal file
128
src/components/PopoverInput/index.vue
Normal 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
src/components/Popup/index.vue
Normal file
118
src/components/Popup/index.vue
Normal 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>
|
||||
207
src/components/ProjectNameList/index.vue
Normal file
207
src/components/ProjectNameList/index.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { getReserveInvestmentProjectsPageAPI, type ReserveInvestmentProjectItem } from '/@/api/investment/reserveRegistration';
|
||||
import { projectNatureOptions } from '/@/hooks/enums';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useMessage } from '/@/hooks/message';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const props = withDefaults(defineProps<{ visible: boolean,isSelect:boolean,selectedId:any }>(), { visible: false,isSelect:false,selectedId:'' });
|
||||
type ReserveProjectItem = ReserveInvestmentProjectItem & {
|
||||
projectMainUnit?: string;
|
||||
projectDirection?: string;
|
||||
projectStartDate?: string;
|
||||
projectEndDate?: string;
|
||||
investmentRegion?: string;
|
||||
lastUpdateTime?: string;
|
||||
};
|
||||
const { t } = useI18n();
|
||||
const message = useMessage();
|
||||
const total = ref(0);
|
||||
const tableData = ref<ReserveProjectItem[]>([]);
|
||||
const selectedRows = ref<ReserveProjectItem>();
|
||||
const tableLoading = ref(false);
|
||||
const queryForm = reactive({
|
||||
projectName: '',
|
||||
projectMainEntity: '',
|
||||
projectNature: '',
|
||||
page: 1,
|
||||
size: 10,
|
||||
});
|
||||
const normalizeReserveProject = (item: ReserveInvestmentProjectItem): ReserveProjectItem => ({
|
||||
...item,
|
||||
projectMainUnit: item.projectMainEntity,
|
||||
projectDirection: item.projectInvestmentDirection,
|
||||
projectStartDate: item.projectStartTime,
|
||||
projectEndDate: item.projectEndTime,
|
||||
investmentRegion: item.investmentArea,
|
||||
majorInvestmentProject: item.majorInvestmentProjects ?? item.majorInvestmentProject ?? '',
|
||||
keyProject: item.keyProject ?? '',
|
||||
lastUpdateTime: item.updateTime || item.createTime || '',
|
||||
lastUpdater: item.updateBy || item.createBy || '',
|
||||
lastPushStatus: item.status || 'pending',
|
||||
});
|
||||
|
||||
const fetchProjectList = async () => {
|
||||
tableLoading.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
page: queryForm.page,
|
||||
size: queryForm.size,
|
||||
projectName: queryForm.projectName || undefined,
|
||||
projectMainEntity: queryForm.projectMainEntity || undefined,
|
||||
projectNature: queryForm.projectNature || undefined,
|
||||
};
|
||||
const res = await getReserveInvestmentProjectsPageAPI(payload);
|
||||
const records = (res as any)?.records ?? (res as any)?.data?.records ?? [];
|
||||
const totalCount = (res as any)?.total ?? (res as any)?.data?.total ?? 0;
|
||||
tableData.value = Array.isArray(records) ? records.map(normalizeReserveProject) : [];
|
||||
total.value = Number(totalCount) || 0;
|
||||
} catch (error: any) {
|
||||
message.error(error?.msg || error?.message || t('common.operateFail') || '获取列表失败');
|
||||
} finally {
|
||||
tableLoading.value = false;
|
||||
}
|
||||
};
|
||||
const handleSearch = () => {
|
||||
queryForm.page = 1;
|
||||
fetchProjectList();
|
||||
};
|
||||
const getStatusTagType = (status: ReserveProjectItem['lastPushStatus']) => {
|
||||
if (status === 'submitted') return 'success';
|
||||
if (status === 'failed') return 'danger';
|
||||
return 'info';
|
||||
};
|
||||
const handlePageSizeChange = (size: number) =>{
|
||||
queryForm.size = size;
|
||||
queryForm.page = 1;
|
||||
queryForm.projectMainEntity = '';
|
||||
queryForm.projectNature = '';
|
||||
queryForm.projectName = '';
|
||||
fetchProjectList();
|
||||
}
|
||||
const handlePageChange = (page: number) => {
|
||||
queryForm.page = page;
|
||||
queryForm.projectMainEntity = '';
|
||||
queryForm.projectNature = '';
|
||||
queryForm.projectName = '';
|
||||
queryForm.size = 10;
|
||||
fetchProjectList();
|
||||
};
|
||||
const getStatusLabel = (status: ReserveProjectItem['lastPushStatus']) => {
|
||||
if (status === 'submitted') return t('reserveLibrary.status.submitted');
|
||||
if (status === 'failed') return t('reserveLibrary.status.failed');
|
||||
return t('reserveLibrary.status.pending');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
queryForm.projectName = '';
|
||||
queryForm.projectMainEntity = '';
|
||||
queryForm.projectNature = '';
|
||||
queryForm.page = 1;
|
||||
fetchProjectList();
|
||||
};
|
||||
const handleSelectionChange = (rows: ReserveProjectItem) => {
|
||||
selectedRows.value = rows;
|
||||
};
|
||||
fetchProjectList()
|
||||
const emits = defineEmits(['emit:confirm','emit:cancel','emit:select']);
|
||||
const handleConfirm = ()=>{
|
||||
emits('emit:confirm',selectedRows.value);
|
||||
}
|
||||
const handleView = (row: ReserveProjectItem) =>{
|
||||
selectedRows.value = row;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="props.visible" title="项目名称列表" width="80%" v-if="!isSelect">
|
||||
<el-row class="toolbar-row mb10">
|
||||
<div class="toolbar-right">
|
||||
<el-form class="search-form" :inline="true" :model="queryForm" @submit.prevent>
|
||||
<el-form-item :label="t('reserveLibrary.form.projectName')">
|
||||
<el-input v-model="queryForm.projectName" :placeholder="t('reserveLibrary.placeholder.input')" style="width: 180px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('reserveLibrary.form.projectMainUnit')">
|
||||
<el-input v-model="queryForm.projectMainEntity" :placeholder="t('reserveLibrary.placeholder.input')" style="width: 180px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('reserveLibrary.form.projectNature')">
|
||||
<el-select v-model="queryForm.projectNature" :placeholder="t('reserveLibrary.placeholder.select')" clearable style="width: 160px">
|
||||
<el-option v-for="item of projectNatureOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="Search" @click="handleSearch">{{ t('common.queryBtn') }}</el-button>
|
||||
<el-button icon="Refresh" @click="handleReset">{{ t('common.resetBtn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-table :data="tableData" style="width: 100%;height: 50vh" border @current-change="handleSelectionChange" v-loading="tableLoading">
|
||||
<el-table-column width="60" label="选择">
|
||||
<template #default="scope">
|
||||
<div class="round" :class="{ 'active-row': selectedRows?.id === scope.row.id }"></div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column type="index" :label="t('reserveLibrary.table.index')" width="60" />
|
||||
<el-table-column prop="projectName" :label="t('reserveLibrary.table.projectName')" min-width="200" />
|
||||
<el-table-column prop="projectMainUnit" :label="t('reserveLibrary.table.projectMainUnit')" min-width="200" />
|
||||
<el-table-column prop="investmentCategory" :label="t('reserveLibrary.table.investmentCategory')" min-width="140" />
|
||||
<el-table-column prop="projectNature" :label="t('reserveLibrary.table.projectNature')" min-width="140" />
|
||||
<el-table-column prop="projectDirection" :label="t('reserveLibrary.table.projectDirection')" min-width="160" />
|
||||
<el-table-column prop="projectStartDate" :label="t('reserveLibrary.table.projectStartDate')" min-width="160" />
|
||||
<el-table-column prop="projectEndDate" :label="t('reserveLibrary.table.projectEndDate')" min-width="160" />
|
||||
<el-table-column prop="investmentRegion" :label="t('reserveLibrary.table.investmentRegion')" min-width="140" />
|
||||
<el-table-column prop="majorInvestmentProject" :label="t('reserveLibrary.table.majorInvestmentProject')" min-width="160" />
|
||||
<el-table-column prop="keyProject" :label="t('reserveLibrary.table.keyProject')" min-width="120" />
|
||||
<el-table-column prop="lastUpdater" :label="t('reserveLibrary.table.lastUpdater')" min-width="140" />
|
||||
<el-table-column prop="lastUpdateTime" :label="t('reserveLibrary.table.lastUpdateTime')" min-width="180" />
|
||||
<el-table-column prop="lastPushStatus" :label="t('reserveLibrary.table.lastPushStatus')" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusTagType(row.lastPushStatus)" effect="light">
|
||||
{{ getStatusLabel(row.lastPushStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('reserveLibrary.table.action')" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleView(row)">{{ t('common.selectText') || '选择' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-between align-center">
|
||||
<div>
|
||||
<el-button type="primary" @click="handleConfirm">{{ t('common.confirmB') }}</el-button>
|
||||
<el-button @click="()=>{emits('emit:cancel',false)}">{{ t('common.cancelB') }}</el-button>
|
||||
</div>
|
||||
<el-pagination
|
||||
style="margin-top: 0"
|
||||
background
|
||||
layout="sizes, prev, pager, next, jumper, total"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
v-model:page-size="queryForm.size"
|
||||
v-model:current-page="queryForm.page"
|
||||
@size-change="handlePageSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-select v-else v-model="props.selectedId" @change="(row:any)=>emits('emit:select', row)">
|
||||
<el-option v-for="item in tableData" :key="item.id" :label="item.projectName" :value="item" />
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.round {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.active-row {
|
||||
background-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
</style>
|
||||
9
src/components/QueryTree/i18n/en.ts
Normal file
9
src/components/QueryTree/i18n/en.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default {
|
||||
queryTree: {
|
||||
hideSearch: 'hideSearch',
|
||||
displayTheSearch: 'displayTheSearch',
|
||||
refresh: 'refresh',
|
||||
print: 'print',
|
||||
view: 'view'
|
||||
},
|
||||
};
|
||||
9
src/components/QueryTree/i18n/zh-cn.ts
Normal file
9
src/components/QueryTree/i18n/zh-cn.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default {
|
||||
queryTree: {
|
||||
hideSearch: '隐藏搜索',
|
||||
displayTheSearch: '显示搜索',
|
||||
refresh: '刷新',
|
||||
print: '打印',
|
||||
view: '视图'
|
||||
},
|
||||
};
|
||||
188
src/components/QueryTree/index.vue
Normal file
188
src/components/QueryTree/index.vue
Normal 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;
|
||||
})
|
||||
.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
src/components/RightToolbar/index.vue
Normal file
119
src/components/RightToolbar/index.vue
Normal 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>
|
||||
111
src/components/SSE/index.vue
Normal file
111
src/components/SSE/index.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
<script setup lang="ts" name="global-sse">
|
||||
import { Session } from '/@/utils/storage';
|
||||
import { ElNotification } from 'element-plus';
|
||||
import { fetchEventSource, EventSourceMessage } from '@microsoft/fetch-event-source';
|
||||
import { onUnmounted } from 'vue';
|
||||
|
||||
interface SseProps {
|
||||
uri?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<SseProps>();
|
||||
|
||||
/**
|
||||
* 从会话存储中获取访问令牌
|
||||
* @returns {string} 访问令牌
|
||||
*/
|
||||
const token = computed(() => {
|
||||
return Session.getToken();
|
||||
});
|
||||
/**
|
||||
* 从会话存储中获取访问租户
|
||||
* @returns {string} 租户
|
||||
*/
|
||||
const tenant = computed(() => {
|
||||
return Session.getTenant();
|
||||
});
|
||||
|
||||
/**
|
||||
* SSE 消息提醒
|
||||
* @param {string} message - 收到的消息内容
|
||||
*/
|
||||
const showSseNotification = (message: string) => {
|
||||
ElNotification.warning({
|
||||
title: '消息提醒',
|
||||
dangerouslyUseHTMLString: true,
|
||||
message: message + '请及时处理',
|
||||
offset: 0,
|
||||
});
|
||||
};
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* 初始化 SSE 连接
|
||||
* @param {string} uri - SSE 服务端 URI
|
||||
*/
|
||||
const initSseConnection = async (uri: string) => {
|
||||
const baseURL = import.meta.env.VITE_API_URL;
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'TENANT-ID': tenant.value,
|
||||
Authorization: `Bearer ${token.value}`,
|
||||
},
|
||||
signal: abortController.signal,
|
||||
onmessage(event: EventSourceMessage) {
|
||||
if (event.data !== 'pong') {
|
||||
showSseNotification(event.data);
|
||||
}
|
||||
},
|
||||
async onopen(response: Response) {
|
||||
if (response.ok && response.status === 200) {
|
||||
console.log('[SSE] connection established');
|
||||
} else {
|
||||
throw new Error(`Failed to establish SSE connection: ${response.status}`);
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
console.log('[SSE] connection closed');
|
||||
},
|
||||
onerror(err: Error) {
|
||||
console.error('[SSE] connection error:', err);
|
||||
throw err; // 重试连接
|
||||
},
|
||||
// 重试策略
|
||||
openWhenHidden: true,
|
||||
retry: {
|
||||
initialRetryDelayMs: 1000,
|
||||
maxRetryDelayMs: 10000,
|
||||
retryBackoff: 1.5,
|
||||
},
|
||||
};
|
||||
|
||||
await fetchEventSource(`${baseURL}${uri}`, options);
|
||||
} catch (e) {
|
||||
console.error('[SSE] connection failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// 是否开启sseEnable
|
||||
const sseEnable = ref(import.meta.env.VITE_SSE_ENABLE === 'true');
|
||||
|
||||
if (sseEnable.value && props.uri) {
|
||||
initSseConnection(props.uri);
|
||||
}
|
||||
|
||||
// 组件卸载时清理连接
|
||||
onUnmounted(() => {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
abortController = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
90
src/components/ShortcutCard/index.vue
Normal file
90
src/components/ShortcutCard/index.vue
Normal 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>
|
||||
295
src/components/Sign/index.vue
Normal file
295
src/components/Sign/index.vue
Normal file
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div class="sign-wrapper">
|
||||
<div v-show="!modelValue" class="sign-container">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
@mousedown="handleMouseDown"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="handleMouseUp"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="handleTouchEnd"
|
||||
></canvas>
|
||||
</div>
|
||||
<div v-if="!props.disabled && !modelValue" class="sign-controls">
|
||||
<el-space>
|
||||
<el-button type="primary" @click="handleGenerate">
|
||||
<el-icon><Check /></el-icon>
|
||||
确认签名
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
清空
|
||||
</el-button>
|
||||
<div>
|
||||
<el-color-picker v-model="currentLineColor" size="small" />
|
||||
</div>
|
||||
</el-space>
|
||||
</div>
|
||||
<div v-if="modelValue" class="flex flex-col items-center sign-preview">
|
||||
<el-image :src="modelValue" fit="contain" />
|
||||
<div class="mt-2">
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重新签名
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue';
|
||||
import { Check, Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import type { Point } from './types';
|
||||
|
||||
interface Props {
|
||||
width?: number;
|
||||
height?: number;
|
||||
lineWidth?: number;
|
||||
lineColor?: string;
|
||||
bgColor?: string;
|
||||
isCrop?: boolean;
|
||||
isClearBgColor?: boolean;
|
||||
modelValue?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: 300,
|
||||
height: 150,
|
||||
lineWidth: 2,
|
||||
lineColor: '#000000',
|
||||
bgColor: '',
|
||||
isCrop: false,
|
||||
isClearBgColor: true,
|
||||
modelValue: '',
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
||||
const isDrawing = ref(false);
|
||||
const hasDrew = ref(false);
|
||||
const currentLineColor = ref(props.lineColor);
|
||||
const points = ref<Point[]>([]);
|
||||
const startPoint = ref<Point>({ x: 0, y: 0 });
|
||||
const scaleRatio = ref(1);
|
||||
|
||||
const backgroundColor = computed(() => props.bgColor || 'rgba(255, 255, 255, 0)');
|
||||
|
||||
function initCanvas(): void {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
|
||||
const container = canvas.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
// Set fixed dimensions
|
||||
canvas.width = props.width;
|
||||
canvas.height = props.height;
|
||||
|
||||
// Set display dimensions
|
||||
canvas.style.width = `${props.width}px`;
|
||||
canvas.style.height = `${props.height}px`;
|
||||
canvas.style.background = backgroundColor.value;
|
||||
|
||||
ctx.value = canvas.getContext('2d');
|
||||
if (ctx.value) {
|
||||
ctx.value.strokeStyle = currentLineColor.value;
|
||||
ctx.value.lineWidth = props.lineWidth;
|
||||
ctx.value.lineCap = 'round';
|
||||
ctx.value.lineJoin = 'round';
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize(): void {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas || !ctx.value) return;
|
||||
|
||||
// Keep the same dimensions
|
||||
canvas.width = props.width;
|
||||
canvas.height = props.height;
|
||||
|
||||
ctx.value = canvas.getContext('2d');
|
||||
if (!ctx.value) return;
|
||||
|
||||
ctx.value.strokeStyle = currentLineColor.value;
|
||||
ctx.value.lineWidth = props.lineWidth;
|
||||
ctx.value.lineCap = 'round';
|
||||
ctx.value.lineJoin = 'round';
|
||||
}
|
||||
|
||||
function drawPoint(point: Point): void {
|
||||
if (!ctx.value) return;
|
||||
|
||||
ctx.value.beginPath();
|
||||
ctx.value.moveTo(startPoint.value.x, startPoint.value.y);
|
||||
ctx.value.lineTo(point.x, point.y);
|
||||
ctx.value.strokeStyle = currentLineColor.value;
|
||||
ctx.value.lineWidth = props.lineWidth * scaleRatio.value;
|
||||
ctx.value.lineCap = 'round';
|
||||
ctx.value.lineJoin = 'round';
|
||||
ctx.value.stroke();
|
||||
ctx.value.closePath();
|
||||
|
||||
startPoint.value = point;
|
||||
points.value.push(point);
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
function handleMouseDown(e: MouseEvent): void {
|
||||
if (props.disabled) return;
|
||||
|
||||
e.preventDefault();
|
||||
isDrawing.value = true;
|
||||
hasDrew.value = true;
|
||||
|
||||
const point = {
|
||||
x: e.offsetX,
|
||||
y: e.offsetY,
|
||||
};
|
||||
startPoint.value = point;
|
||||
points.value.push(point);
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent): void {
|
||||
if (!isDrawing.value || props.disabled) return;
|
||||
|
||||
e.preventDefault();
|
||||
drawPoint({
|
||||
x: e.offsetX,
|
||||
y: e.offsetY,
|
||||
});
|
||||
}
|
||||
|
||||
function handleMouseUp(e: MouseEvent): void {
|
||||
if (props.disabled) return;
|
||||
|
||||
e.preventDefault();
|
||||
isDrawing.value = false;
|
||||
points.value.push({ x: -1, y: -1 }); // Mark end of stroke
|
||||
}
|
||||
|
||||
// Touch events
|
||||
function handleTouchStart(e: TouchEvent): void {
|
||||
if (props.disabled || !canvasRef.value) return;
|
||||
|
||||
e.preventDefault();
|
||||
hasDrew.value = true;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
const point = {
|
||||
x: touch.clientX - rect.left,
|
||||
y: touch.clientY - rect.top,
|
||||
};
|
||||
|
||||
startPoint.value = point;
|
||||
points.value.push(point);
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent): void {
|
||||
if (props.disabled || !canvasRef.value) return;
|
||||
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const rect = canvasRef.value.getBoundingClientRect();
|
||||
|
||||
drawPoint({
|
||||
x: touch.clientX - rect.left,
|
||||
y: touch.clientY - rect.top,
|
||||
});
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: TouchEvent): void {
|
||||
if (props.disabled) return;
|
||||
|
||||
e.preventDefault();
|
||||
points.value.push({ x: -1, y: -1 }); // Mark end of stroke
|
||||
}
|
||||
|
||||
// Actions
|
||||
async function handleGenerate(): Promise<void> {
|
||||
try {
|
||||
const result = await generate();
|
||||
emit('update:modelValue', result);
|
||||
} catch (error) {
|
||||
ElMessage.warning('请先进行签名');
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset(): void {
|
||||
reset();
|
||||
emit('update:modelValue', '');
|
||||
}
|
||||
|
||||
function generate(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!hasDrew.value || !canvasRef.value || !ctx.value) {
|
||||
reject('请先进行签名');
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.value;
|
||||
const context = ctx.value;
|
||||
|
||||
// Save current drawing
|
||||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Add background
|
||||
context.globalCompositeOperation = 'destination-over';
|
||||
context.fillStyle = backgroundColor.value;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Get result
|
||||
const result = canvas.toDataURL();
|
||||
|
||||
// Restore original drawing
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.putImageData(imageData, 0, 0);
|
||||
context.globalCompositeOperation = 'source-over';
|
||||
|
||||
resolve(result);
|
||||
});
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
if (!ctx.value || !canvasRef.value) return;
|
||||
|
||||
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
|
||||
points.value = [];
|
||||
hasDrew.value = false;
|
||||
|
||||
if (props.isClearBgColor) {
|
||||
canvasRef.value.style.background = 'rgba(255, 255, 255, 0)';
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
initCanvas();
|
||||
window.addEventListener('resize', handleResize);
|
||||
document.addEventListener('mouseup', () => (isDrawing.value = false));
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
document.removeEventListener('mouseup', () => (isDrawing.value = false));
|
||||
});
|
||||
|
||||
// Watch
|
||||
watch(
|
||||
() => backgroundColor.value,
|
||||
(newVal) => {
|
||||
if (canvasRef.value) {
|
||||
canvasRef.value.style.background = newVal;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
defineExpose({ reset, generate });
|
||||
</script>
|
||||
21
src/components/Sign/types.ts
Normal file
21
src/components/Sign/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface SignProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
lineWidth?: number;
|
||||
lineColor?: string;
|
||||
bgColor?: string;
|
||||
isCrop?: boolean;
|
||||
isClearBgColor?: boolean;
|
||||
modelValue?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface SignInstance {
|
||||
reset: () => void;
|
||||
generate: () => Promise<string>;
|
||||
}
|
||||
118
src/components/StrengthMeter/index.vue
Normal file
118
src/components/StrengthMeter/index.vue
Normal 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
src/components/SvgIcon/index.vue
Normal file
68
src/components/SvgIcon/index.vue
Normal 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
src/components/TagList/index.vue
Normal file
82
src/components/TagList/index.vue
Normal 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
src/components/Tip/index.vue
Normal file
18
src/components/Tip/index.vue
Normal 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
src/components/TreeSelect/index.vue
Normal file
181
src/components/TreeSelect/index.vue
Normal 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
src/components/Upload/Excel.vue
Normal file
165
src/components/Upload/Excel.vue
Normal 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>
|
||||
320
src/components/Upload/Image.vue
Normal file
320
src/components/Upload/Image.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<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">编辑</span>
|
||||
</div>
|
||||
<div class="handle-icon" @click="imgViewVisible = true">
|
||||
<el-icon :size="props.iconSize"><ZoomIn /></el-icon>
|
||||
<span v-if="!props.iconSize">查看</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">删除</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',
|
||||
'Enc-Flag': 'false',
|
||||
},
|
||||
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
src/components/Upload/i18n/en.ts
Normal file
19
src/components/Upload/i18n/en.ts
Normal 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
src/components/Upload/i18n/zh-cn.ts
Normal file
19
src/components/Upload/i18n/zh-cn.ts
Normal 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: '个文件',
|
||||
},
|
||||
};
|
||||
327
src/components/Upload/index.vue
Normal file
327
src/components/Upload/index.vue
Normal file
@@ -0,0 +1,327 @@
|
||||
<!--文件上传组件-->
|
||||
<template>
|
||||
<div class="w-full upload-file">
|
||||
<!-- 当禁用时只显示文件列表,不使用el-upload组件 -->
|
||||
<div v-if="props.disabled">
|
||||
<div v-if="fileList.length === 0" class="flex justify-center items-center px-4 text-gray-400 bg-gray-50 rounded-md p">
|
||||
<el-icon class="mr-2 text-lg"><Document /></el-icon>
|
||||
<span class="text-sm">无附件</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="(file, index) in fileList"
|
||||
:key="index"
|
||||
class="flex items-center px-4 py-3 mb-1 rounded transition-colors duration-200 cursor-pointer group hover:bg-blue-50"
|
||||
@click="handlePreview(file)"
|
||||
>
|
||||
<el-icon class="mr-3 text-blue-500"><Document /></el-icon>
|
||||
<span class="flex-1 text-gray-700 truncate transition-colors duration-200 group-hover:text-blue-600">
|
||||
{{ getFileName(file) }}
|
||||
</span>
|
||||
<el-icon class="text-gray-400 transition-colors duration-200 group-hover:text-blue-500"><Download /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 默认上传组件 -->
|
||||
<el-upload
|
||||
ref="fileUpload"
|
||||
v-if="props.type === 'default' && !props.disabled"
|
||||
: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' && !props.disabled"
|
||||
: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>
|
||||
<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>
|
||||
</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';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { Document, Download } from '@element-plus/icons-vue';
|
||||
|
||||
// 定义基础URL
|
||||
const baseUrl = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
// 获取文件名
|
||||
const getFileName = (file: any): string => {
|
||||
if (file.name) return file.name;
|
||||
if (file.url && file.url.includes('fileName=')) {
|
||||
return file.url.split('fileName=')[1];
|
||||
}
|
||||
return file.url ? file.url.split('/').pop() : 'File';
|
||||
};
|
||||
|
||||
interface FileItem {
|
||||
name?: string;
|
||||
url?: string;
|
||||
uid?: number;
|
||||
}
|
||||
|
||||
interface UploadFileItem {
|
||||
name: string;
|
||||
url: string;
|
||||
fileUrl: string;
|
||||
fileSize: number;
|
||||
fileName: string;
|
||||
fileType: string;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change']);
|
||||
|
||||
const number = ref(0);
|
||||
const fileList = ref<FileItem[]>([]);
|
||||
const uploadList = ref<UploadFileItem[]>([]);
|
||||
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), fileList.value);
|
||||
emit('update:modelValue', listToString(fileList.value));
|
||||
};
|
||||
|
||||
const handlePreview = (file: any) => {
|
||||
other.downBlobFile(file.url, {}, file.name);
|
||||
};
|
||||
|
||||
// 添加 handleExceed 函数
|
||||
const handleExceed = () => {
|
||||
useMessage().warning(`${t('excel.uploadLimit')} ${props.limit} ${t('excel.files')}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 将对象数组转为字符串,以逗号分隔。
|
||||
* @param list 待转换的对象数组。
|
||||
* @param separator 分隔符,默认为逗号。
|
||||
* @returns {string} 返回转换后的字符串。
|
||||
*/
|
||||
const listToString = (list: FileItem[], 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: any) => {
|
||||
if (typeof item === 'string') {
|
||||
item = { name: item.split('fileName=')[1], url: item };
|
||||
}
|
||||
item.uid = item.uid || new Date().getTime() + temp++;
|
||||
return item as FileItem;
|
||||
});
|
||||
} else {
|
||||
fileList.value = [];
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
fileUpload.value?.submit();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
submit,
|
||||
});
|
||||
</script>
|
||||
170
src/components/UserSelect/index.vue
Normal file
170
src/components/UserSelect/index.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="选择用户"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
@close="visible = false"
|
||||
style="--el-dialog-padding-primary: 0"
|
||||
>
|
||||
<div class="flex items-start">
|
||||
<div class="flex-1">
|
||||
<div class="mb-4 text-center font-bold">组织架构</div>
|
||||
<div class="h-[400px] overflow-auto">
|
||||
<el-scrollbar>
|
||||
<query-tree :placeholder="$t('common.queryDeptTip')" :query="deptData.queryList" @node-click="handleNodeClick">
|
||||
<!-- 没有数据权限提示 -->
|
||||
<template #default="{ node, data }">
|
||||
<el-tooltip v-if="data.isLock" class="item" effect="dark" :content="$t('sysuser.noDataScopeTip')" placement="right-start">
|
||||
<span
|
||||
>{{ node.label }}
|
||||
<SvgIcon name="ele-Lock" />
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-if="!data.isLock">{{ node.label }}</span>
|
||||
</template>
|
||||
</query-tree>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 ml-4">
|
||||
<div class="mb-4 text-center font-bold">请选择用户</div>
|
||||
<div class="h-[400px] overflow-auto">
|
||||
<el-checkbox
|
||||
v-for="user of userList"
|
||||
:key="user.userId"
|
||||
class="block"
|
||||
:label="user.name"
|
||||
:model-value="checkedUserList.some(item => item.userId === user.userId)"
|
||||
@change="handleCheck(user)"
|
||||
/>
|
||||
<div class="text-center" v-loading="userLoading">
|
||||
<el-button type="primary" link @click="loadNextPage">
|
||||
{{ hasMore ? '加载更多' : '没有更多了' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 ml-4">
|
||||
<div class="mb-4 text-center font-bold">已选用户</div>
|
||||
<div class="h-[400px] overflow-auto">
|
||||
<el-checkbox
|
||||
v-for="user of checkedUserList"
|
||||
:key="user.userId"
|
||||
class="block"
|
||||
:label="user.name"
|
||||
:model-value="checkedUserList.some(item => item.userId === user.userId)"
|
||||
@change="handleCheck(user)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleOk">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { deptTree } from '/@/api/admin/dept'
|
||||
import { pageList } from '/@/api/admin/user'
|
||||
|
||||
const emit = defineEmits(['ok'])
|
||||
const visible = defineModel<boolean>('visible')
|
||||
const props = defineProps({
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 动态加载组件
|
||||
const QueryTree = defineAsyncComponent(() => import('/@/components/QueryTree/index.vue'));
|
||||
|
||||
// 部门树使用的数据
|
||||
const deptData = reactive({
|
||||
queryList: async (name: String) => {
|
||||
const res = await deptTree({
|
||||
deptName: name,
|
||||
});
|
||||
return res.data
|
||||
},
|
||||
});
|
||||
|
||||
const selectedDept = ref<any>(null)
|
||||
const handleNodeClick = (e: any) => {
|
||||
selectedDept.value = e
|
||||
pagination.current = 1
|
||||
fetchUserList()
|
||||
}
|
||||
|
||||
// 获取部门用户
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
size: 10,
|
||||
total: 0
|
||||
})
|
||||
const userList = ref<any[]>([])
|
||||
const userLoading = ref<boolean>(false)
|
||||
const fetchUserList = async () => {
|
||||
// if (!selectedDept.value || userLoading.value) return
|
||||
userLoading.value = true
|
||||
try {
|
||||
const params = {
|
||||
current: pagination.current,
|
||||
size: pagination.size,
|
||||
deptId: selectedDept.value?.id,
|
||||
// username: '',
|
||||
// phone: ''
|
||||
}
|
||||
const res = await pageList(params)
|
||||
const records = res.data?.records || []
|
||||
pagination.total = res.data?.total || 0
|
||||
if (pagination.current === 1) {
|
||||
userList.value = records
|
||||
} else {
|
||||
userList.value = userList.value.concat(records)
|
||||
}
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const hasMore = computed(() => {
|
||||
return userList.value.length < pagination.total
|
||||
})
|
||||
|
||||
const loadNextPage = () => {
|
||||
if (!hasMore.value) return
|
||||
pagination.current += 1
|
||||
fetchUserList()
|
||||
}
|
||||
|
||||
// 选择用户
|
||||
const checkedUserList = ref<any[]>([])
|
||||
const handleCheck = (user: any) => {
|
||||
if (checkedUserList.value.some(item => item.userId === user.userId)) {
|
||||
checkedUserList.value = checkedUserList.value.filter(item => item.userId !== user.userId)
|
||||
} else {
|
||||
// 单选多选逻辑判断
|
||||
if (props.multiple) {
|
||||
checkedUserList.value.push(user)
|
||||
} else {
|
||||
checkedUserList.value = [user]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确定选择
|
||||
const handleOk = () => {
|
||||
emit('ok', props.multiple ? checkedUserList.value : checkedUserList.value[0])
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchUserList()
|
||||
})
|
||||
</script>
|
||||
524
src/components/Verifition/Verify.vue
Normal file
524
src/components/Verifition/Verify.vue
Normal 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>
|
||||
274
src/components/Verifition/Verify/VerifyPoints.vue
Normal file
274
src/components/Verifition/Verify/VerifyPoints.vue
Normal file
@@ -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>
|
||||
516
src/components/Verifition/Verify/VerifySlide.vue
Normal file
516
src/components/Verifition/Verify/VerifySlide.vue
Normal file
@@ -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
src/components/Verifition/api/index.ts
Normal file
23
src/components/Verifition/api/index.ts
Normal 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
src/components/Verifition/i18n/en.ts
Normal file
11
src/components/Verifition/i18n/en.ts
Normal 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
src/components/Verifition/i18n/zh-cn.ts
Normal file
11
src/components/Verifition/i18n/zh-cn.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export default {
|
||||
verify: {
|
||||
complete: '请完成安全验证',
|
||||
slide: {
|
||||
explain: '向右滑动完成验证',
|
||||
success: '验证成功',
|
||||
fail: '验证失败',
|
||||
time: '{time}s验证成功'
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/components/Verifition/utils/ase.js
Normal file
11
src/components/Verifition/utils/ase.js
Normal 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
src/components/Verifition/utils/util.js
Normal file
97
src/components/Verifition/utils/util.js
Normal 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
src/components/VideoPlayer/index.vue
Normal file
72
src/components/VideoPlayer/index.vue
Normal 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
src/components/Websocket/index.vue
Normal file
160
src/components/Websocket/index.vue
Normal 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>
|
||||
95
src/components/Wechat/fileUpload/index.vue
Normal file
95
src/components/Wechat/fileUpload/index.vue
Normal file
@@ -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('/api/mp/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>
|
||||
166
src/components/Wechat/wx-material-select/main.vue
Normal file
166
src/components/Wechat/wx-material-select/main.vue
Normal file
@@ -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
src/components/Wechat/wx-msg/card.scss
Normal file
101
src/components/Wechat/wx-msg/card.scss
Normal 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
src/components/Wechat/wx-msg/comment.scss
Normal file
92
src/components/Wechat/wx-msg/comment.scss
Normal 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
src/components/Wechat/wx-msg/index.vue
Normal file
258
src/components/Wechat/wx-msg/index.vue
Normal 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
src/components/Wechat/wx-news/index.vue
Normal file
90
src/components/Wechat/wx-news/index.vue
Normal 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
src/components/Wechat/wx-reply/index.vue
Normal file
309
src/components/Wechat/wx-reply/index.vue
Normal 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
src/components/auth/auth.vue
Normal file
26
src/components/auth/auth.vue
Normal 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
src/components/auth/authAll.vue
Normal file
27
src/components/auth/authAll.vue
Normal 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
src/components/auth/auths.vue
Normal file
32
src/components/auth/auths.vue
Normal 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>
|
||||
69
src/components/index.ts
Normal file
69
src/components/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import Pagination from '/@/components/Pagination/index.vue';
|
||||
import RightToolbar from '/@/components/RightToolbar/index.vue';
|
||||
import DictTag from '/@/components/DictTag/index.vue';
|
||||
import DictSelect from '/@/components/DictTag/Select.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 Sign from '/@/components/Sign/index.vue';
|
||||
import ChinaArea from '/@/components/ChinaArea/index.vue';
|
||||
import OrgSelector from '/@/components/OrgSelector/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';
|
||||
|
||||
// 部门树组件
|
||||
import vue3TreeOrg from 'vue3-tree-org';
|
||||
import 'vue3-tree-org/lib/vue3-tree-org.css';
|
||||
|
||||
// 导入 FcDesigner
|
||||
import FcDesigner from 'form-create-designer';
|
||||
|
||||
// 导入声明
|
||||
import { App } from 'vue';
|
||||
|
||||
export default {
|
||||
install(app: App) {
|
||||
app.component('DictTag', DictTag);
|
||||
app.component('DictSelect', DictSelect);
|
||||
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);
|
||||
app.component('ChinaArea', ChinaArea);
|
||||
app.component('OrgSelector', OrgSelector);
|
||||
app.component('Sign', Sign);
|
||||
|
||||
// 导入全部的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(vue3TreeOrg); // 组织架构组件
|
||||
app.use(FcDesigner);
|
||||
app.use(FcDesigner.formCreate);
|
||||
},
|
||||
};
|
||||
960
src/components/invBid/common/PostInvestmentEvaluationForm.vue
Normal file
960
src/components/invBid/common/PostInvestmentEvaluationForm.vue
Normal file
@@ -0,0 +1,960 @@
|
||||
<template>
|
||||
<div class="post-investment-evaluation-form">
|
||||
<div class="form-section-title">{{ t('postInvestmentEvaluationForm.sectionTitle') }}</div>
|
||||
<el-form :model="submitFormData" :rules="rules" ref="submitFormDataRef" label-width="200px" class="evaluation-form">
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item prop="actualTotalIncome" :label="t('postInvestmentEvaluationForm.actualTotalRevenue')" required>
|
||||
<el-input v-model="submitFormData.actualTotalIncome"
|
||||
:placeholder="t('postInvestmentEvaluationForm.inputPlaceholder')">
|
||||
<template #append>{{ t('postInvestmentEvaluationForm.unitSuffix') }}</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item prop="actualInvestmentGain" :label="t('postInvestmentEvaluationForm.actualInvestmentIncome')" required>
|
||||
<el-input v-model="submitFormData.actualInvestmentGain"
|
||||
:placeholder="t('postInvestmentEvaluationForm.inputPlaceholder')">
|
||||
<template #append>{{ t('postInvestmentEvaluationForm.unitSuffix') }}</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="24">
|
||||
<el-form-item prop="actualInvestmentReturnRate" :label="t('postInvestmentEvaluationForm.actualInvestmentReturnRate')" required>
|
||||
<el-input v-model="actualInvestmentReturnRate" readonly >
|
||||
<template #suffix>%</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="t('postInvestmentEvaluationForm.investmentResults')" >
|
||||
<el-input v-model="submitFormData.investmentResults" type="text"
|
||||
:placeholder="t('postInvestmentEvaluationForm.inputPlaceholder')">
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="t('postInvestmentEvaluationForm.profitLossSituation')">
|
||||
<el-input v-model="submitFormData.profitLossSituation" type="text"
|
||||
:placeholder="t('postInvestmentEvaluationForm.inputPlaceholder')">
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="t('postInvestmentEvaluationForm.existingProblems')">
|
||||
<el-input v-model="submitFormData.existingProblems" type="text"
|
||||
:placeholder="t('postInvestmentEvaluationForm.inputPlaceholder')">
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="t('postInvestmentEvaluationForm.improvementSuggestions')">
|
||||
<el-input v-model="submitFormData.improvementSuggestions" type="text"
|
||||
:placeholder="t('postInvestmentEvaluationForm.inputPlaceholder')">
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="t('postInvestmentEvaluationForm.conclusion')">
|
||||
<el-input v-model="submitFormData.conclusion" type="text"
|
||||
:placeholder="t('postInvestmentEvaluationForm.inputPlaceholder')">
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="t('postInvestmentEvaluationForm.attachmentUpload')">
|
||||
<UploadFile :modelValue="submitFormData.attachmentUrl" @change="(_:any,data:any[])=>uploadChange(1,_,data)" :fileSize="20" type="simple" :limit="10"
|
||||
:isShowTip="false" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<div class="basic-info-section">
|
||||
<div class="form-section-title">{{ t('postInvestmentEvaluationForm.basicInfo.title') }}</div>
|
||||
<div class="basic-info-table">
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.basicInfo.projectName') }}</div>
|
||||
<div class="info-value info-value-full">{{ formData.projectName || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.basicInfo.constructionStage') }}</div>
|
||||
<div class="info-value">{{ initTypeString(constructionStageOptions,lastListItem.constructionStage) || 'XXX' }}</div>
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.basicInfo.projectNature') }}</div>
|
||||
<div class="info-value">{{ initTypeString(projectNatureOptions,formData.projectNature) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.basicInfo.projectImplementationUnit') }}
|
||||
</div>
|
||||
<div class="info-value">{{ formData.projectMainEntity || 'XXX' }}</div>
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.basicInfo.projectOwnerUnit') }}</div>
|
||||
<div class="info-value">{{ formData.projectOwnerUnit || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.investmentCategory')
|
||||
}}</div>
|
||||
<div class="info-value required">{{ initTypeString(investmentCategoryOptions,formData.investmentCategory) || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.investmentRegion') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ initTypeString(investmentAreaOptions,formData.investmentArea) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.projectAddress') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ formData.projectAddress || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.projectDetailAddress')
|
||||
}}
|
||||
</div>
|
||||
<div class="info-value required">{{ formData.projectAddressDetail || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.projectDirection') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ formData.projectInvestmentDirection || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.directionSubdivision')
|
||||
}}
|
||||
</div>
|
||||
<div class="info-value required">{{initTypeString(projectDirectionDetailsOptions,formData.investmentDirectionSegmentation) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.projectSource') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ initTypeString(projectSourceOptions,formData.projectSource) || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.constructionNature')
|
||||
}}</div>
|
||||
<div class="info-value required">{{ initTypeString(constructionNatureOptions,formData.constructionNature) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.basicInfo.majorInvestmentProject') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ formData.majorInvestmentProjects || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.keyProject') }}</div>
|
||||
<div class="info-value required">{{ initTypeString(projectImportantOptions,formData.keyProject) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.isWithinMainBusiness')
|
||||
}}
|
||||
</div>
|
||||
<div class="info-value required">{{ formData.isMainBusiness || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.mainBusinessType') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ initTypeString(mainBusinessOptions,formData.mainBusinessTypes) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.mainBusinessCode') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ formData.mainBusinessCode || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.isManufacturing') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ formData.isManufacturingIndustry || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.basicInfo.strategicEmergingIndustry') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ initTypeString(strategicIndustryOptions,formData.isStrategicEmergingIndustries) || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.cityStrategy') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ initTypeString(cityStrategyOptions,formData.urbanStrategy) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.basicInfo.projectImplementationStart') }}
|
||||
</div>
|
||||
<div class="info-value">{{ formData.projectStartTime || 'XXX' }}</div>
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.basicInfo.projectImplementationEnd') }}
|
||||
</div>
|
||||
<div class="info-value">{{ formData.projectEndTime || 'XXX' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="additional-info-table">
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.additionalInfo.projectOverview')
|
||||
}}
|
||||
</div>
|
||||
<div class="info-value">{{ formData.projectDesc || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.additionalInfo.projectPreliminaryPlan')
|
||||
}}</div>
|
||||
<div class="info-value">
|
||||
<UploadFile :modelValue="JSON.parse(formData.projectPreliminaryPlanAttachment || '[]') || []" :fileSize="20" type="simple"
|
||||
:limit="10" :isShowTip="false" disabled/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.additionalInfo.remark') }}</div>
|
||||
<div class="info-value">{{ formData.remark || 'XXX' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="annual-investment-plan-section">
|
||||
<div class="form-section-title">{{ t('postInvestmentEvaluationForm.annualPlan.title') }}</div>
|
||||
<el-table :data="formData.projectInvestmentEntities" border style="width: 100%" class="annual-plan-table">
|
||||
<el-table-column type="index" :label="t('postInvestmentEvaluationForm.annualPlan.index')" width="60"
|
||||
align="center" />
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.plannedInvestmentYear')"
|
||||
prop="plannedInvestmentYear" min-width="120" align="center" />
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.plannedImageAmount')"
|
||||
prop="plannedImageAmount" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.plannedImageAmount ? `${row.plannedImageAmount}
|
||||
${t('postInvestmentEvaluationForm.unitSuffix')}` : '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.plannedPaymentAmount')"
|
||||
prop="plannedPaymentAmount" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.plannedPaymentAmount ? `${row.plannedPaymentAmount}
|
||||
${t('postInvestmentEvaluationForm.unitSuffix')}` : '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.fundingSource')" align="center">
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.ownFunds')" prop="ownFunds"
|
||||
min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.selfFunding ? `${row.selfFunding} ${t('postInvestmentEvaluationForm.unitSuffix')}` : ''
|
||||
}}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.fiscalFunds')"
|
||||
prop="fiscalFunds" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.fiscalFunding ? `${row.fiscalFunding}
|
||||
${t('postInvestmentEvaluationForm.unitSuffix')}` : '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.externalFundraising')"
|
||||
prop="externalFundraising" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.externalFunding ? `${row.externalFunding}
|
||||
${t('postInvestmentEvaluationForm.unitSuffix')}` : '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.otherFunds')"
|
||||
prop="otherFunds" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.otherFunding ? `${row.otherFunding} ${t('postInvestmentEvaluationForm.unitSuffix')}`
|
||||
: '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="t('postInvestmentEvaluationForm.annualPlan.fiscalFundsSourceExplanation')"
|
||||
prop="fiscalFundingSource" min-width="180" align="center" />
|
||||
<el-table-column
|
||||
:label="t('postInvestmentEvaluationForm.annualPlan.otherFundsSourceExplanation')"
|
||||
prop="otherFundingSource" min-width="180" align="center" />
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="decision-info-section">
|
||||
<div class="form-section-title">{{ t('postInvestmentEvaluationForm.decisionInfo.title') }}</div>
|
||||
<div class="decision-info-table">
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.decisionInfo.decisionType') }}</div>
|
||||
<div class="info-value">{{ formData.decisionType || 'XXX' }}</div>
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.decisionInfo.isProjectApprovalProcedureCompleted') }}</div>
|
||||
<div class="info-value">{{ formData.isProjectApprovalCompleted || 'XXX'
|
||||
}}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.decisionInfo.projectApprovalDocumentNumber')
|
||||
}}</div>
|
||||
<div class="info-value">{{ formData.projectApprovalFileNo || 'XXX' }}
|
||||
</div>
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.decisionInfo.projectApprovalDocumentInfo') }}
|
||||
</div>
|
||||
<div class="info-value">{{ formData.projectApprovalFileInfo || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.decisionInfo.isDecisionProcedureCompleted')
|
||||
}}</div>
|
||||
<div class="info-value">{{ formData.isDecisionProcedureCompleted || 'XXX' }}</div>
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.decisionInfo.decisionProcedureDocumentNumber') }}</div>
|
||||
<div class="info-value">{{ formData.decisionProcedureFileNo || 'XXX' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.decisionInfo.decisionDocumentInfo')
|
||||
}}</div>
|
||||
<div class="info-value">{{ formData.decisionFileInfo || 'XXX' }}</div>
|
||||
<div class="info-label"></div>
|
||||
<div class="info-value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-report-info-section">
|
||||
<div class="form-section-title">{{ t('postInvestmentEvaluationForm.progressReportInfo.title') }}</div>
|
||||
<div class="progress-report-info-table">
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.projectStatus')
|
||||
}}</div>
|
||||
<div class="info-value">{{ initTypeString(projectStatusOptions,lastListItem.projectStatus) || 'XXX' }}</div>
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.constructionStage') }}
|
||||
</div>
|
||||
<div class="info-value">{{ initTypeString(constructionStageOptions,lastListItem.constructionStage) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.totalInvestmentAmount') }}</div>
|
||||
<div class="info-value">{{ lastListItem.projectTotalAmount }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.cumulativeInvestmentToMonthEnd') }}</div>
|
||||
<div class="info-value">{{ lastListItem.cumulativeInvestmentToDate
|
||||
}}</div>
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.investmentCompletionRate') }}</div>
|
||||
<div class="info-value">{{ lastListItem.investmentCompletionRate || 'XXX' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.cumulativePaymentToMonthEnd') }}</div>
|
||||
<div class="info-value">{{ lastListItem.cumulativeInvestmentToDate || 'XXX' }}
|
||||
</div>
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.paymentCompletionRate') }}
|
||||
</div>
|
||||
<div class="info-value">{{ lastListItem.paymentCompletionRate || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.progressReportInfo.effectiveness') }}
|
||||
</div>
|
||||
<div class="info-value">{{ lastListItem.achievements || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.coordinationMatters') }}
|
||||
</div>
|
||||
<div class="info-value">{{lastListItem.coordinationIssues || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.nextWorkArrangements') }}
|
||||
</div>
|
||||
<div class="info-value">{{ lastListItem.nextWorkPlan || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.currentStageEvidenceMaterials') }}</div>
|
||||
<div class="info-value">
|
||||
<UploadFile :modelValue="JSON.parse(lastListItem.supportingDocuments || '[]') || []"
|
||||
:fileSize="20" type="simple" :limit="10" :isShowTip="false" disabled/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.progressReportInfo.remark') }}</div>
|
||||
<div class="info-value">{{ lastListItem.remarks || 'XXX' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-records-section">
|
||||
<el-tabs v-model="activeTab" class="project-records-tabs">
|
||||
<el-tab-pane :label="t('postInvestmentEvaluationForm.projectRecords.adjustmentRecord')"
|
||||
name="adjustment">
|
||||
<el-table :data="projectRecordsData.adjustmentRecords" border style="width: 100%"
|
||||
class="project-records-table">
|
||||
<el-table-column type="index"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.index')" width="60"
|
||||
align="center" />
|
||||
<el-table-column prop="projectName"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.projectName')" min-width="150"
|
||||
align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary">{{ row.projectName || 'XXX' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="projectOverview"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.projectOverview')"
|
||||
min-width="150" align="center" />
|
||||
<el-table-column prop="projectPlan"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.projectPlan')" min-width="120"
|
||||
align="center" />
|
||||
<el-table-column prop="attachments"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.attachments')" min-width="100"
|
||||
align="center" />
|
||||
<el-table-column prop="projectNature"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.projectNature')" min-width="120"
|
||||
align="center" />
|
||||
<el-table-column prop="projectDirection"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.projectDirection')"
|
||||
min-width="120" align="center" />
|
||||
<el-table-column prop="projectImplementationStart"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.projectImplementationStart')"
|
||||
min-width="160" align="center" />
|
||||
<el-table-column prop="projectImplementationEnd"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.projectImplementationEnd')"
|
||||
min-width="160" align="center" />
|
||||
<el-table-column prop="totalInvestmentAmount"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.totalInvestmentAmount')"
|
||||
min-width="140" align="center" />
|
||||
<el-table-column prop="handler"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.handler')" min-width="100"
|
||||
align="center" />
|
||||
<el-table-column prop="updateTime"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.updateTime')" min-width="160"
|
||||
align="center" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="t('postInvestmentEvaluationForm.projectRecords.progressDeclarationRecord')"
|
||||
name="progress">
|
||||
<el-table :data="projectRecordsData.progressRecords" border style="width: 100%"
|
||||
class="project-records-table">
|
||||
<el-table-column type="index"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.index')" width="60"
|
||||
align="center" />
|
||||
<el-table-column prop="projectName" :label="t('progressReportRecord.table.projectName')"
|
||||
min-width="200" />
|
||||
<el-table-column prop="annualReport" :label="t('progressReportRecord.table.reportDate')" min-width="120"
|
||||
sortable />
|
||||
<el-table-column prop="implementingBody"
|
||||
:label="t('progressReportRecord.table.projectImplementationUnit')" min-width="200" />
|
||||
<el-table-column prop="isFinalApplication" :label="t('progressReportRecord.table.isLastDeclaration')"
|
||||
min-width="160" >
|
||||
<template #default="{ row }">
|
||||
{{row.isFinalApplication === 1 ? t('planApply.common.yes') : t('planApply.common.no')}}
|
||||
</template>
|
||||
|
||||
</el-table-column>
|
||||
<el-table-column prop="projectStatus" :label="t('progressReportRecord.table.projectStatus')"
|
||||
min-width="120" >
|
||||
<template #default="{row}">
|
||||
{{initTypeString(projectStatusOptions,row.projectStatus)}}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="constructionStage" :label="t('progressReportRecord.table.constructionStage')"
|
||||
min-width="120" >
|
||||
<template #default="{row}">
|
||||
{{initTypeString(constructionStageOptions,row.constructionStage)}}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="investmentPlanCompletionRate"
|
||||
:label="t('progressReportRecord.table.currentYearPlannedImageTotal')" min-width="180" />
|
||||
<el-table-column prop="ytdRemainingInvestment"
|
||||
:label="t('progressReportRecord.table.enterpriseCumulativeInvestmentToMonthEnd')" min-width="200" />
|
||||
<el-table-column prop="investmentCompletionRate"
|
||||
:label="t('progressReportRecord.table.currentYearImageCompletion')" min-width="180" />
|
||||
<el-table-column prop="projectTotalAmount"
|
||||
:label="t('progressReportRecord.table.totalInvestmentAmount')" min-width="160" />
|
||||
<el-table-column prop="cumulativeInvestmentToDate"
|
||||
:label="t('progressReportRecord.table.cumulativeInvestmentToMonthEnd')" min-width="200" />
|
||||
<el-table-column prop="completionRate" :label="t('progressReportRecord.table.totalCompletionRate')"
|
||||
min-width="140" />
|
||||
<el-table-column prop="achievements" :label="t('progressReportRecord.table.effectiveness')"
|
||||
min-width="120" />
|
||||
<el-table-column prop="coordinationIssues" :label="t('progressReportRecord.table.coordinationMatters')"
|
||||
min-width="160" />
|
||||
<el-table-column prop="nextWorkPlan"
|
||||
:label="t('progressReportRecord.table.nextWorkArrangements')" min-width="160" />
|
||||
<el-table-column prop="updateBy" :label="t('progressReportRecord.table.lastUpdater')"
|
||||
min-width="140" />
|
||||
<el-table-column prop="updateTime" :label="t('progressReportRecord.table.lastUpdateTime')"
|
||||
min-width="180" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.postInvestmentEvaluationRecord')"
|
||||
name="evaluation">
|
||||
<el-table :data="projectRecordsData.evaluationRecords" border style="width: 100%"
|
||||
class="project-records-table">
|
||||
<el-table-column type="index"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.index')" width="60"
|
||||
align="center" />
|
||||
<el-table-column prop="actualTotalIncome"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.actualTotalIncome')"
|
||||
min-width="140" align="center" />
|
||||
<el-table-column prop="actualInvestmentGain"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.actualInvestmentIncome')"
|
||||
min-width="140" align="center" />
|
||||
<el-table-column prop="actualInvestmentReturnRate"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.actualInvestmentIncomeRate')"
|
||||
min-width="160" align="center" />
|
||||
<el-table-column prop="attachmentUrl"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.attachments')" min-width="100"
|
||||
align="center" >
|
||||
<template #default="{row}">
|
||||
<UploadFile :modelValue="JSON.parse(row.attachmentUrl || '[]') || []" :fileSize="20" type="simple" :limit="10"
|
||||
:isShowTip="false" disabled />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createBy"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.handler')" min-width="100"
|
||||
align="center" />
|
||||
<el-table-column prop="createTime"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.reportingTime')" min-width="160"
|
||||
align="center" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import UploadFile from '/@/components/Upload/index.vue';
|
||||
import {
|
||||
cityStrategyOptions,
|
||||
constructionNatureOptions, constructionStageOptions,
|
||||
Enums, investmentAreaOptions, investmentCategoryOptions, mainBusinessOptions, projectDirectionDetailsOptions,
|
||||
projectImportantOptions, projectNatureOptions,
|
||||
projectSourceOptions, projectStatusOptions, strategicIndustryOptions
|
||||
} from '/@/hooks/enums';
|
||||
import { PostInvestmentEvaluation, ProjectInvestmentInfo } from '/@/views/invBid/postInvestmentEvaluation/interface/type';
|
||||
import { InvestmentProjectProgress } from '/@/views/invMid/progressReport/interface/type';
|
||||
import { FormInstance, FormRules } from 'element-plus';
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n();
|
||||
|
||||
/**
|
||||
* 项目记录标签页状态
|
||||
*/
|
||||
const activeTab = ref('adjustment');
|
||||
|
||||
/**
|
||||
* 项目记录数据
|
||||
*/
|
||||
const projectRecordsData:{
|
||||
adjustmentRecords: InvestmentProjectProgress[];
|
||||
progressRecords: InvestmentProjectProgress[];
|
||||
evaluationRecords: PostInvestmentEvaluation[];
|
||||
} = reactive({
|
||||
// 调整记录
|
||||
adjustmentRecords: [],
|
||||
// 进度记录
|
||||
progressRecords: [],
|
||||
// 后评价记录
|
||||
evaluationRecords: []
|
||||
});
|
||||
|
||||
/**
|
||||
* 组件属性定义
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/**
|
||||
* 表单数据模型
|
||||
*/
|
||||
modelValue: ProjectInvestmentInfo;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 事件发射器定义
|
||||
*/
|
||||
const emit = defineEmits<{
|
||||
/**
|
||||
* 更新表单数据事件
|
||||
* @param e
|
||||
* @param value 新的表单数据
|
||||
*/
|
||||
(e: 'update:modelValue', value: ProjectInvestmentInfo): void;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 默认表单数据
|
||||
*/
|
||||
const defaultForm: ProjectInvestmentInfo = {
|
||||
// 来自ProjectPlanApplyFormItem的字段(这些字段已经在ProjectPlanApplyFormItem中定义)
|
||||
id: undefined,
|
||||
projectName: '',
|
||||
projectNature: '',
|
||||
groupCompany: '',
|
||||
projectOwnerUnit: '',
|
||||
projectMainEntity: '',
|
||||
investmentCategory: '',
|
||||
investmentArea: '',
|
||||
projectAddress: '',
|
||||
projectAddressDetail: '',
|
||||
projectInvestmentDirection: '',
|
||||
investmentDirectionSegmentation: '',
|
||||
constructionNature: '',
|
||||
keyProject: '',
|
||||
isMainBusiness: '',
|
||||
mainBusinessTypes: '',
|
||||
mainBusinessCode: '',
|
||||
isManufacturingIndustry: '',
|
||||
urbanStrategy: '',
|
||||
projectSource: '',
|
||||
majorInvestmentProjects: '',
|
||||
isStrategicEmergingIndustries: '',
|
||||
projectStartTime: '',
|
||||
projectEndTime: '',
|
||||
planInvestmentYear: '',
|
||||
planPaymentLimit: '',
|
||||
ownedFunds: '',
|
||||
financialFunds: '',
|
||||
externalRaisedCapital: '',
|
||||
otherFunds: '',
|
||||
governmentFundSourceDesc: '',
|
||||
otherFundSourceDesc: '',
|
||||
projectTotalAmount: '',
|
||||
lastYearCompleted: '',
|
||||
ourInvestmentTotalAmount: '',
|
||||
ourLastYearCompleted: '',
|
||||
projectDesc: '',
|
||||
projectPreliminaryPlan: '',
|
||||
projectPreliminaryPlanAttachment: '',
|
||||
remark: '',
|
||||
decisionType: '',
|
||||
isProjectApprovalCompleted: '',
|
||||
isDecisionProcedureCompleted: '',
|
||||
projectApprovalFileNo: '',
|
||||
projectApprovalFileInfo: '',
|
||||
decisionProcedureFileNo: '',
|
||||
decisionFileInfo: '',
|
||||
planImageQuota:'',
|
||||
// 其他字段
|
||||
projectInvestmentEntities: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* 表单响应式数据
|
||||
*/
|
||||
const formData = reactive<ProjectInvestmentInfo>({ ...defaultForm });
|
||||
/**
|
||||
* 提交数据表单
|
||||
* */
|
||||
const submitFormData = ref<PostInvestmentEvaluation>({} as PostInvestmentEvaluation);
|
||||
const requiredRule = [{ required: true, message: `${t('该字段必填')}` },{validator: (rule: any, value:any,callback: (e?: Error) => void) => {
|
||||
// 检查是否为空值
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
// 检查是否为有效数字
|
||||
const numValue = Number(value)
|
||||
if (!isNaN(numValue) && isFinite(numValue)) {
|
||||
callback()
|
||||
} else {
|
||||
callback(new Error(`${t('common.isNumber')}`))
|
||||
}
|
||||
}}]
|
||||
const rules = ref<FormRules<PostInvestmentEvaluation>>({
|
||||
actualTotalIncome: requiredRule,
|
||||
actualInvestmentGain:requiredRule,
|
||||
actualInvestmentReturnRate:[requiredRule[0]],
|
||||
})
|
||||
const submitFormDataRef = ref<FormInstance>()
|
||||
/**
|
||||
* 初始化时将props.modelValue映射到formData
|
||||
*/
|
||||
if (props.modelValue) {
|
||||
const mappedData: ProjectInvestmentInfo = {
|
||||
...props.modelValue,
|
||||
};
|
||||
Object.assign(formData, mappedData);
|
||||
}
|
||||
const lastListItem = ref<InvestmentProjectProgress>({} as InvestmentProjectProgress)
|
||||
/**
|
||||
* 监听 formData 变化,同步到父组件
|
||||
*/
|
||||
watch(
|
||||
formData,
|
||||
() => {
|
||||
// 构造符合ProjectPlanApplyFormItem接口的数据
|
||||
const projectPlanData: ProjectInvestmentInfo = {
|
||||
...formData
|
||||
};
|
||||
emit('update:modelValue', projectPlanData as ProjectInvestmentInfo);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* 监听 props.modelValue 变化,同步到 formData
|
||||
*/
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
// 将ProjectPlanApplyFormItem的字段映射到PostInvestmentEvaluationFormData
|
||||
const mappedData: ProjectInvestmentInfo = {
|
||||
...newVal,
|
||||
};
|
||||
if (newVal.id != null) {
|
||||
submitFormData.value.projectId = newVal.id;
|
||||
}
|
||||
lastListItem.value = (newVal.investmentProjectsProgressEntities && newVal.investmentProjectsProgressEntities.length > 0) ? newVal.investmentProjectsProgressEntities[newVal.investmentProjectsProgressEntities.length - 1] : {} as InvestmentProjectProgress;
|
||||
Object.assign(formData, mappedData);
|
||||
projectRecordsData.progressRecords = newVal.investmentProjectsProgressEntities || []
|
||||
projectRecordsData.evaluationRecords = newVal.evaluationRecordEntities || []
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
const actualInvestmentReturnRate = ref('');
|
||||
watch(()=>submitFormData.value,(newData)=>{
|
||||
// actualInvestmentReturnRate = newData.actualInvestmentGain / newData.actualTotalIncome;
|
||||
submitFormData.value.actualInvestmentReturnRate = calculateReturnRate(newData.actualInvestmentGain,newData.actualTotalIncome)
|
||||
actualInvestmentReturnRate.value = submitFormData.value.actualInvestmentReturnRate.toFixed(2)
|
||||
},{deep:true})
|
||||
/**
|
||||
* 根据枚举值获取对应的标签文本
|
||||
* @param enums 枚举数组
|
||||
* @param type 枚举值
|
||||
* @returns 对应的标签文本
|
||||
*/
|
||||
const initTypeString = (enums: Enums[], type: string) => {
|
||||
return enums.find(item => item.value === type)?.label;
|
||||
}
|
||||
// 计算实际投资收益率
|
||||
const calculateReturnRate = (totalIncome: string | number,investmentIncome: string | number) => {
|
||||
if (!investmentIncome || !totalIncome) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 转换为数字
|
||||
const income = parseFloat(investmentIncome as string);
|
||||
const total = parseFloat(totalIncome as string);
|
||||
|
||||
// 检查是否为有效数字
|
||||
if (isNaN(income) || isNaN(total)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 计算投资本金 = 总收入 - 投资收益
|
||||
const principal = income;
|
||||
|
||||
// 避免除零错误
|
||||
if (principal <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 计算投资收益率:投资收益 / 投资本金
|
||||
let rate = (total / income) * 100;
|
||||
return rate;
|
||||
};
|
||||
defineExpose({
|
||||
validateRefFn: async ()=>{
|
||||
const res = await submitFormDataRef.value?.validate();
|
||||
if (res){
|
||||
return submitFormData.value;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
})
|
||||
const uploadChange = (type:number,_:any,data:any[],index?:number) => {
|
||||
if (!data || Object.prototype.toString.call(data) !== '[object Array]' || data.length < 1){
|
||||
if (type === 1){
|
||||
submitFormData.value.attachmentUrl = []
|
||||
}
|
||||
if (type === 2){
|
||||
formData.projectPreliminaryPlanAttachment = []
|
||||
}
|
||||
if (type === 3){
|
||||
lastListItem.value.supportingDocuments = []
|
||||
}
|
||||
if (type === 4 && index != null){
|
||||
projectRecordsData.evaluationRecords[index].attachmentUrl = []
|
||||
}
|
||||
}
|
||||
const newData = data.map(item =>{
|
||||
return {
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
}
|
||||
})
|
||||
if (type === 1){
|
||||
submitFormData.value.attachmentUrl = newData
|
||||
}
|
||||
if (type === 2){
|
||||
formData.projectPreliminaryPlanAttachment = newData
|
||||
}
|
||||
if (type === 3){
|
||||
lastListItem.value.supportingDocuments = newData
|
||||
}
|
||||
if (type === 4 && index != null){
|
||||
projectRecordsData.evaluationRecords[index].attachmentUrl = newData
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.post-investment-evaluation-form {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0 0 20px;
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
.evaluation-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 表单 label 左对齐 */
|
||||
.evaluation-form :deep(.el-form-item__label) {
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* 必填字段标签显示为红色 */
|
||||
.evaluation-form :deep(.el-form-item.is-required .el-form-item__label) {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.form-row:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.basic-info-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.basic-info-table,
|
||||
.additional-info-table {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.additional-info-table {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.decision-info-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.decision-info-table {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.progress-report-info-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.progress-report-info-table {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.project-records-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.project-records-tabs {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.project-records-table {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.annual-investment-plan-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.annual-plan-table {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.annual-plan-table :deep(.el-table__cell) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.annual-plan-table :deep(.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.info-row-full {
|
||||
grid-template-columns: 1fr 3fr;
|
||||
}
|
||||
|
||||
.info-row-single {
|
||||
grid-template-columns: 1fr 3fr;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
background-color: #f0f7ff;
|
||||
padding: 12px 16px;
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-label.required {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
background-color: #fff;
|
||||
padding: 12px 16px;
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
color: var(--el-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-value-full {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.info-row .info-value:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,775 @@
|
||||
<template>
|
||||
<div class="post-investment-evaluation-form">
|
||||
<div class="form-section-title">{{ t('postInvestmentEvaluationForm.basicInfo.title') }}</div>
|
||||
<div class="basic-info-section">
|
||||
<div class="basic-info-table">
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.basicInfo.projectName') }}</div>
|
||||
<div class="info-value info-value-full">{{ formData.projectName || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.basicInfo.constructionStage') }}</div>
|
||||
<div class="info-value">{{ initTypeString(constructionStageOptions,lastListItem.constructionStage) || 'XXX' }}</div>
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.basicInfo.projectNature') }}</div>
|
||||
<div class="info-value">{{ initTypeString(projectNatureOptions,formData.projectNature) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.basicInfo.projectImplementationUnit') }}
|
||||
</div>
|
||||
<div class="info-value">{{ formData.projectMainEntity || 'XXX' }}</div>
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.basicInfo.projectOwnerUnit') }}</div>
|
||||
<div class="info-value">{{ formData.projectOwnerUnit || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.investmentCategory')
|
||||
}}</div>
|
||||
<div class="info-value required">{{ initTypeString(investmentCategoryOptions,formData.investmentCategory) || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.investmentRegion') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ initTypeString(investmentAreaOptions,formData.investmentArea) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.projectAddress') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ formData.projectAddress || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.projectDetailAddress')
|
||||
}}
|
||||
</div>
|
||||
<div class="info-value required">{{ formData.projectAddressDetail || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.projectDirection') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ formData.projectInvestmentDirection || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.directionSubdivision')
|
||||
}}
|
||||
</div>
|
||||
<div class="info-value required">{{initTypeString(projectDirectionDetailsOptions,formData.investmentDirectionSegmentation) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.projectSource') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ initTypeString(projectSourceOptions,formData.projectSource) || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.constructionNature')
|
||||
}}</div>
|
||||
<div class="info-value required">{{ initTypeString(constructionNatureOptions,formData.constructionNature) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.basicInfo.majorInvestmentProject') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ formData.majorInvestmentProjects || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.keyProject') }}</div>
|
||||
<div class="info-value required">{{ initTypeString(projectImportantOptions,formData.keyProject) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.isWithinMainBusiness')
|
||||
}}
|
||||
</div>
|
||||
<div class="info-value required">{{ formData.isMainBusiness || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.mainBusinessType') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ initTypeString(mainBusinessOptions,formData.mainBusinessTypes) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.mainBusinessCode') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ formData.mainBusinessCode || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.isManufacturing') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ formData.isManufacturingIndustry || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.basicInfo.strategicEmergingIndustry') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ initTypeString(strategicIndustryOptions,formData.isStrategicEmergingIndustries) || 'XXX' }}</div>
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.basicInfo.cityStrategy') }}
|
||||
</div>
|
||||
<div class="info-value required">{{ initTypeString(cityStrategyOptions,formData.urbanStrategy) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.basicInfo.projectImplementationStart') }}
|
||||
</div>
|
||||
<div class="info-value">{{ formData.projectStartTime || 'XXX' }}</div>
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.basicInfo.projectImplementationEnd') }}
|
||||
</div>
|
||||
<div class="info-value">{{ formData.projectEndTime || 'XXX' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="additional-info-table">
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label required">{{ t('postInvestmentEvaluationForm.additionalInfo.projectOverview')
|
||||
}}
|
||||
</div>
|
||||
<div class="info-value">{{ formData.projectDesc || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.additionalInfo.projectPreliminaryPlan')
|
||||
}}</div>
|
||||
<div class="info-value">
|
||||
<UploadFile :modelValue="JSON.parse(formData.projectPreliminaryPlanAttachment || '[]') || []" :fileSize="20" type="simple"
|
||||
:limit="10" :isShowTip="false" disabled/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.additionalInfo.remark') }}</div>
|
||||
<div class="info-value">{{ formData.remark || 'XXX' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="annual-investment-plan-section">
|
||||
<div class="form-section-title">{{ t('postInvestmentEvaluationForm.annualPlan.title') }}</div>
|
||||
<el-table :data="formData.projectInvestmentEntities" border style="width: 100%" class="annual-plan-table">
|
||||
<el-table-column type="index" :label="t('postInvestmentEvaluationForm.annualPlan.index')" width="60"
|
||||
align="center" />
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.plannedInvestmentYear')"
|
||||
prop="plannedInvestmentYear" min-width="120" align="center" />
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.plannedImageAmount')"
|
||||
prop="plannedImageAmount" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.plannedImageAmount ? `${row.plannedImageAmount}
|
||||
${t('postInvestmentEvaluationForm.unitSuffix')}` : '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.plannedPaymentAmount')"
|
||||
prop="plannedPaymentAmount" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.plannedPaymentAmount ? `${row.plannedPaymentAmount}
|
||||
${t('postInvestmentEvaluationForm.unitSuffix')}` : '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.fundingSource')" align="center">
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.ownFunds')" prop="ownFunds"
|
||||
min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.selfFunding ? `${row.selfFunding} ${t('postInvestmentEvaluationForm.unitSuffix')}` : ''
|
||||
}}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.fiscalFunds')"
|
||||
prop="fiscalFunds" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.fiscalFunding ? `${row.fiscalFunding}
|
||||
${t('postInvestmentEvaluationForm.unitSuffix')}` : '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.externalFundraising')"
|
||||
prop="externalFundraising" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.externalFunding ? `${row.externalFunding}
|
||||
${t('postInvestmentEvaluationForm.unitSuffix')}` : '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('postInvestmentEvaluationForm.annualPlan.otherFunds')"
|
||||
prop="otherFunds" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.otherFunding ? `${row.otherFunding} ${t('postInvestmentEvaluationForm.unitSuffix')}`
|
||||
: '' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:label="t('postInvestmentEvaluationForm.annualPlan.fiscalFundsSourceExplanation')"
|
||||
prop="fiscalFundingSource" min-width="180" align="center" />
|
||||
<el-table-column
|
||||
:label="t('postInvestmentEvaluationForm.annualPlan.otherFundsSourceExplanation')"
|
||||
prop="otherFundingSource" min-width="180" align="center" />
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="decision-info-section">
|
||||
<div class="form-section-title">{{ t('postInvestmentEvaluationForm.decisionInfo.title') }}</div>
|
||||
<div class="decision-info-table">
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.decisionInfo.decisionType') }}</div>
|
||||
<div class="info-value">{{ formData.decisionType || 'XXX' }}</div>
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.decisionInfo.isProjectApprovalProcedureCompleted') }}</div>
|
||||
<div class="info-value">{{ formData.isProjectApprovalCompleted || 'XXX'
|
||||
}}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.decisionInfo.projectApprovalDocumentNumber')
|
||||
}}</div>
|
||||
<div class="info-value">{{ formData.projectApprovalFileNo || 'XXX' }}
|
||||
</div>
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.decisionInfo.projectApprovalDocumentInfo') }}
|
||||
</div>
|
||||
<div class="info-value">{{ formData.projectApprovalFileInfo || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.decisionInfo.isDecisionProcedureCompleted')
|
||||
}}</div>
|
||||
<div class="info-value">{{ formData.isDecisionProcedureCompleted || 'XXX' }}</div>
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.decisionInfo.decisionProcedureDocumentNumber') }}</div>
|
||||
<div class="info-value">{{ formData.decisionProcedureFileNo || 'XXX' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.decisionInfo.decisionDocumentInfo')
|
||||
}}</div>
|
||||
<div class="info-value">{{ formData.decisionFileInfo || 'XXX' }}</div>
|
||||
<div class="info-label"></div>
|
||||
<div class="info-value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-report-info-section">
|
||||
<div class="form-section-title">{{ t('postInvestmentEvaluationForm.progressReportInfo.title') }}</div>
|
||||
<div class="progress-report-info-table">
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.projectStatus')
|
||||
}}</div>
|
||||
<div class="info-value">{{ initTypeString(projectStatusOptions,lastListItem.projectStatus) || 'XXX' }}</div>
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.constructionStage') }}
|
||||
</div>
|
||||
<div class="info-value">{{ initTypeString(constructionStageOptions,lastListItem.constructionStage) || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.totalInvestmentAmount') }}</div>
|
||||
<div class="info-value">{{ lastListItem.projectTotalAmount }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.cumulativeInvestmentToMonthEnd') }}</div>
|
||||
<div class="info-value">{{ lastListItem.cumulativeInvestmentToDate
|
||||
}}</div>
|
||||
<div class="info-label required">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.investmentCompletionRate') }}</div>
|
||||
<div class="info-value">{{ lastListItem.investmentCompletionRate || 'XXX' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.cumulativePaymentToMonthEnd') }}</div>
|
||||
<div class="info-value">{{ lastListItem.cumulativeInvestmentToDate || 'XXX' }}
|
||||
</div>
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.paymentCompletionRate') }}
|
||||
</div>
|
||||
<div class="info-value">{{ lastListItem.paymentCompletionRate || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.progressReportInfo.effectiveness') }}
|
||||
</div>
|
||||
<div class="info-value">{{ lastListItem.achievements || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.coordinationMatters') }}
|
||||
</div>
|
||||
<div class="info-value">{{lastListItem.coordinationIssues || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.nextWorkArrangements') }}
|
||||
</div>
|
||||
<div class="info-value">{{ lastListItem.nextWorkPlan || 'XXX' }}</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label">{{
|
||||
t('postInvestmentEvaluationForm.progressReportInfo.currentStageEvidenceMaterials') }}</div>
|
||||
<div class="info-value">
|
||||
<UploadFile :modelValue="JSON.parse(lastListItem.supportingDocuments || '[]') || []"
|
||||
:fileSize="20" type="simple" :limit="10" :isShowTip="false" disabled/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row info-row-single">
|
||||
<div class="info-label">{{ t('postInvestmentEvaluationForm.progressReportInfo.remark') }}</div>
|
||||
<div class="info-value">{{ lastListItem.remarks || 'XXX' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-records-section">
|
||||
<el-tabs v-model="activeTab" class="project-records-tabs">
|
||||
<el-tab-pane :label="t('postInvestmentEvaluationForm.projectRecords.adjustmentRecord')"
|
||||
name="adjustment">
|
||||
<el-table :data="projectRecordsData.adjustmentRecords" border style="width: 100%"
|
||||
class="project-records-table">
|
||||
<el-table-column type="index"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.index')" width="60"
|
||||
align="center" />
|
||||
<el-table-column prop="projectName"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.projectName')" min-width="150"
|
||||
align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary">{{ row.projectName || 'XXX' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="projectOverview"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.projectOverview')"
|
||||
min-width="150" align="center" />
|
||||
<el-table-column prop="projectPlan"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.projectPlan')" min-width="120"
|
||||
align="center" />
|
||||
<el-table-column prop="attachments"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.attachments')" min-width="100"
|
||||
align="center" />
|
||||
<el-table-column prop="projectNature"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.projectNature')" min-width="120"
|
||||
align="center" />
|
||||
<el-table-column prop="projectDirection"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.projectDirection')"
|
||||
min-width="120" align="center" />
|
||||
<el-table-column prop="projectImplementationStart"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.projectImplementationStart')"
|
||||
min-width="160" align="center" />
|
||||
<el-table-column prop="projectImplementationEnd"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.projectImplementationEnd')"
|
||||
min-width="160" align="center" />
|
||||
<el-table-column prop="totalInvestmentAmount"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.totalInvestmentAmount')"
|
||||
min-width="140" align="center" />
|
||||
<el-table-column prop="handler"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.handler')" min-width="100"
|
||||
align="center" />
|
||||
<el-table-column prop="updateTime"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.updateTime')" min-width="160"
|
||||
align="center" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="t('postInvestmentEvaluationForm.projectRecords.progressDeclarationRecord')"
|
||||
name="progress">
|
||||
<el-table :data="projectRecordsData.progressRecords" border style="width: 100%"
|
||||
class="project-records-table">
|
||||
<el-table-column type="index"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.index')" width="60"
|
||||
align="center" />
|
||||
<el-table-column prop="projectName" :label="t('progressReportRecord.table.projectName')"
|
||||
min-width="200" />
|
||||
<el-table-column prop="annualReport" :label="t('progressReportRecord.table.reportDate')" min-width="120"
|
||||
sortable />
|
||||
<el-table-column prop="implementingBody"
|
||||
:label="t('progressReportRecord.table.projectImplementationUnit')" min-width="200" />
|
||||
<el-table-column prop="isFinalApplication" :label="t('progressReportRecord.table.isLastDeclaration')"
|
||||
min-width="160" >
|
||||
<template #default="{ row }">
|
||||
{{row.isFinalApplication === 1 ? t('planApply.common.yes') : t('planApply.common.no')}}
|
||||
</template>
|
||||
|
||||
</el-table-column>
|
||||
<el-table-column prop="projectStatus" :label="t('progressReportRecord.table.projectStatus')"
|
||||
min-width="120" >
|
||||
<template #default="{row}">
|
||||
{{initTypeString(projectStatusOptions,row.projectStatus)}}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="constructionStage" :label="t('progressReportRecord.table.constructionStage')"
|
||||
min-width="120" >
|
||||
<template #default="{row}">
|
||||
{{initTypeString(constructionStageOptions,row.constructionStage)}}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="investmentPlanCompletionRate"
|
||||
:label="t('progressReportRecord.table.currentYearPlannedImageTotal')" min-width="180" />
|
||||
<el-table-column prop="ytdRemainingInvestment"
|
||||
:label="t('progressReportRecord.table.enterpriseCumulativeInvestmentToMonthEnd')" min-width="200" />
|
||||
<el-table-column prop="investmentCompletionRate"
|
||||
:label="t('progressReportRecord.table.currentYearImageCompletion')" min-width="180" />
|
||||
<el-table-column prop="projectTotalAmount"
|
||||
:label="t('progressReportRecord.table.totalInvestmentAmount')" min-width="160" />
|
||||
<el-table-column prop="cumulativeInvestmentToDate"
|
||||
:label="t('progressReportRecord.table.cumulativeInvestmentToMonthEnd')" min-width="200" />
|
||||
<el-table-column prop="completionRate" :label="t('progressReportRecord.table.totalCompletionRate')"
|
||||
min-width="140" />
|
||||
<el-table-column prop="achievements" :label="t('progressReportRecord.table.effectiveness')"
|
||||
min-width="120" />
|
||||
<el-table-column prop="coordinationIssues" :label="t('progressReportRecord.table.coordinationMatters')"
|
||||
min-width="160" />
|
||||
<el-table-column prop="nextWorkPlan"
|
||||
:label="t('progressReportRecord.table.nextWorkArrangements')" min-width="160" />
|
||||
<el-table-column prop="updateBy" :label="t('progressReportRecord.table.lastUpdater')"
|
||||
min-width="140" />
|
||||
<el-table-column prop="updateTime" :label="t('progressReportRecord.table.lastUpdateTime')"
|
||||
min-width="180" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.postInvestmentEvaluationRecord')"
|
||||
name="evaluation">
|
||||
<el-table :data="projectRecordsData.evaluationRecords" border style="width: 100%"
|
||||
class="project-records-table">
|
||||
<el-table-column type="index"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.index')" width="60"
|
||||
align="center" />
|
||||
<el-table-column prop="actualTotalIncome"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.actualTotalIncome')"
|
||||
min-width="140" align="center" />
|
||||
<el-table-column prop="actualInvestmentGain"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.actualInvestmentIncome')"
|
||||
min-width="140" align="center" />
|
||||
<el-table-column prop="actualInvestmentReturnRate"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.actualInvestmentIncomeRate')"
|
||||
min-width="160" align="center" />
|
||||
<el-table-column prop="attachmentUrl"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.attachments')" min-width="100"
|
||||
align="center" >
|
||||
<template #default="{row}">
|
||||
<UploadFile :modelValue="JSON.parse(row.attachmentUrl || '[]') || []" :fileSize="20" type="simple" :limit="10"
|
||||
:isShowTip="false" disabled />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createBy"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.handler')" min-width="100"
|
||||
align="center" />
|
||||
<el-table-column prop="createTime"
|
||||
:label="t('postInvestmentEvaluationForm.projectRecords.reportingTime')" min-width="160"
|
||||
align="center" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import UploadFile from '/@/components/Upload/index.vue';
|
||||
import {
|
||||
cityStrategyOptions,
|
||||
constructionNatureOptions, constructionStageOptions,
|
||||
Enums, investmentAreaOptions, investmentCategoryOptions, mainBusinessOptions, projectDirectionDetailsOptions,
|
||||
projectImportantOptions, projectNatureOptions,
|
||||
projectSourceOptions, projectStatusOptions, strategicIndustryOptions
|
||||
} from '/@/hooks/enums';
|
||||
import { PostInvestmentEvaluation, ProjectInvestmentInfo } from '/@/views/invBid/postInvestmentEvaluation/interface/type';
|
||||
import { InvestmentProjectProgress } from '/@/views/invMid/progressReport/interface/type';
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n();
|
||||
|
||||
/**
|
||||
* 项目记录标签页状态
|
||||
*/
|
||||
const activeTab = ref('adjustment');
|
||||
|
||||
/**
|
||||
* 项目记录数据
|
||||
*/
|
||||
const projectRecordsData:{
|
||||
adjustmentRecords: InvestmentProjectProgress[];
|
||||
progressRecords: InvestmentProjectProgress[];
|
||||
evaluationRecords: PostInvestmentEvaluation[];
|
||||
} = reactive({
|
||||
// 调整记录
|
||||
adjustmentRecords: [],
|
||||
// 进度记录
|
||||
progressRecords: [],
|
||||
// 后评价记录
|
||||
evaluationRecords: []
|
||||
});
|
||||
|
||||
/**
|
||||
* 组件属性定义
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/**
|
||||
* 表单数据模型
|
||||
*/
|
||||
modelValue: ProjectInvestmentInfo;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 事件发射器定义
|
||||
*/
|
||||
const emit = defineEmits<{
|
||||
/**
|
||||
* 更新表单数据事件
|
||||
* @param e
|
||||
* @param value 新的表单数据
|
||||
*/
|
||||
(e: 'update:modelValue', value: ProjectInvestmentInfo): void;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 默认表单数据
|
||||
*/
|
||||
const defaultForm: ProjectInvestmentInfo = {
|
||||
// 来自ProjectPlanApplyFormItem的字段(这些字段已经在ProjectPlanApplyFormItem中定义)
|
||||
id: undefined,
|
||||
projectName: '',
|
||||
projectNature: '',
|
||||
groupCompany: '',
|
||||
projectOwnerUnit: '',
|
||||
projectMainEntity: '',
|
||||
investmentCategory: '',
|
||||
investmentArea: '',
|
||||
projectAddress: '',
|
||||
projectAddressDetail: '',
|
||||
projectInvestmentDirection: '',
|
||||
investmentDirectionSegmentation: '',
|
||||
constructionNature: '',
|
||||
keyProject: '',
|
||||
isMainBusiness: '',
|
||||
mainBusinessTypes: '',
|
||||
mainBusinessCode: '',
|
||||
isManufacturingIndustry: '',
|
||||
urbanStrategy: '',
|
||||
projectSource: '',
|
||||
majorInvestmentProjects: '',
|
||||
isStrategicEmergingIndustries: '',
|
||||
projectStartTime: '',
|
||||
projectEndTime: '',
|
||||
planInvestmentYear: '',
|
||||
planPaymentLimit: '',
|
||||
ownedFunds: '',
|
||||
financialFunds: '',
|
||||
externalRaisedCapital: '',
|
||||
otherFunds: '',
|
||||
governmentFundSourceDesc: '',
|
||||
otherFundSourceDesc: '',
|
||||
projectTotalAmount: '',
|
||||
lastYearCompleted: '',
|
||||
ourInvestmentTotalAmount: '',
|
||||
ourLastYearCompleted: '',
|
||||
projectDesc: '',
|
||||
projectPreliminaryPlan: '',
|
||||
projectPreliminaryPlanAttachment: '',
|
||||
remark: '',
|
||||
decisionType: '',
|
||||
isProjectApprovalCompleted: '',
|
||||
isDecisionProcedureCompleted: '',
|
||||
projectApprovalFileNo: '',
|
||||
projectApprovalFileInfo: '',
|
||||
decisionProcedureFileNo: '',
|
||||
decisionFileInfo: '',
|
||||
planImageQuota:'',
|
||||
// 其他字段
|
||||
projectInvestmentEntities: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* 表单响应式数据
|
||||
*/
|
||||
const formData = reactive<ProjectInvestmentInfo>({ ...defaultForm });
|
||||
/**
|
||||
* 提交数据表单
|
||||
* */
|
||||
/**
|
||||
* 初始化时将props.modelValue映射到formData
|
||||
*/
|
||||
if (props.modelValue) {
|
||||
const mappedData: ProjectInvestmentInfo = {
|
||||
...props.modelValue,
|
||||
};
|
||||
Object.assign(formData, mappedData);
|
||||
}
|
||||
const lastListItem = ref<InvestmentProjectProgress>({} as InvestmentProjectProgress)
|
||||
/**
|
||||
* 监听 formData 变化,同步到父组件
|
||||
*/
|
||||
watch(
|
||||
formData,
|
||||
() => {
|
||||
// 构造符合ProjectPlanApplyFormItem接口的数据
|
||||
const projectPlanData: ProjectInvestmentInfo = {
|
||||
...formData
|
||||
};
|
||||
emit('update:modelValue', projectPlanData as ProjectInvestmentInfo);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* 监听 props.modelValue 变化,同步到 formData
|
||||
*/
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
// 将ProjectPlanApplyFormItem的字段映射到PostInvestmentEvaluationFormData
|
||||
const mappedData: ProjectInvestmentInfo = {
|
||||
...newVal,
|
||||
};
|
||||
lastListItem.value = (newVal.investmentProjectsProgressEntities && newVal.investmentProjectsProgressEntities.length > 0) ? newVal.investmentProjectsProgressEntities[newVal.investmentProjectsProgressEntities.length - 1] : {} as InvestmentProjectProgress;
|
||||
Object.assign(formData, mappedData);
|
||||
projectRecordsData.progressRecords = newVal.investmentProjectsProgressEntities || []
|
||||
projectRecordsData.evaluationRecords = newVal.evaluationRecordEntities || []
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
/**
|
||||
* 根据枚举值获取对应的标签文本
|
||||
* @param enums 枚举数组
|
||||
* @param type 枚举值
|
||||
* @returns 对应的标签文本
|
||||
*/
|
||||
const initTypeString = (enums: Enums[], type: string) => {
|
||||
return enums.find(item => item.value === type)?.label;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.post-investment-evaluation-form {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0 0 20px;
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
.evaluation-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 表单 label 左对齐 */
|
||||
.evaluation-form :deep(.el-form-item__label) {
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* 必填字段标签显示为红色 */
|
||||
.evaluation-form :deep(.el-form-item.is-required .el-form-item__label) {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.form-row:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.basic-info-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.basic-info-table,
|
||||
.additional-info-table {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.additional-info-table {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.decision-info-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.decision-info-table {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.progress-report-info-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.progress-report-info-table {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.project-records-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.project-records-tabs {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.project-records-table {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.annual-investment-plan-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.annual-plan-table {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.annual-plan-table :deep(.el-table__cell) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.annual-plan-table :deep(.span) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.info-row-full {
|
||||
grid-template-columns: 1fr 3fr;
|
||||
}
|
||||
|
||||
.info-row-single {
|
||||
grid-template-columns: 1fr 3fr;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
background-color: #f0f7ff;
|
||||
padding: 12px 16px;
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-label.required {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
background-color: #fff;
|
||||
padding: 12px 16px;
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
color: var(--el-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-value-full {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.info-row .info-value:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
</style>
|
||||
127
src/components/investment/ExpertDetailDialog.vue
Normal file
127
src/components/investment/ExpertDetailDialog.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<el-dialog v-model="visibleInner" :title="t('common.viewBtn') + ' ' + t('expertLibrary.title')" width="900px"
|
||||
append-to-body>
|
||||
<!-- 顶部三列摘要 -->
|
||||
<el-descriptions :column="3" border class="mb10">
|
||||
<el-descriptions-item :label="t('expertApply.reporter')">
|
||||
{{ meta.reporter }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('expertApply.date')">
|
||||
{{ meta.date }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('expertApply.reportingUnit')">
|
||||
{{ meta.unit }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 专家信息 -->
|
||||
<el-descriptions :title="t('expertLibrary.title')" :column="2" border>
|
||||
<el-descriptions-item :label="t('expertApply.table.name')">
|
||||
{{ detail.expertName || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('expertApply.table.contact')">
|
||||
{{ detail.contactInformation || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('expertApply.table.field')">
|
||||
{{ detail.technologyField || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('expertApply.table.title')">
|
||||
{{ detail.professionalTitle || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('expertApply.table.company')" :span="2">
|
||||
{{ detail.workUnit || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('expertApply.table.level')">
|
||||
{{ levelLabel(detail.level) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('expertLibrary.table.status')">
|
||||
<el-tag :type="['info', 'primary', 'success', 'danger'][detail.status]">{{ statusLabel(detail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 附件与佐证材料 -->
|
||||
<el-descriptions :column="1" border class="mt10">
|
||||
<el-descriptions-item :label="t('expertApply.table.attachments')">
|
||||
<template v-if="(detail.attachments || []).length">
|
||||
<span v-for="(f, idx) in detail.attachments" :key="'a-' + idx">
|
||||
<a :href="`${baseURL}${f.url}`" target="_blank">
|
||||
<el-button link type="primary">
|
||||
{{ f.name }}
|
||||
</el-button>
|
||||
</a>
|
||||
<span v-if="f.size">({{ f.size }})</span>
|
||||
<span v-if="idx !== detail.attachments.length - 1" class="mx8">|</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('expertApply.table.evidences')">
|
||||
<template v-if="(detail.evidences || []).length">
|
||||
<span v-for="(f, idx) in detail.evidences" :key="'e-' + idx">
|
||||
<a :href="`${baseURL}${f.url}`" target="_blank">
|
||||
<el-button link type="primary">
|
||||
{{ f.name }}
|
||||
</el-button>
|
||||
</a>
|
||||
<span v-if="f.size">({{ f.size }})</span>
|
||||
<span v-if="idx !== detail.evidences.length - 1" class="mx8">|</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="visibleInner = false">{{ t('tagsView.close') || '关闭' }}</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_URL
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
detail: any
|
||||
meta: { reporter: string; unit: string; date: string }
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', v: boolean): void
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const visibleInner = ref(false);
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => (visibleInner.value = v),
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(visibleInner, (v) => emit('update:visible', v));
|
||||
|
||||
const levelLabel = (val: string) => {
|
||||
if (val === 'national') return t('expertApply.level.national');
|
||||
if (val === 'provincial') return t('expertApply.level.provincial');
|
||||
if (val === 'city') return t('expertApply.level.city');
|
||||
return '-';
|
||||
};
|
||||
const statusLabel = (val: string) => {
|
||||
return {
|
||||
'0': t('expertLibrary.status.pending'),
|
||||
'1': t('expertLibrary.status.reviewing'),
|
||||
'2': t('expertLibrary.status.approved'),
|
||||
'3': t('expertLibrary.status.rejected'),
|
||||
}[val] || val
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mb10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
167
src/components/investment/SelectCompanyDialog.vue
Normal file
167
src/components/investment/SelectCompanyDialog.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<el-dialog v-model="innerVisible" :title="t('cooperationUnit.businessCompany')" width="90%" top="5vh"
|
||||
:close-on-click-modal="false">
|
||||
<div class="dialog-toolbar">
|
||||
<el-form :inline="true" :model="queryForm" @keyup.enter="getDataList" ref="queryRef">
|
||||
<el-form-item :label="t('cooperationUnit.cooperationName')">
|
||||
<el-input v-model="queryForm.cooperationName"
|
||||
:placeholder="t('cooperationUnit.inputCooperationNameTip')" style="width: 220px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('cooperationUnit.industryField')">
|
||||
<el-input v-model="queryForm.industryField"
|
||||
:placeholder="t('cooperationUnit.inputIndustryFieldTip')" style="width: 160px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('cooperationUnit.cooperationNature')">
|
||||
<el-select v-model="queryForm.cooperationNature" clearable style="width: 160px">
|
||||
<el-option label="企业" value="enterprise" />
|
||||
<el-option label="事业单位" value="institution" />
|
||||
<el-option label="政府机构" value="government" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="Search" @click="getDataList">{{ $t('common.queryBtn') }}</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">{{ $t('common.resetBtn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-table :data="dataList" @selection-change="onCurrentChange" max-height="560" style="width: 100%"
|
||||
v-loading="loading" border>
|
||||
<el-table-column width="60">
|
||||
<template #default="scope">
|
||||
<el-radio v-model="selectedIndex" :label="scope.$index"> </el-radio>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cooperationUnit.cooperationName')" prop="cooperationName" min-width="150"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.provinceCity')" prop="provinceCityLabel" min-width="120"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column prop="cooperationNature" header-class-name="red-header" min-width="140"
|
||||
show-overflow-tooltip>
|
||||
<template #header>
|
||||
<span class="red-text">{{ t('cooperationUnit.cooperationNature') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cooperationUnit.industryField')" prop="cooperationIndustry" min-width="150"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.cooperationBusiness')" prop="proposedBusinessCooperation"
|
||||
min-width="160" show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.cooperationAdvantage')" prop="cooperationAdvantage"
|
||||
min-width="160" show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.cooperationStatus')" prop="cooperationStatus" min-width="120"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.cooperationStartTime')" prop="startTime"
|
||||
min-width="140" show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.cooperationEndTime')" prop="endTime" min-width="140"
|
||||
show-overflow-tooltip />
|
||||
<!-- <el-table-column :label="t('cooperationUnit.unitAddress')" prop="unitAddress" min-width="200"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.unitContact')" prop="unitContact" min-width="120"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.departmentPosition')" prop="departmentPosition" min-width="150"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.phone')" prop="phone" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.potentialOpportunity')" prop="potentialOpportunity"
|
||||
min-width="160" show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.businessCompany')" prop="businessCompany" min-width="150"
|
||||
show-overflow-tooltip /> -->
|
||||
<el-table-column :label="t('cooperationUnit.actualController')" prop="cooperationController"
|
||||
header-class-name="red-header" min-width="150" show-overflow-tooltip>
|
||||
<template #header>
|
||||
<span class="red-text">{{ t('cooperationUnit.actualController') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cooperationUnit.shareholdingRatio')" prop="shareholdingRatio"
|
||||
header-class-name="red-header" min-width="120" show-overflow-tooltip>
|
||||
<template #header>
|
||||
<span class="red-text">{{ t('cooperationUnit.shareholdingRatio') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cooperationUnit.createTime')" prop="createTime" min-width="170"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.updateTime')" prop="updateTime" min-width="170"
|
||||
show-overflow-tooltip />
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="innerVisible = false">{{ $t('common.cancelButtonText') }}</el-button>
|
||||
<el-button type="primary" @click="confirm" :disabled="selectedIndex === -1">{{
|
||||
$t('common.confirmButtonText') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getExternalCooperationUnitsPageAPI } from '/@/api/investment/cooperationUnit';
|
||||
import type { FormInstance } from 'element-plus';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', v: boolean): void;
|
||||
(e: 'confirm', value: string): void;
|
||||
}>();
|
||||
const { t } = useI18n();
|
||||
const innerVisible = ref(false);
|
||||
watch(() => props.visible, v => (innerVisible.value = v));
|
||||
watch(innerVisible, v => emit('update:visible', v));
|
||||
|
||||
const loading = ref(false);
|
||||
const dataList = ref<any[]>([]);
|
||||
const selectedIndex = ref(-1);
|
||||
const queryRef = ref<FormInstance|null>(null);
|
||||
const queryForm = reactive({
|
||||
cooperationName: '',
|
||||
industryField: '',
|
||||
cooperationNature: '',
|
||||
page: 1,
|
||||
size: 10,
|
||||
});
|
||||
|
||||
const getDataList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getExternalCooperationUnitsPageAPI(queryForm as any);
|
||||
const apiList = res.data?.records || [];
|
||||
dataList.value = apiList
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
const resetQuery = () => {
|
||||
Object.assign(queryForm, {
|
||||
cooperationName: '',
|
||||
industryField: '',
|
||||
cooperationNature: ''
|
||||
})
|
||||
getDataList();
|
||||
};
|
||||
const onCurrentChange = (row: any) => {
|
||||
if (!row) return;
|
||||
selectedIndex.value = dataList.value.indexOf(row);
|
||||
};
|
||||
const confirm = () => {
|
||||
if (selectedIndex.value === -1) return;
|
||||
const row = dataList.value[selectedIndex.value];
|
||||
emit('confirm', row);
|
||||
innerVisible.value = false;
|
||||
};
|
||||
|
||||
watch(innerVisible, v => {
|
||||
if (v) {
|
||||
getDataList();
|
||||
selectedIndex.value = -1;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-toolbar {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
189
src/components/investment/SelectExpertDialog.vue
Normal file
189
src/components/investment/SelectExpertDialog.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<el-dialog v-model="visibleInner" :title="t('expertLibrary.title')" width="900px" append-to-body>
|
||||
<!-- 搜索区 -->
|
||||
<el-form :inline="true" :model="queryForm" class="mb10">
|
||||
<el-form-item :label="t('expertLibrary.form.name')" prop="name">
|
||||
<el-input v-model="queryForm.expertName" :placeholder="t('expertLibrary.placeholder.input')"
|
||||
style="width: 180px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('expertLibrary.form.field')" prop="techField">
|
||||
<el-input v-model="queryForm.technologyField" :placeholder="t('expertLibrary.placeholder.input')"
|
||||
style="width: 180px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="Search" @click="getDataList">{{ t('common.queryBtn') }}</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">{{ t('common.resetBtn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 单选表格 -->
|
||||
<el-table :data="tableData" border @current-change="onCurrentChange" v-loading="loading">
|
||||
<el-table-column width="60">
|
||||
<template #default="scope">
|
||||
<el-radio v-model="selectedRowIndex" :label="scope.$index"
|
||||
@change="handleRowSelect(scope.$index)"> </el-radio>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column type="index" :label="t('expertLibrary.table.index')" width="60" />
|
||||
<el-table-column prop="expertName" :label="t('expertLibrary.table.name')" min-width="140" />
|
||||
<el-table-column prop="contactInformation" :label="t('expertLibrary.table.contact')" min-width="140" />
|
||||
<el-table-column prop="technologyField" :label="t('expertLibrary.table.field')" min-width="140" />
|
||||
<el-table-column prop="professionalTitle" :label="t('expertLibrary.table.title')" min-width="120" />
|
||||
<el-table-column prop="workUnit" :label="t('expertLibrary.table.company')" min-width="160" />
|
||||
<el-table-column prop="level" :label="t('expertLibrary.table.level')" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ levelLabel(row.level) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="externalStatus" :label="t('expertLibrary.table.status')" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ externalStatusLabel(row.externalStatus) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="mt15 flex justify-end">
|
||||
<el-pagination background layout="sizes, prev, pager, next, jumper, total" :total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]" v-model:page-size="pagination.size"
|
||||
v-model:current-page="pagination.page" @size-change="handleSizeChange"
|
||||
@current-change="handlePageChange" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleCancel">{{ t('common.cancelButtonText') || '取消' }}</el-button>
|
||||
<el-button type="primary" @click="handleConfirm">{{ t('user.logOutConfirm') || '确定' }}</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, reactive } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getExpertInformationPageAPI } from '/@/api/investment/cooperationUnit';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', v: boolean): void
|
||||
(e: 'confirm', row: any): void
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const visibleInner = ref(false);
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => (visibleInner.value = v),
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(visibleInner, (v) => emit('update:visible', v));
|
||||
|
||||
const queryForm = reactive({
|
||||
expertName: '',
|
||||
technologyField: '',
|
||||
externalStatus: '',
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const tableData = ref<any[]>([]);
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const getDataList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
...queryForm,
|
||||
};
|
||||
const res = await getExpertInformationPageAPI(params);
|
||||
const records = res?.records ?? res?.data?.records ?? [];
|
||||
const total = res?.total ?? res?.data?.total ?? 0;
|
||||
tableData.value = records;
|
||||
pagination.total = total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryForm.expertName = '';
|
||||
queryForm.technologyField = '';
|
||||
queryForm.externalStatus = '';
|
||||
pagination.page = 1;
|
||||
getDataList();
|
||||
};
|
||||
|
||||
const handleSizeChange = (val: number) => {
|
||||
pagination.size = val;
|
||||
pagination.page = 1;
|
||||
getDataList();
|
||||
};
|
||||
|
||||
const handlePageChange = (val: number) => {
|
||||
pagination.page = val;
|
||||
getDataList();
|
||||
};
|
||||
|
||||
const selectedRowIndex = ref(-1)
|
||||
const currentRow = ref<any | null>(null);
|
||||
const onCurrentChange = (row: any) => {
|
||||
selectedRowIndex.value = tableData.value.findIndex(item => item === row)
|
||||
currentRow.value = row;
|
||||
};
|
||||
const handleRowSelect = (index: number) => {
|
||||
selectedRowIndex.value = index
|
||||
currentRow.value = tableData.value[index];
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
visibleInner.value = false;
|
||||
};
|
||||
const handleConfirm = () => {
|
||||
if (currentRow.value) emit('confirm', currentRow.value);
|
||||
visibleInner.value = false;
|
||||
};
|
||||
|
||||
const levelLabel = (val: string) => {
|
||||
if (val === 'national') return t('expertApply.level.national');
|
||||
if (val === 'provincial') return t('expertApply.level.provincial');
|
||||
if (val === 'city') return t('expertApply.level.city');
|
||||
return '-';
|
||||
};
|
||||
const externalStatusLabel = (val: string) => {
|
||||
if (val === 'active') return t('expertLibrary.status.active');
|
||||
if (val === 'inactive') return t('expertLibrary.status.inactive');
|
||||
return '-';
|
||||
};
|
||||
|
||||
// 监听对话框显示状态,当打开时加载数据
|
||||
watch(visibleInner, (newVal) => {
|
||||
if (newVal) {
|
||||
pagination.page = 1;
|
||||
getDataList();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mb10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.mt15 {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
185
src/components/investment/UnitNameSelectDialog.vue
Normal file
185
src/components/investment/UnitNameSelectDialog.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<el-dialog v-model="innerVisible" :title="t('cooperationUnit.cooperationName')" width="90%" top="5vh"
|
||||
:close-on-click-modal="false">
|
||||
<div class="dialog-toolbar">
|
||||
<el-form :inline="true" :model="queryForm" @keyup.enter="getDataList" ref="queryRef">
|
||||
<el-form-item :label="t('cooperationUnit.cooperationName')">
|
||||
<el-input v-model="queryForm.cooperationName"
|
||||
:placeholder="t('cooperationUnit.inputCooperationNameTip')" style="width: 220px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('cooperationUnit.industryField')">
|
||||
<el-input v-model="queryForm.industryField"
|
||||
:placeholder="t('cooperationUnit.inputIndustryFieldTip')" style="width: 160px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('cooperationUnit.cooperationNature')">
|
||||
<el-select v-model="queryForm.cooperationNature" clearable style="width: 160px">
|
||||
<el-option label="企业" value="enterprise" />
|
||||
<el-option label="事业单位" value="institution" />
|
||||
<el-option label="政府机构" value="government" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="Search" @click="getDataList">{{ $t('common.queryBtn') }}</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">{{ $t('common.resetBtn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-table :data="dataList" v-loading="loading" @current-change="onCurrentChange" highlight-current-row
|
||||
height="55vh" border>
|
||||
<el-table-column width="60">
|
||||
<template #default="scope">
|
||||
<el-radio v-model="selectedIndex" :label="scope.$index"> </el-radio>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cooperationUnit.cooperationName')" prop="cooperationName" min-width="200"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.provinceCity')" prop="provinceCity" min-width="120"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.industryField')" prop="industryField" min-width="160"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.cooperationBusiness')" prop="cooperationBusiness"
|
||||
min-width="160" show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.cooperationAdvantage')" prop="cooperationAdvantage"
|
||||
min-width="160" show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.cooperationStatus')" prop="cooperationStatus" min-width="140"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.cooperationStartTime')" prop="cooperationStartTime"
|
||||
min-width="160" show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.cooperationEndTime')" prop="cooperationEndTime" min-width="160"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.reportingUnit')" prop="reportingUnit" min-width="160"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column :label="t('cooperationUnit.date')" prop="date" min-width="140" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">{{ $t('common.cancelButtonText') }}</el-button>
|
||||
<el-button type="primary" @click="handleConfirm" :disabled="selectedIndex === -1">{{
|
||||
$t('common.confirmButtonText') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
// import { getCooperationUnitPageAPI } from '/@/api/investment/cooperationUnit';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', v: boolean): void;
|
||||
(e: 'confirm', row: any): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const innerVisible = ref(false);
|
||||
watch(
|
||||
() => props.visible,
|
||||
v => (innerVisible.value = v)
|
||||
);
|
||||
watch(innerVisible, v => emit('update:visible', v));
|
||||
|
||||
const loading = ref(false);
|
||||
const dataList = ref<any[]>([]);
|
||||
const selectedIndex = ref(-1);
|
||||
const queryRef = ref();
|
||||
const queryForm = reactive({
|
||||
cooperationName: '',
|
||||
industryField: '',
|
||||
cooperationNature: '',
|
||||
current: 1,
|
||||
size: 10,
|
||||
});
|
||||
|
||||
const getDataList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// const res = await getCooperationUnitPageAPI(queryForm as any);
|
||||
// const apiList = res.records || res.data?.records || [];
|
||||
const apiList: any[] = [];
|
||||
// 模拟数据(用于演示/联调前占位)
|
||||
const mockList = [
|
||||
{
|
||||
cooperationName: '成都公交集团',
|
||||
provinceCity: '成都市',
|
||||
unitType: '国有企业',
|
||||
industryField: '道路运输',
|
||||
cooperationBusiness: '公交客运服务',
|
||||
cooperationAdvantage: '线路资源丰富、品牌影响力强',
|
||||
cooperationStatus: '已开展合作',
|
||||
cooperationStartTime: '2025-08-01',
|
||||
cooperationEndTime: '2025-12-31',
|
||||
reportingUnit: '成都市交通运输局',
|
||||
date: '2025-08-01',
|
||||
unitContact: '张三',
|
||||
phone: '13800000001',
|
||||
},
|
||||
{
|
||||
cooperationName: '四川能投集团',
|
||||
provinceCity: '成都市',
|
||||
unitType: '国有企业',
|
||||
industryField: '专业技术与商务服务业',
|
||||
cooperationBusiness: '资产评估',
|
||||
cooperationAdvantage: '行业资源、资金实力雄厚',
|
||||
cooperationStatus: '已开展合作',
|
||||
cooperationStartTime: '2025-08-05',
|
||||
cooperationEndTime: '2025-12-31',
|
||||
reportingUnit: '成都市国资委',
|
||||
date: '2025-08-05',
|
||||
unitContact: '李四',
|
||||
phone: '13800000002',
|
||||
},
|
||||
{
|
||||
cooperationName: '成都地铁集团',
|
||||
provinceCity: '成都市',
|
||||
unitType: '事业单位',
|
||||
industryField: '道路运输',
|
||||
cooperationBusiness: '公共交通协同',
|
||||
cooperationAdvantage: '轨道交通网络完善',
|
||||
cooperationStatus: '拟合作',
|
||||
cooperationStartTime: '2025-09-01',
|
||||
cooperationEndTime: '2025-12-31',
|
||||
reportingUnit: '成都市交委',
|
||||
date: '2025-09-01',
|
||||
unitContact: '王五',
|
||||
phone: '13800000003',
|
||||
},
|
||||
];
|
||||
dataList.value = apiList && apiList.length ? apiList : mockList;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
const resetQuery = () => {
|
||||
queryRef.value?.resetFields();
|
||||
getDataList();
|
||||
};
|
||||
const onCurrentChange = (row: any) => {
|
||||
if (!row) return;
|
||||
selectedIndex.value = dataList.value.indexOf(row);
|
||||
};
|
||||
const handleCancel = () => (innerVisible.value = false);
|
||||
const handleConfirm = () => {
|
||||
if (selectedIndex.value === -1) return;
|
||||
emit('confirm', dataList.value[selectedIndex.value]);
|
||||
innerVisible.value = false;
|
||||
};
|
||||
|
||||
watch(innerVisible, v => {
|
||||
if (v) {
|
||||
getDataList();
|
||||
selectedIndex.value = -1;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-toolbar {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
109
src/components/investment/common/CooperationUnitView.vue
Normal file
109
src/components/investment/common/CooperationUnitView.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-row class="mb10 header-meta">
|
||||
<div class="meta-item">{{ t('cooperationUnit.reporterLabel') + displayReporter }}</div>
|
||||
<div class="meta-item">{{ t('cooperationUnit.reportingUnitLabel') + displayUnit }}</div>
|
||||
<div class="meta-item">{{ t('cooperationUnit.dateLabel') + displayDate }}</div>
|
||||
<div class="meta-item">{{ t('cooperationUnit.serialNumber') + '--' }}</div>
|
||||
</el-row>
|
||||
|
||||
<!-- 动态明细表(按图示) -->
|
||||
<el-table :data="data" border style="width: 100%;">
|
||||
<el-table-column type="index" :label="t('cooperationUnit.index')" width="60" />
|
||||
<el-table-column prop="cooperationName" :label="t('cooperationUnit.cooperationName')" min-width="200" />
|
||||
<el-table-column prop="areaCode" :label="t('cooperationUnit.provinceCity')" min-width="240">
|
||||
<template #default="scope">
|
||||
{{ scope.row.areaCode }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cooperationNature" :label="t('cooperationUnit.cooperationNature')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ cooperationPartnerNatureOptions.find(item => item.value === row.cooperationNature)?.label || row.cooperationNature || '--' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cooperationIndustry" :label="t('cooperationUnit.cooperationIndustry')" min-width="160" />
|
||||
<el-table-column prop="proposedBusinessCooperation" :label="t('cooperationUnit.proposedBusinessCooperation')" min-width="180" />
|
||||
<el-table-column prop="cooperationAdvantage" :label="t('cooperationUnit.cooperationAdvantage')" min-width="160" />
|
||||
<el-table-column prop="cooperationStatus" :label="t('cooperationUnit.cooperationStatus')" min-width="140" />
|
||||
<el-table-column prop="startTime" :label="t('cooperationUnit.startTime')" min-width="170" />
|
||||
<el-table-column prop="endTime" :label="t('cooperationUnit.endTime')" min-width="170" />
|
||||
<el-table-column prop="unitAddress" :label="t('cooperationUnit.unitAddress')" min-width="200" />
|
||||
<el-table-column prop="unitContactPerson" :label="t('cooperationUnit.unitContactPerson')" min-width="150" />
|
||||
<el-table-column prop="contactPersonDept" :label="t('cooperationUnit.contactPersonDept')" min-width="160" />
|
||||
<el-table-column prop="contactPersonPhone" :label="t('cooperationUnit.contactPersonPhone')" min-width="160" />
|
||||
<el-table-column prop="potentialCooperation" :label="t('cooperationUnit.potentialCooperation')" min-width="180" />
|
||||
<el-table-column prop="businessConnectionCompany" :label="t('cooperationUnit.businessConnectionCompany')" min-width="200" />
|
||||
<el-table-column prop="cooperationController" :label="t('cooperationUnit.actualController')" min-width="160" />
|
||||
<el-table-column prop="shareholdingRatio" :label="t('cooperationUnit.shareholdingRatio')" min-width="140" />
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="bizInvestmentCooperationUnit" setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUserInfo } from '/@/stores/userInfo';
|
||||
import { formatDate } from '/@/utils/formatTime';
|
||||
import { cooperationPartnerNatureOptions } from '/@/hooks/enums'
|
||||
|
||||
const props = defineProps({
|
||||
isUpdate: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => ([])
|
||||
}
|
||||
})
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// 头部摘要显示
|
||||
const userInfoStore = useUserInfo();
|
||||
const displayReporter = (userInfoStore.userInfos.user?.username || '');
|
||||
const displayUnit = (userInfoStore.userInfos.user?.deptName || '');
|
||||
const displayDate = formatDate(new Date(), 'YYYY-mm-dd');
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-header-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-summary .title {
|
||||
margin: 0 auto;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 50%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-meta .meta-item {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.required-star {
|
||||
color: var(--el-color-danger);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.red-label {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.form-header-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
1107
src/components/investment/common/ProjectBasicInfoForm.vue
Normal file
1107
src/components/investment/common/ProjectBasicInfoForm.vue
Normal file
File diff suppressed because it is too large
Load Diff
617
src/components/investment/common/ProjectBasicInfoView.vue
Normal file
617
src/components/investment/common/ProjectBasicInfoView.vue
Normal file
@@ -0,0 +1,617 @@
|
||||
<template>
|
||||
<div class="project-basic-info-form">
|
||||
<div class="form-section-title">{{ t('reserveRegistration.basicInfo.title') }}</div>
|
||||
<el-form
|
||||
label-width="220px"
|
||||
class="project-form"
|
||||
ref="formRef"
|
||||
>
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.applicant')">
|
||||
{{ formData.applicant || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.applicationDate')">
|
||||
{{ formData.applicationDate || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.applicationUnit')">
|
||||
{{ formData.applicationUnit || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.projectName')">
|
||||
{{ formData.projectName || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.projectMainUnit')">
|
||||
{{ formData.projectMainUnit || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.projectOwnerUnit')">
|
||||
{{ formData.projectOwnerUnit || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.projectStartDate')">
|
||||
{{ formData.projectStartDate || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.projectEndDate')">
|
||||
{{ formData.projectEndDate || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.investmentCategory')">
|
||||
{{ investmentCategoryOptions.find(item => item.value === formData.investmentCategory)?.label || formData.investmentCategory || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.projectNature')">
|
||||
{{ projectNatureOptions.find(item => item.value === formData.projectNature)?.label || formData.projectNature || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.projectManagerName')">
|
||||
{{ formData.projectManagerName || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.contactNumber')">
|
||||
{{ formData.contactNumber || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.projectSource')">
|
||||
{{ projectSourceOptions.find(item => item.value === formData.projectSource)?.label || formData.projectSource || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.constructionNature')">
|
||||
{{ constructionNatureOptions.find(item => item.value === formData.constructionNature)?.label || formData.constructionNature || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.keyProject')">
|
||||
{{ formData.keyProject === 'yes' ? '是' : '否' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.majorInvestmentProject')">
|
||||
{{ formData.majorInvestmentProject || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.withinMainBusiness')">
|
||||
{{ formData.withinMainBusiness || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.mainBusinessType')">
|
||||
{{ formData.mainBusinessType || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.mainBusinessCode')">
|
||||
{{ formData.mainBusinessCode || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.projectDirection')">
|
||||
{{ formData.projectDirection || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.directionSubdivision')">
|
||||
{{ formData.directionSubdivision || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.strategicEmergingIndustry')">
|
||||
{{ formData.strategicEmergingIndustry || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.urbanStrategy')">
|
||||
{{ formData.urbanStrategy || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.isManufacturing')">
|
||||
{{ formData.isManufacturing || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.investmentRegion')">
|
||||
{{ investmentAreaOptions.find(item => item.value === formData.investmentRegion)?.label || formData.investmentRegion || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.projectAddress')">
|
||||
{{ formData.projectAddress || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.projectDetailedAddress')">
|
||||
{{ formData.projectDetailedAddress || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.isControllingShareholder')">
|
||||
{{ formData.isControllingShareholder || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.ourShareholdingRatio')">
|
||||
{{ formData.ourShareholdingRatio || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.totalInvestmentAmount')">
|
||||
{{ formData.totalInvestmentAmount || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.cumulativeInvestmentLastYear')">
|
||||
{{ formData.cumulativeInvestmentLastYear || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.ourTotalInvestmentAmount')">
|
||||
{{ formData.ourTotalInvestmentAmount || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.ourCumulativeInvestmentLastYear')">
|
||||
{{ formData.ourCumulativeInvestmentLastYear || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.projectOverview')">
|
||||
{{ formData.projectDesc || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.progressDescription')">
|
||||
{{ formData.progressDesc || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="t('reserveRegistration.basicInfo.projectProposal')">
|
||||
<div v-for="file of formData.projectProposal" :key="file.url" class="mr-4">
|
||||
<a :href="`${baseURL}${file.url}`" target="_blank">
|
||||
<el-button link type="primary">
|
||||
{{ file.name }}
|
||||
</el-button>
|
||||
</a>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="sub-section-title">{{ t('reserveRegistration.investmentEstimate.title') }}</div>
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.investmentEstimate.enterpriseSelfFund')">
|
||||
{{ formData.enterpriseSelfFund || '0' }}
|
||||
{{ t('reserveRegistration.investmentEstimate.unitWanYuan') }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.investmentEstimate.governmentInvestmentFund')">
|
||||
{{ formData.governmentInvestmentFund || '0' }}
|
||||
{{ t('reserveRegistration.investmentEstimate.unitWanYuan') }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.investmentEstimate.externalRaisedFund')">
|
||||
{{ formData.externalRaisedFund || '0' }}
|
||||
{{ t('reserveRegistration.investmentEstimate.unitWanYuan') }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.investmentEstimate.otherFunds')">
|
||||
{{ formData.otherFunds || '0' }}
|
||||
{{ t('reserveRegistration.investmentEstimate.unitWanYuan') }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="sub-section-title">{{ t('reserveRegistration.partnerInfo.title') }}</div>
|
||||
<el-form-item prop="partnerInfos" label-width="0">
|
||||
<el-table :data="formData.cooperationUnits" border style="width: 100%;" highlight-current-row>
|
||||
<el-table-column type="index" :label="t('reserveRegistration.partnerInfo.table.index')" width="60" />
|
||||
<el-table-column prop="cooperationName" :label="t('reserveRegistration.partnerInfo.table.name')" />
|
||||
<el-table-column prop="cooperationNature" :label="t('reserveRegistration.partnerInfo.table.nature')" />
|
||||
<el-table-column prop="cooperationController" :label="t('reserveRegistration.partnerInfo.table.controller')" />
|
||||
<el-table-column readonly prop="shareholdingRatio" :label="t('reserveRegistration.partnerInfo.table.share')" width="100">
|
||||
<template #default="scope">
|
||||
{{ scope.row.shareholdingRatio }}%
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-form-item>
|
||||
|
||||
<div class="sub-section-title">{{ t('reserveRegistration.decisionInfo.title') }}</div>
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.decisionInfo.decisionType')">
|
||||
{{ formData.decisionType || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.decisionInfo.isCompleteEstablishmentProcedures')">
|
||||
{{ formData.isCompleteEstablishmentProcedures === 'yes' ? '是' : '否' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.decisionInfo.establishmentDocumentNumber')">
|
||||
{{ formData.establishmentDocumentNumber || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.decisionInfo.establishmentDocumentInfo')">
|
||||
{{ formData.establishmentDocumentInfo || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.decisionInfo.isCompleteDecisionProcedures')">
|
||||
{{ formData.isCompleteDecisionProcedures === 'yes' ? '是' : '否' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('reserveRegistration.decisionInfo.decisionProgramDocumentNumber')">
|
||||
{{ formData.decisionProgramDocumentNumber || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20" class="form-row">
|
||||
<el-col :span="24">
|
||||
<el-form-item :label="t('reserveRegistration.decisionInfo.decisionDocumentInfo')">
|
||||
{{ formData.decisionDocumentInfo || '--' }}
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- <div class="sub-section-title">{{ t('reserveRegistration.reviewOpinions.title') }}</div>
|
||||
<el-form-item :label="t('reserveRegistration.reviewOpinions.submitUnitOpinion')">
|
||||
{{ formData.reviewOpinions.submitUnitOpinion || '--' }}
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('reserveRegistration.reviewOpinions.groupInvestmentDeptOpinion')">
|
||||
{{ formData.reviewOpinions.groupInvestmentDeptOpinion || '--' }}
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('reserveRegistration.reviewOpinions.submitUnitLeadershipOpinion')">
|
||||
{{ formData.reviewOpinions.submitUnitLeadershipOpinion || '--' }}
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('reserveRegistration.reviewOpinions.submitUnitMainLeadershipOpinion')">
|
||||
{{ formData.reviewOpinions.submitUnitMainLeadershipOpinion || '--' }}
|
||||
</el-form-item> -->
|
||||
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
projectNatureOptions,
|
||||
investmentCategoryOptions,
|
||||
projectSourceOptions,
|
||||
constructionNatureOptions,
|
||||
investmentAreaOptions
|
||||
} from '/@/hooks/enums'
|
||||
import type { ExternalCooperationUnitItemT } from '/@/api/investment/cooperationUnit'
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
export interface ProjectBasicInfoFormData {
|
||||
applicant: string;
|
||||
applicationUnit: string;
|
||||
projectName: string;
|
||||
projectMainUnit: string;
|
||||
projectStartDate: string;
|
||||
investmentCategory: string;
|
||||
projectManagerName: string;
|
||||
projectSource: string;
|
||||
keyProject: string;
|
||||
withinMainBusiness: string;
|
||||
mainBusinessType: string;
|
||||
projectDirection: string;
|
||||
strategicEmergingIndustry: string;
|
||||
isManufacturing: string;
|
||||
projectAddress: string;
|
||||
isControllingShareholder: string;
|
||||
totalInvestmentAmount: string;
|
||||
ourTotalInvestmentAmount: string;
|
||||
projectDesc: string;
|
||||
progressDesc: string;
|
||||
projectProposal: any[];
|
||||
applicationDate: string;
|
||||
projectOwnerUnit: string;
|
||||
projectEndDate: string;
|
||||
projectNature: string;
|
||||
contactNumber: string;
|
||||
constructionNature: string;
|
||||
majorInvestmentProject: string;
|
||||
mainBusinessCode: string;
|
||||
directionSubdivision: string;
|
||||
urbanStrategy: string;
|
||||
investmentRegion: string;
|
||||
projectDetailedAddress: string;
|
||||
ourShareholdingRatio: string;
|
||||
cumulativeInvestmentLastYear: string;
|
||||
ourCumulativeInvestmentLastYear: string;
|
||||
enterpriseSelfFund: string;
|
||||
externalRaisedFund: string;
|
||||
governmentInvestmentFund: string;
|
||||
otherFunds: string;
|
||||
partnerInfos: PartnerInfo[];
|
||||
decisionType: string;
|
||||
isCompleteEstablishmentProcedures: string;
|
||||
establishmentDocumentNumber: string;
|
||||
establishmentDocumentInfo: string;
|
||||
decisionProgramDocumentNumber: string;
|
||||
decisionDocumentInfo: string;
|
||||
isCompleteDecisionProcedures: string;
|
||||
reviewOpinions: ReviewOpinions;
|
||||
processInstanceId: string;
|
||||
cooperationUnits?: ExternalCooperationUnitItemT[]
|
||||
}
|
||||
|
||||
export interface PartnerInfo {
|
||||
id?: string | number | null;
|
||||
name: string;
|
||||
nature: string;
|
||||
controller: string;
|
||||
share: string;
|
||||
}
|
||||
|
||||
export interface ReviewOpinions {
|
||||
submitUnitOpinion: string;
|
||||
groupInvestmentDeptOpinion: string;
|
||||
submitUnitLeadershipOpinion: string;
|
||||
submitUnitMainLeadershipOpinion: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: ProjectBasicInfoFormData;
|
||||
}>();
|
||||
|
||||
const defaultPartnerRow: PartnerInfo = {
|
||||
id: '',
|
||||
name: '',
|
||||
nature: '',
|
||||
controller: '',
|
||||
share: '',
|
||||
};
|
||||
|
||||
const defaultFormData: ProjectBasicInfoFormData = {
|
||||
applicant: '',
|
||||
applicationUnit: '',
|
||||
projectName: '',
|
||||
projectMainUnit: '',
|
||||
projectStartDate: '',
|
||||
investmentCategory: '',
|
||||
projectManagerName: '',
|
||||
projectSource: '',
|
||||
keyProject: '',
|
||||
withinMainBusiness: '',
|
||||
mainBusinessType: '',
|
||||
projectDirection: '',
|
||||
strategicEmergingIndustry: '',
|
||||
isManufacturing: '',
|
||||
projectAddress: '',
|
||||
isControllingShareholder: '',
|
||||
totalInvestmentAmount: '',
|
||||
ourTotalInvestmentAmount: '',
|
||||
projectDesc: '',
|
||||
progressDesc: '',
|
||||
projectProposal: [],
|
||||
applicationDate: '',
|
||||
projectOwnerUnit: '',
|
||||
projectEndDate: '',
|
||||
projectNature: '',
|
||||
contactNumber: '',
|
||||
constructionNature: '',
|
||||
majorInvestmentProject: '',
|
||||
mainBusinessCode: '',
|
||||
directionSubdivision: '',
|
||||
urbanStrategy: '',
|
||||
investmentRegion: '',
|
||||
projectDetailedAddress: '',
|
||||
ourShareholdingRatio: '',
|
||||
cumulativeInvestmentLastYear: '',
|
||||
ourCumulativeInvestmentLastYear: '',
|
||||
enterpriseSelfFund: '',
|
||||
externalRaisedFund: '',
|
||||
governmentInvestmentFund: '',
|
||||
otherFunds: '',
|
||||
partnerInfos: [],
|
||||
decisionType: '',
|
||||
isCompleteEstablishmentProcedures: '',
|
||||
establishmentDocumentNumber: '',
|
||||
establishmentDocumentInfo: '',
|
||||
decisionProgramDocumentNumber: '',
|
||||
decisionDocumentInfo: '',
|
||||
isCompleteDecisionProcedures: '',
|
||||
processInstanceId: '',
|
||||
reviewOpinions: {
|
||||
submitUnitOpinion: '',
|
||||
groupInvestmentDeptOpinion: '',
|
||||
submitUnitLeadershipOpinion: '',
|
||||
submitUnitMainLeadershipOpinion: '',
|
||||
},
|
||||
};
|
||||
|
||||
const formData = reactive<ProjectBasicInfoFormData>({ ...defaultFormData });
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newVal => {
|
||||
Object.assign(
|
||||
formData,
|
||||
newVal
|
||||
);
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-basic-info-form {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-main-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 20px;
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid #409eff;
|
||||
}
|
||||
|
||||
.project-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-row:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.is-required .el-form-item__label) {
|
||||
font-weight: 500;
|
||||
/* color: var(--el-color-danger); */
|
||||
}
|
||||
|
||||
:deep(.is-required .el-form-item__label)::before {
|
||||
content: '*';
|
||||
/* color: var(--el-color-danger); */
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.sub-section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 24px 0 12px;
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid #409eff;
|
||||
}
|
||||
|
||||
.partner-toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.partner-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.partner-name-cell :deep(.el-input) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.el-table th .cell) {
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
137
src/components/investment/common/ProjectExitFeedbackDetail.vue
Normal file
137
src/components/investment/common/ProjectExitFeedbackDetail.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="project-exit-feedback-form">
|
||||
<div class="form-heading">{{ t('projectExitFeedback.form.title') }}</div>
|
||||
<div class="feedback-table">
|
||||
<div class="table-row">
|
||||
<div class="table-cell label required">{{ t('projectExitFeedback.form.projectName') }}</div>
|
||||
<div class="table-cell value project-name-cell" >
|
||||
{{ formData.projectName }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
<div class="table-cell label">{{ t('projectExitFeedback.form.exitTime') }}</div>
|
||||
<div class="table-cell value">
|
||||
{{ formData.exitTime }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
<div class="table-cell label">{{ t('projectExitFeedback.form.exitExecutor') }}</div>
|
||||
<div class="table-cell value">
|
||||
{{ formData.exitExecutor }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
<div class="table-cell label">{{ t('projectExitFeedback.form.exitDescription') }}</div>
|
||||
<div class="table-cell value">
|
||||
{{ formData.exitRemark }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
<div class="table-cell label">{{ t('projectExitFeedback.form.exitFiles') }}</div>
|
||||
<div class="table-cell value">
|
||||
<UploadFile :modelValue="formData.exitAttachment ? JSON.parse(formData.exitAttachment as string) : []" :fileSize="20" disabled type="simple" :limit="10" :isShowTip="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ProjectExitFeedback } from '/@/views/invMid/projectExitFeedback/interface/type';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: ProjectExitFeedback;
|
||||
}>();
|
||||
const formData = reactive<ProjectExitFeedback>({ ...props.modelValue });
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
Object.assign(formData, newVal);
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-exit-feedback-form {
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-heading {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.feedback-table {
|
||||
border: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.table-row:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
border-right: 1px solid var(--el-border-color);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-cell:last-of-type {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.table-cell.label {
|
||||
width: 160px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table-cell.label.required::after {
|
||||
content: '*';
|
||||
color: var(--el-color-danger);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.table-cell.value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.interactive-icon {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.feedback-table :deep(.el-input),
|
||||
.feedback-table :deep(.el-select),
|
||||
.feedback-table :deep(.el-date-editor),
|
||||
.feedback-table :deep(.el-textarea) {
|
||||
width: 100%;
|
||||
}
|
||||
.options {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.active {
|
||||
background-color: var(--el-color-primary);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user