first commit

This commit is contained in:
2025-12-26 23:19:09 +08:00
commit b29d128e41
788 changed files with 100922 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
import request from "/@/utils/request";
export const turbo = (content: string) => {
return request({
url: '/admin/chat/msg',
method: 'post',
data: {content: content},
});
};

View 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',
},
};

View File

@@ -0,0 +1,16 @@
export default {
chat: {
send: '发送',
inputPlaceholder: '请输入消息...',
title: 'AI 助手',
clearChat: '清空会话',
webSearchEnabled: '已开启联网搜索',
webSearchDisabled: '已关闭联网搜索',
welcome: '您好!我是通用大模型,请问有什么可以帮助您?',
thinking: '正在思考...',
thinkingCompleted: '已完成思考',
thinkingTime: '用时',
connectionError: '连接已断开,请重试',
seconds: '秒',
},
};

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

View File

@@ -0,0 +1,19 @@
<template>
<div></div>
</template>
<script setup lang="ts" name="check-token">
import { checkToken } from '/@/api/login';
const refreshLock = ref(false);
const refreshTime = ref();
onMounted(() => {
refreshToken();
});
const refreshToken = () => {
refreshTime.value = setInterval(() => {
checkToken(refreshTime.value, refreshLock.value);
}, 60000);
};
</script>

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

View File

@@ -0,0 +1,121 @@
<!--
* @Descripttion: 代码编辑器
* @version: 1.0
* @Author: sakuya
* @Date: 2022年5月20日21:46:29
* @LastEditors:
* @LastEditTime:
-->
<template>
<div class="code-editor" :style="{ height: _height }">
<textarea ref="textarea" v-model="contentValue"></textarea>
</div>
</template>
<script>
import { markRaw } from 'vue';
//框架
import CodeMirror from 'codemirror';
import 'codemirror/lib/codemirror.css';
//主题
import 'codemirror/theme/idea.css';
import 'codemirror/theme/darcula.css';
//功能
import 'codemirror/addon/selection/active-line';
//语言
import 'codemirror/mode/velocity/velocity';
import 'codemirror/mode/go/go';
export default {
props: {
modelValue: {
type: String,
default: '',
},
mode: {
type: String,
default: 'go',
},
height: {
type: [String, Number],
default: 300,
},
options: {
type: Object,
default: () => {},
},
theme: {
type: String,
default: 'idea',
},
readOnly: {
type: Boolean,
default: false,
},
},
data() {
return {
contentValue: this.modelValue,
coder: null,
opt: {
theme: this.theme, //主题
styleActiveLine: true, //高亮当前行
lineNumbers: true, //行号
lineWrapping: false, //自动换行
tabSize: 4, //Tab缩进
indentUnit: 4, //缩进单位
indentWithTabs: true, //自动缩进
mode: this.mode, //语言
readOnly: this.readOnly, //只读
...this.options,
},
};
},
computed: {
_height() {
return Number(this.height) ? Number(this.height) + 'px' : this.height;
},
},
watch: {
modelValue(val) {
this.contentValue = val;
if (val !== this.coder.getValue()) {
this.coder.setValue(val);
}
},
},
mounted() {
this.init();
//获取挂载的所有modes
//console.log(CodeMirror.modes)
},
methods: {
init() {
this.coder = markRaw(CodeMirror.fromTextArea(this.$refs.textarea, this.opt));
this.coder.on('change', (coder) => {
this.contentValue = coder.getValue();
this.$emit('update:modelValue', this.contentValue);
});
},
formatStrInJson(strValue) {
return JSON.stringify(JSON.parse(strValue), null, 4);
},
},
};
</script>
<style scoped>
.code-editor {
font-size: 14px;
border: 1px solid #ddd;
line-height: 150%;
}
.code-editor:deep(.CodeMirror) {
height: 100%;
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<div class="color-picker flex flex-1">
<el-color-picker v-model="color" :predefine="predefineColors" />
<el-input v-model="color" class="mx-[10px] flex-1" type="text" readonly />
<el-button type="text" @click="reset">重置</el-button>
</div>
</template>
<script lang="ts" setup>
const props = defineProps({
modelValue: {
type: String,
},
defaultColor: {
type: String,
},
});
const emit = defineEmits<{
(event: 'update:modelValue', value: any): void;
}>();
const color = computed({
get() {
return props.modelValue;
},
set(value) {
emit('update:modelValue', value);
},
});
const predefineColors = ['#409EFF', '#28C76F', '#EA5455', '#FF9F43', '#01CFE8', '#4A5DFF'];
const reset = () => {
color.value = props.defaultColor;
};
</script>

View File

@@ -0,0 +1,806 @@
<!--
* @Descripttion: cron规则生成器
* @version: 1.0
* @Author: sakuya
* @Date: 2023年05月23日13:04:12
* @LastEditors:
* @LastEditTime:
-->
<template>
<el-input v-model="defaultValue" v-bind="$attrs">
<template #append>
<el-dropdown size="medium" @command="handleShortcuts">
<el-button icon="el-icon-arrow-down"></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="0 * * * * ?">每分钟</el-dropdown-item>
<el-dropdown-item command="0 0 * * * ?">每小时</el-dropdown-item>
<el-dropdown-item command="0 0 0 * * ?">每天零点</el-dropdown-item>
<el-dropdown-item command="0 0 0 1 * ?">每月一号零点</el-dropdown-item>
<el-dropdown-item command="0 0 0 L * ?">每月最后一天零点</el-dropdown-item>
<el-dropdown-item command="0 0 0 ? * 1">每周星期日零点</el-dropdown-item>
<el-dropdown-item v-for="(item, index) in shortcuts" :key="item.value" :divided="index == 0" :command="item.value">{{
item.text
}}</el-dropdown-item>
<el-dropdown-item icon="el-icon-plus" divided command="custom">自定义</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-input>
<el-dialog title="cron规则生成器" v-model="dialogVisible" :width="580" destroy-on-close append-to-body>
<div class="sc-cron">
<el-tabs>
<el-tab-pane>
<template #label>
<div class="sc-cron-num">
<h2></h2>
<h4>{{ value_second }}</h4>
</div>
</template>
<el-form>
<el-form-item label="类型">
<el-radio-group v-model="value.second.type">
<el-radio-button label="0">任意值</el-radio-button>
<el-radio-button label="1">范围</el-radio-button>
<el-radio-button label="2">间隔</el-radio-button>
<el-radio-button label="3">指定</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围" v-if="value.second.type == 1">
<el-input-number v-model="value.second.range.start" :min="0" :max="59" controls-position="right"></el-input-number>
<span style="padding: 0 15px">-</span>
<el-input-number v-model="value.second.range.end" :min="0" :max="59" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="间隔" v-if="value.second.type == 2">
<el-input-number v-model="value.second.loop.start" :min="0" :max="59" controls-position="right"></el-input-number>
秒开始
<el-input-number v-model="value.second.loop.end" :min="0" :max="59" controls-position="right"></el-input-number>
秒执行一次
</el-form-item>
<el-form-item label="指定" v-if="value.second.type == 3">
<el-select v-model="value.second.appoint" multiple style="width: 100%">
<el-option v-for="(item, index) in data.second" :key="index" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane>
<template #label>
<div class="sc-cron-num">
<h2>分钟</h2>
<h4>{{ value_minute }}</h4>
</div>
</template>
<el-form>
<el-form-item label="类型">
<el-radio-group v-model="value.minute.type">
<el-radio-button label="0">任意值</el-radio-button>
<el-radio-button label="1">范围</el-radio-button>
<el-radio-button label="2">间隔</el-radio-button>
<el-radio-button label="3">指定</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围" v-if="value.minute.type == 1">
<el-input-number v-model="value.minute.range.start" :min="0" :max="59" controls-position="right"></el-input-number>
<span style="padding: 0 15px">-</span>
<el-input-number v-model="value.minute.range.end" :min="0" :max="59" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="间隔" v-if="value.minute.type == 2">
<el-input-number v-model="value.minute.loop.start" :min="0" :max="59" controls-position="right"></el-input-number>
分钟开始
<el-input-number v-model="value.minute.loop.end" :min="0" :max="59" controls-position="right"></el-input-number>
分钟执行一次
</el-form-item>
<el-form-item label="指定" v-if="value.minute.type == 3">
<el-select v-model="value.minute.appoint" multiple style="width: 100%">
<el-option v-for="(item, index) in data.minute" :key="index" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane>
<template #label>
<div class="sc-cron-num">
<h2>小时</h2>
<h4>{{ value_hour }}</h4>
</div>
</template>
<el-form>
<el-form-item label="类型">
<el-radio-group v-model="value.hour.type">
<el-radio-button label="0">任意值</el-radio-button>
<el-radio-button label="1">范围</el-radio-button>
<el-radio-button label="2">间隔</el-radio-button>
<el-radio-button label="3">指定</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围" v-if="value.hour.type == 1">
<el-input-number v-model="value.hour.range.start" :min="0" :max="23" controls-position="right"></el-input-number>
<span style="padding: 0 15px">-</span>
<el-input-number v-model="value.hour.range.end" :min="0" :max="23" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="间隔" v-if="value.hour.type == 2">
<el-input-number v-model="value.hour.loop.start" :min="0" :max="23" controls-position="right"></el-input-number>
小时开始
<el-input-number v-model="value.hour.loop.end" :min="0" :max="23" controls-position="right"></el-input-number>
小时执行一次
</el-form-item>
<el-form-item label="指定" v-if="value.hour.type == 3">
<el-select v-model="value.hour.appoint" multiple style="width: 100%">
<el-option v-for="(item, index) in data.hour" :key="index" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane>
<template #label>
<div class="sc-cron-num">
<h2></h2>
<h4>{{ value_day }}</h4>
</div>
</template>
<el-form>
<el-form-item label="类型">
<el-radio-group v-model="value.day.type">
<el-radio-button label="0">任意值</el-radio-button>
<el-radio-button label="1">范围</el-radio-button>
<el-radio-button label="2">间隔</el-radio-button>
<el-radio-button label="3">指定</el-radio-button>
<el-radio-button label="4">本月最后一天</el-radio-button>
<el-radio-button label="5">不指定</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围" v-if="value.day.type == 1">
<el-input-number v-model="value.day.range.start" :min="1" :max="31" controls-position="right"></el-input-number>
<span style="padding: 0 15px">-</span>
<el-input-number v-model="value.day.range.end" :min="1" :max="31" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="间隔" v-if="value.day.type == 2">
<el-input-number v-model="value.day.loop.start" :min="1" :max="31" controls-position="right"></el-input-number>
号开始
<el-input-number v-model="value.day.loop.end" :min="1" :max="31" controls-position="right"></el-input-number>
天执行一次
</el-form-item>
<el-form-item label="指定" v-if="value.day.type == 3">
<el-select v-model="value.day.appoint" multiple style="width: 100%">
<el-option v-for="(item, index) in data.day" :key="index" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane>
<template #label>
<div class="sc-cron-num">
<h2></h2>
<h4>{{ value_month }}</h4>
</div>
</template>
<el-form>
<el-form-item label="类型">
<el-radio-group v-model="value.month.type">
<el-radio-button label="0">任意值</el-radio-button>
<el-radio-button label="1">范围</el-radio-button>
<el-radio-button label="2">间隔</el-radio-button>
<el-radio-button label="3">指定</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围" v-if="value.month.type == 1">
<el-input-number v-model="value.month.range.start" :min="1" :max="12" controls-position="right"></el-input-number>
<span style="padding: 0 15px">-</span>
<el-input-number v-model="value.month.range.end" :min="1" :max="12" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="间隔" v-if="value.month.type == 2">
<el-input-number v-model="value.month.loop.start" :min="1" :max="12" controls-position="right"></el-input-number>
月开始
<el-input-number v-model="value.month.loop.end" :min="1" :max="12" controls-position="right"></el-input-number>
月执行一次
</el-form-item>
<el-form-item label="指定" v-if="value.month.type == 3">
<el-select v-model="value.month.appoint" multiple style="width: 100%">
<el-option v-for="(item, index) in data.month" :key="index" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane>
<template #label>
<div class="sc-cron-num">
<h2></h2>
<h4>{{ value_week }}</h4>
</div>
</template>
<el-form>
<el-form>
<el-form-item label="类型">
<el-radio-group v-model="value.week.type">
<el-radio-button label="0">任意值</el-radio-button>
<el-radio-button label="1">范围</el-radio-button>
<el-radio-button label="2">间隔</el-radio-button>
<el-radio-button label="3">指定</el-radio-button>
<el-radio-button label="4">本月最后一周</el-radio-button>
<el-radio-button label="5">不指定</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围" v-if="value.week.type == 1">
<el-select v-model="value.week.range.start">
<el-option v-for="(item, index) in data.week" :key="index" :label="item.label" :value="item.value"></el-option>
</el-select>
<span style="padding: 0 15px">-</span>
<el-select v-model="value.week.range.end">
<el-option v-for="(item, index) in data.week" :key="index" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
<el-form-item label="间隔" v-if="value.week.type == 2">
<el-input-number v-model="value.week.loop.start" :min="1" :max="4" controls-position="right"></el-input-number>
周的星期
<el-select v-model="value.week.loop.end">
<el-option v-for="(item, index) in data.week" :key="index" :label="item.label" :value="item.value"></el-option>
</el-select>
执行一次
</el-form-item>
<el-form-item label="指定" v-if="value.week.type == 3">
<el-select v-model="value.week.appoint" multiple style="width: 100%">
<el-option v-for="(item, index) in data.week" :key="index" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
<el-form-item label="最后一周" v-if="value.week.type == 4">
<el-select v-model="value.week.last">
<el-option v-for="(item, index) in data.week" :key="index" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-form>
</el-tab-pane>
<el-tab-pane>
<template #label>
<div class="sc-cron-num">
<h2></h2>
<h4>{{ value_year }}</h4>
</div>
</template>
<el-form>
<el-form-item label="类型">
<el-radio-group v-model="value.year.type">
<el-radio-button label="-1">忽略</el-radio-button>
<el-radio-button label="0">任意值</el-radio-button>
<el-radio-button label="1">范围</el-radio-button>
<el-radio-button label="2">间隔</el-radio-button>
<el-radio-button label="3">指定</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="范围" v-if="value.year.type == 1">
<el-input-number v-model="value.year.range.start" controls-position="right"></el-input-number>
<span style="padding: 0 15px">-</span>
<el-input-number v-model="value.year.range.end" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="间隔" v-if="value.year.type == 2">
<el-input-number v-model="value.year.loop.start" controls-position="right"></el-input-number>
年开始
<el-input-number v-model="value.year.loop.end" :min="1" controls-position="right"></el-input-number>
年执行一次
</el-form-item>
<el-form-item label="指定" v-if="value.year.type == 3">
<el-select v-model="value.year.appoint" multiple style="width: 100%">
<el-option v-for="(item, index) in data.year" :key="index" :label="item" :value="item"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<el-button @click="dialogVisible = false"> </el-button>
<el-button type="primary" @click="submit()"> </el-button>
</template>
</el-dialog>
</template>
<script>
export default {
props: {
modelValue: { type: String, default: '* * * * * ?' },
shortcuts: { type: Array, default: () => [] },
},
data() {
return {
type: '0',
defaultValue: '',
dialogVisible: false,
value: {
second: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 0,
end: 1,
},
appoint: [],
},
minute: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 0,
end: 1,
},
appoint: [],
},
hour: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 0,
end: 1,
},
appoint: [],
},
day: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 1,
end: 1,
},
appoint: [],
},
month: {
type: '0',
range: {
start: 1,
end: 2,
},
loop: {
start: 1,
end: 1,
},
appoint: [],
},
week: {
type: '5',
range: {
start: '2',
end: '3',
},
loop: {
start: 0,
end: '2',
},
last: '2',
appoint: [],
},
year: {
type: '-1',
range: {
start: this.getYear()[0],
end: this.getYear()[1],
},
loop: {
start: this.getYear()[0],
end: 1,
},
appoint: [],
},
},
data: {
second: ['0', '5', '15', '20', '25', '30', '35', '40', '45', '50', '55', '59'],
minute: ['0', '5', '15', '20', '25', '30', '35', '40', '45', '50', '55', '59'],
hour: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23'],
day: [
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
'11',
'12',
'13',
'14',
'15',
'16',
'17',
'18',
'19',
'20',
'21',
'22',
'23',
'24',
'25',
'26',
'27',
'28',
'29',
'30',
'31',
],
month: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
week: [
{
value: '1',
label: '周日',
},
{
value: '2',
label: '周一',
},
{
value: '3',
label: '周二',
},
{
value: '4',
label: '周三',
},
{
value: '5',
label: '周四',
},
{
value: '6',
label: '周五',
},
{
value: '7',
label: '周六',
},
],
year: this.getYear(),
},
};
},
watch: {
'value.week.type'(val) {
if (val != '5') {
this.value.day.type = '5';
}
},
'value.day.type'(val) {
if (val != '5') {
this.value.week.type = '5';
}
},
modelValue() {
this.defaultValue = this.modelValue;
},
},
computed: {
value_second() {
let v = this.value.second;
if (v.type == 0) {
return '*';
} else if (v.type == 1) {
return v.range.start + '-' + v.range.end;
} else if (v.type == 2) {
return v.loop.start + '/' + v.loop.end;
} else if (v.type == 3) {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
} else {
return '*';
}
},
value_minute() {
let v = this.value.minute;
if (v.type == 0) {
return '*';
} else if (v.type == 1) {
return v.range.start + '-' + v.range.end;
} else if (v.type == 2) {
return v.loop.start + '/' + v.loop.end;
} else if (v.type == 3) {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
} else {
return '*';
}
},
value_hour() {
let v = this.value.hour;
if (v.type == 0) {
return '*';
} else if (v.type == 1) {
return v.range.start + '-' + v.range.end;
} else if (v.type == 2) {
return v.loop.start + '/' + v.loop.end;
} else if (v.type == 3) {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
} else {
return '*';
}
},
value_day() {
let v = this.value.day;
if (v.type == 0) {
return '*';
} else if (v.type == 1) {
return v.range.start + '-' + v.range.end;
} else if (v.type == 2) {
return v.loop.start + '/' + v.loop.end;
} else if (v.type == 3) {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
} else if (v.type == 4) {
return 'L';
} else if (v.type == 5) {
return '?';
} else {
return '*';
}
},
value_month() {
let v = this.value.month;
if (v.type == 0) {
return '*';
} else if (v.type == 1) {
return v.range.start + '-' + v.range.end;
} else if (v.type == 2) {
return v.loop.start + '/' + v.loop.end;
} else if (v.type == 3) {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
} else {
return '*';
}
},
value_week() {
let v = this.value.week;
if (v.type == 0) {
return '*';
} else if (v.type == 1) {
return v.range.start + '-' + v.range.end;
} else if (v.type == 2) {
return v.loop.end + '#' + v.loop.start;
} else if (v.type == 3) {
return v.appoint.length > 0 ? v.appoint.join(',') : '*';
} else if (v.type == 4) {
return v.last + 'L';
} else if (v.type == 5) {
return '?';
} else {
return '*';
}
},
value_year() {
let v = this.value.year;
if (v.type == -1) {
return '';
} else if (v.type == 0) {
return '*';
} else if (v.type == 1) {
return v.range.start + '-' + v.range.end;
} else if (v.type == 2) {
return v.loop.start + '/' + v.loop.end;
} else if (v.type == 3) {
return v.appoint.length > 0 ? v.appoint.join(',') : '';
} else {
return '';
}
},
},
mounted() {
this.defaultValue = this.modelValue;
},
methods: {
handleShortcuts(command) {
if (command == 'custom') {
this.open();
} else {
this.defaultValue = command;
this.$emit('update:modelValue', this.defaultValue);
}
},
open() {
this.set();
this.dialogVisible = true;
},
set() {
this.defaultValue = this.modelValue;
let arr = (this.modelValue || '* * * * * ?').split(' ');
//简单检查
if (arr.length < 6) {
this.$message.warning('cron表达式错误已转换为默认表达式');
arr = '* * * * * ?'.split(' ');
}
//秒
if (arr[0] == '*') {
this.value.second.type = '0';
} else if (arr[0].includes('-')) {
this.value.second.type = '1';
this.value.second.range.start = Number(arr[0].split('-')[0]);
this.value.second.range.end = Number(arr[0].split('-')[1]);
} else if (arr[0].includes('/')) {
this.value.second.type = '2';
this.value.second.loop.start = Number(arr[0].split('/')[0]);
this.value.second.loop.end = Number(arr[0].split('/')[1]);
} else {
this.value.second.type = '3';
this.value.second.appoint = arr[0].split(',');
}
//分
if (arr[1] == '*') {
this.value.minute.type = '0';
} else if (arr[1].includes('-')) {
this.value.minute.type = '1';
this.value.minute.range.start = Number(arr[1].split('-')[0]);
this.value.minute.range.end = Number(arr[1].split('-')[1]);
} else if (arr[1].includes('/')) {
this.value.minute.type = '2';
this.value.minute.loop.start = Number(arr[1].split('/')[0]);
this.value.minute.loop.end = Number(arr[1].split('/')[1]);
} else {
this.value.minute.type = '3';
this.value.minute.appoint = arr[1].split(',');
}
//小时
if (arr[2] == '*') {
this.value.hour.type = '0';
} else if (arr[2].includes('-')) {
this.value.hour.type = '1';
this.value.hour.range.start = Number(arr[2].split('-')[0]);
this.value.hour.range.end = Number(arr[2].split('-')[1]);
} else if (arr[2].includes('/')) {
this.value.hour.type = '2';
this.value.hour.loop.start = Number(arr[2].split('/')[0]);
this.value.hour.loop.end = Number(arr[2].split('/')[1]);
} else {
this.value.hour.type = '3';
this.value.hour.appoint = arr[2].split(',');
}
//日
if (arr[3] == '*') {
this.value.day.type = '0';
} else if (arr[3] == 'L') {
this.value.day.type = '4';
} else if (arr[3] == '?') {
this.value.day.type = '5';
} else if (arr[3].includes('-')) {
this.value.day.type = '1';
this.value.day.range.start = Number(arr[3].split('-')[0]);
this.value.day.range.end = Number(arr[3].split('-')[1]);
} else if (arr[3].includes('/')) {
this.value.day.type = '2';
this.value.day.loop.start = Number(arr[3].split('/')[0]);
this.value.day.loop.end = Number(arr[3].split('/')[1]);
} else {
this.value.day.type = '3';
this.value.day.appoint = arr[3].split(',');
}
//月
if (arr[4] == '*') {
this.value.month.type = '0';
} else if (arr[4].includes('-')) {
this.value.month.type = '1';
this.value.month.range.start = Number(arr[4].split('-')[0]);
this.value.month.range.end = Number(arr[4].split('-')[1]);
} else if (arr[4].includes('/')) {
this.value.month.type = '2';
this.value.month.loop.start = Number(arr[4].split('/')[0]);
this.value.month.loop.end = Number(arr[4].split('/')[1]);
} else {
this.value.month.type = '3';
this.value.month.appoint = arr[4].split(',');
}
//周
if (arr[5] == '*') {
this.value.week.type = '0';
} else if (arr[5] == '?') {
this.value.week.type = '5';
} else if (arr[5].includes('-')) {
this.value.week.type = '1';
this.value.week.range.start = arr[5].split('-')[0];
this.value.week.range.end = arr[5].split('-')[1];
} else if (arr[5].includes('#')) {
this.value.week.type = '2';
this.value.week.loop.start = Number(arr[5].split('#')[1]);
this.value.week.loop.end = arr[5].split('#')[0];
} else if (arr[5].includes('L')) {
this.value.week.type = '4';
this.value.week.last = arr[5].split('L')[0];
} else {
this.value.week.type = '3';
this.value.week.appoint = arr[5].split(',');
}
//年
if (!arr[6]) {
this.value.year.type = '-1';
} else if (arr[6] == '*') {
this.value.year.type = '0';
} else if (arr[6].includes('-')) {
this.value.year.type = '1';
this.value.year.range.start = Number(arr[6].split('-')[0]);
this.value.year.range.end = Number(arr[6].split('-')[1]);
} else if (arr[6].includes('/')) {
this.value.year.type = '2';
this.value.year.loop.start = Number(arr[6].split('/')[1]);
this.value.year.loop.end = Number(arr[6].split('/')[0]);
} else {
this.value.year.type = '3';
this.value.year.appoint = arr[6].split(',');
}
},
getYear() {
let v = [];
let y = new Date().getFullYear();
for (let i = 0; i < 11; i++) {
v.push(y + i);
}
return v;
},
submit() {
let year = this.value_year ? ' ' + this.value_year : '';
this.defaultValue =
this.value_second +
' ' +
this.value_minute +
' ' +
this.value_hour +
' ' +
this.value_day +
' ' +
this.value_month +
' ' +
this.value_week +
year;
this.$emit('update:modelValue', this.defaultValue);
this.dialogVisible = false;
},
},
};
</script>
<style scoped>
.sc-cron:deep(.el-tabs__item) {
height: auto;
line-height: 1;
padding: 0 7px;
vertical-align: bottom;
}
.sc-cron-num {
text-align: center;
margin-bottom: 15px;
width: 100%;
}
.sc-cron-num h2 {
font-size: 12px;
margin-bottom: 15px;
font-weight: normal;
}
.sc-cron-num h4 {
display: block;
height: 32px;
line-height: 30px;
width: 100%;
font-size: 12px;
padding: 0 15px;
background: var(--el-color-primary-light-9);
border-radius: 4px;
}
.sc-cron:deep(.el-tabs__item.is-active) .sc-cron-num h4 {
background: var(--el-color-primary);
color: #fff;
}
[data-theme='dark'] .sc-cron-num h4 {
background: var(--el-color-white);
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div class="del-wrap">
<slot></slot>
<div v-if="showClose" class="icon-close" @click.stop="handleClose">
<el-icon><CloseBold /></el-icon>
</div>
</div>
</template>
<script lang="ts">
export default defineComponent({
props: {
showClose: {
type: Boolean,
default: true,
},
},
emits: ['close'],
setup(props, { emit }) {
const handleClose = () => {
emit('close');
};
return {
handleClose,
};
},
});
</script>
<style scoped lang="scss">
.del-wrap {
position: relative;
&:hover > .icon-close {
display: flex;
}
.icon-close {
display: none;
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
background-color: rgba(0, 0, 0, 0.3);
justify-content: center;
align-items: center;
border-radius: 50%;
color: #fff;
cursor: pointer;
}
}
</style>

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

View File

@@ -0,0 +1,48 @@
<template>
<div>
<template v-for="(item, index) in props.options">
<template v-if="values.includes(item.value || item)">
<span v-if="item.elTagType == 'default' || item.elTagType == ''" :key="index" :index="index" :class="item.elTagClass">{{
item.label || item
}}</span>
<el-tag
v-else
:disable-transitions="true"
:key="index * 2"
:index="index"
:type="item.elTagType === 'primary' ? '' : item.elTagType"
:class="item.elTagClass"
>{{ item.label || item }}</el-tag
>
</template>
</template>
</div>
</template>
<script setup lang="ts" name="dict-tag">
import { computed } from 'vue';
const props = defineProps({
// 数据
options: {
type: Array as any,
default: null,
},
// 当前的值
value: [Number, String, Array],
});
const values = computed(() => {
if (props.value !== null && typeof props.value !== 'undefined') {
return Array.isArray(props.value) ? props.value : [String(props.value)];
} else {
return [];
}
});
</script>
<style scoped>
.el-tag + .el-tag {
margin-left: 10px;
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="flex flex-col border border-br" :style="styles">
<Toolbar class="border-b border-br" :editor="editorRef" :mode="mode" />
<Editor
class="flex-1 overflow-y-auto"
:mode="mode"
:defaultConfig="state.editorConfig"
v-model="state.editorVal"
@onCreated="handleCreated"
@onChange="handleChange"
/>
</div>
</template>
<script setup lang="ts" name="wngEditor">
import '@wangeditor/editor/dist/css/style.css';
import { reactive, shallowRef, watch, onBeforeUnmount, CSSProperties } from 'vue';
// @ts-ignore
import { IDomEditor } from '@wangeditor/editor';
import { Toolbar, Editor } from '@wangeditor/editor-for-vue';
import { Session } from '/@/utils/storage';
import other from '/@/utils/other';
const { proxy } = getCurrentInstance();
// 定义父组件传过来的值
const props = defineProps({
// 是否禁用
disable: {
type: Boolean,
default: () => false,
},
// 内容框默认 placeholder
placeholder: {
type: String,
default: () => '请输入内容...',
},
// https://www.wangeditor.com/v5/getting-started.html#mode-%E6%A8%A1%E5%BC%8F
// 模式,可选 <default|simple>,默认 default
mode: {
type: String,
default: () => 'default',
},
// 高度
height: {
type: String,
default: () => '310',
},
// 宽度
width: {
type: String,
default: () => 'auto',
},
// 双向绑定,用于获取 editor.getHtml()
getHtml: String,
// 双向绑定,用于获取 editor.getText()
getText: String,
uploadFileUrl: {
type: String,
default: `/admin/sys-file/upload`,
},
});
// 定义子组件向父组件传值/事件
const emit = defineEmits(['update:getHtml', 'update:getText']);
// 定义上传需要的请求头信息
const headers = computed(() => {
return {
Authorization: 'Bearer ' + Session.get('token'),
'TENANT-ID': Session.getTenant(),
};
});
// 定义上传需要的字段信息
const uploadAttr = reactive({
fieldName: 'file',
server: proxy.baseURL + props.uploadFileUrl,
headers: headers,
customInsert(res, insertFn) {
insertFn(proxy.baseURL + res.data.url);
},
});
const editorRef = shallowRef();
const state = reactive({
editorConfig: {
placeholder: props.placeholder,
MENU_CONF: {
uploadImage: uploadAttr,
uploadVideo: uploadAttr,
},
},
editorVal: props.getHtml,
});
const styles = computed<CSSProperties>(() => ({
height: other.addUnit(props.height),
width: other.addUnit(props.width),
'z-index': 1000,
}));
// 编辑器回调函数
const handleCreated = (editor: IDomEditor) => {
editorRef.value = editor;
};
// 编辑器内容改变时
const handleChange = (editor: IDomEditor) => {
emit('update:getHtml', editor.getHtml());
emit('update:getText', editor.getText());
};
// 页面销毁时
onBeforeUnmount(() => {
const editor = editorRef.value;
if (editor == null) return;
emit('update:getHtml', '');
emit('update:getText', '');
editor.destroy();
});
// 监听是否禁用改变
onMounted(() =>{
nextTick(()=>{
const editor = editorRef.value;
if (editor == null) return;
props.disable ? editor.disable() : editor.enable();
})
})
// 监听双向绑定值改变,用于回显
watch(
() => props.getHtml,
(val) => {
state.editorVal = val;
},
{
deep: true,
}
);
</script>

View File

@@ -0,0 +1,210 @@
<template>
<div class="form-table" ref="scFormTable">
<el-table
:data="data"
ref="table"
border
stripe
:cell-style="{ textAlign: 'center' }"
:header-cell-style="{
textAlign: 'center',
background: 'var(--el-table-row-hover-bg-color)',
color: 'var(--el-text-color-primary)',
}"
>
<el-table-column type="index" width="50" fixed="left">
<template #header>
<el-button v-if="!hideAdd" type="primary" icon="el-icon-plus" size="small" circle @click="rowAdd"></el-button>
<el-tooltip v-else content="序号" placement="top"> # </el-tooltip>
</template>
<template #default="scope">
<div :class="['form-table-handle', { 'form-table-handle-delete': !hideDelete }]">
<span>{{ scope.$index + 1 }}</span>
<el-button
v-if="!hideDelete"
type="danger"
icon="el-icon-delete"
size="small"
plain
circle
@click="rowDel(scope.row, scope.$index)"
></el-button>
</div>
</template>
</el-table-column>
<el-table-column label="" width="50" v-if="dragSort">
<template #header>
<el-icon>
<el-tooltip content="拖动排序" placement="top">
<WarningFilled />
</el-tooltip>
</el-icon>
</template>
<template #default>
<div class="move" style="cursor: move">
<el-icon>
<Sort />
</el-icon>
</div>
</template>
</el-table-column>
<slot></slot>
<template #empty>
{{ placeholder }}
</template>
</el-table>
</div>
</template>
<script>
import Sortable from 'sortablejs';
export default {
props: {
/**
* 表格数据
*/
modelValue: { type: Array, default: () => [] },
/**
* 新增行模板
*/
addTemplate: { type: Object, default: () => {} },
/**
* 无数据时的提示语
*/
placeholder: { type: String, default: '暂无数据' },
/**
* 是否启用拖拽排序
*/
dragSort: { type: Boolean, default: false },
/**
* 是否隐藏新增按钮
*/
hideAdd: { type: Boolean, default: false },
/**
* 是否隐藏删除按钮
*/
hideDelete: { type: Boolean, default: false },
},
data() {
return {
/**
* 表格数据
*/
data: [],
};
},
mounted() {
this.data = this.modelValue;
if (this.dragSort) {
this.rowDrop();
}
},
watch: {
modelValue() {
this.data = this.modelValue;
},
data: {
handler() {
/**
* 更新表格数据
* @event update:modelValue
* @type {Array}
*/
this.$emit('update:modelValue', this.data);
},
deep: true,
},
},
methods: {
/**
* 启用表格行拖拽排序
*/
rowDrop() {
const _this = this;
const tbody = this.$refs.table.$el.querySelector('.el-table__body-wrapper tbody');
Sortable.create(tbody, {
handle: '.move',
animation: 300,
ghostClass: 'ghost',
onEnd({ newIndex, oldIndex }) {
_this.data.splice(newIndex, 0, _this.data.splice(oldIndex, 1)[0]);
const newArray = _this.data.slice(0);
const tmpHeight = _this.$refs.scFormTable.offsetHeight;
_this.$refs.scFormTable.style.setProperty('height', tmpHeight + 'px');
_this.data = [];
_this.$nextTick(() => {
_this.data = newArray;
_this.$nextTick(() => {
_this.$refs.scFormTable.style.removeProperty('height');
});
});
},
});
},
/**
* 新增一行
*/
rowAdd() {
const temp = JSON.parse(JSON.stringify(this.addTemplate));
this.data.push(temp);
},
/**
* 删除一行
* @param {Object} row - 要删除的行数据
* @param {number} index - 要删除的行的索引
*/
rowDel(row, index) {
this.data.splice(index, 1);
this.$emit('delete', row);
},
/**
* 插入一行
* @param {Object} row - 要插入的行数据,默认为新增行模板
*/
pushRow(row) {
const temp = row || JSON.parse(JSON.stringify(this.addTemplate));
this.data.push(temp);
},
/**
* 根据索引删除一行
* @param {number} index - 要删除的行的索引
*/
deleteRow(index) {
this.data.splice(index, 1);
},
},
};
</script>
<style scoped>
.form-table {
width: 100%;
}
.form-table .form-table-handle {
text-align: center;
}
.form-table .form-table-handle span {
display: inline-block;
}
.form-table .form-table-handle button {
display: none;
}
.form-table .hover-row .form-table-handle-delete span {
display: none;
}
.form-table .hover-row .form-table-handle-delete button {
display: inline-block;
}
.form-table .move {
text-align: center;
font-size: 14px;
margin-top: 3px;
}
</style>

View File

@@ -0,0 +1,71 @@
import { readFileSync, readdirSync } from 'fs';
let idPerfix = '';
const iconNames: string[] = [];
const svgTitle = /<svg([^>+].*?)>/;
const clearHeightWidth = /(width|height)="([^>+].*?)"/g;
const hasViewBox = /(viewBox="[^>+].*?")/g;
const clearReturn = /(\r)|(\n)/g;
// 清理 svg 的 fill
const clearFill = /(fill="[^>+].*?")/g;
function findSvgFile(dir: string): string[] {
const svgRes = [] as any;
const dirents = readdirSync(dir, {
withFileTypes: true,
});
for (const dirent of dirents) {
iconNames.push(`${idPerfix}-${dirent.name.replace('.svg', '')}`);
if (dirent.isDirectory()) {
svgRes.push(...findSvgFile(dir + dirent.name + '/'));
} else {
const svg = readFileSync(dir + dirent.name)
.toString()
.replace(clearReturn, '')
.replace(clearFill, 'fill=""')
.replace(svgTitle, ($1, $2) => {
let width = 0;
let height = 0;
let content = $2.replace(clearHeightWidth, (s1: string, s2: string, s3: number) => {
if (s2 === 'width') {
width = s3;
} else if (s2 === 'height') {
height = s3;
}
return '';
});
if (!hasViewBox.test($2)) {
content += `viewBox="0 0 ${width} ${height}"`;
}
return `<symbol id="${idPerfix}-${dirent.name.replace('.svg', '')}" ${content}>`;
})
.replace('</svg>', '</symbol>');
svgRes.push(svg);
}
}
return svgRes;
}
export const svgBuilder = (path: string, perfix = 'local') => {
if (path === '') return;
idPerfix = perfix;
const res = findSvgFile(path);
return {
name: 'svg-transform',
transformIndexHtml(html: string) {
/* eslint-disable */
return html.replace(
'<body>',
`
<body>
<svg id="local-icon" data-icon-name="${iconNames.join(
','
)}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="position: absolute; width: 0; height: 0">
${res.join('')}
</svg>
`
);
/* eslint-enable */
},
};
};

View File

@@ -0,0 +1,254 @@
<template>
<div class="icon-selector w100 h100">
<el-input
v-model="state.fontIconSearch"
:placeholder="state.fontIconPlaceholder"
:clearable="clearable"
:disabled="disabled"
:size="size"
ref="inputWidthRef"
@clear="onClearFontIcon"
@focus="onIconFocus"
@blur="onIconBlur"
>
<template #prepend>
<SvgIcon :name="state.fontIconPrefix === '' ? prepend : state.fontIconPrefix" class="font14" />
</template>
</el-input>
<el-popover
placement="bottom"
:width="state.fontIconWidth"
transition="el-zoom-in-top"
popper-class="icon-selector-popper"
trigger="click"
:virtual-ref="inputWidthRef"
virtual-triggering
>
<template #default>
<div class="icon-selector-warp">
<div class="icon-selector-warp-title">{{ title }}</div>
<el-tabs v-model="state.fontIconTabActive" @tab-click="onIconClick">
<el-tab-pane lazy label="ali" name="ali">
<IconList :list="fontIconSheetsFilterList" :empty="emptyDescription" :prefix="state.fontIconPrefix" @get-icon="onColClick" />
</el-tab-pane>
<el-tab-pane lazy label="ele" name="ele">
<IconList :list="fontIconSheetsFilterList" :empty="emptyDescription" :prefix="state.fontIconPrefix" @get-icon="onColClick" />
</el-tab-pane>
<el-tab-pane lazy label="awe" name="awe">
<IconList :list="fontIconSheetsFilterList" :empty="emptyDescription" :prefix="state.fontIconPrefix" @get-icon="onColClick" />
</el-tab-pane>
<el-tab-pane lazy label="local" name="local">
<IconList :list="fontIconSheetsFilterList" :empty="emptyDescription" :prefix="state.fontIconPrefix" @get-icon="onColClick" />
</el-tab-pane>
</el-tabs>
</div>
</template>
</el-popover>
</div>
</template>
<script setup lang="ts" name="iconSelector">
import { defineAsyncComponent, ref, reactive, onMounted, nextTick, computed, watch } from 'vue';
import type { TabsPaneContext } from 'element-plus';
import initIconfont from '/@/utils/getStyleSheets';
import '/@/theme/iconSelector.scss';
// 定义父组件传过来的值
const props = defineProps({
// 输入框前置内容
prepend: {
type: String,
default: () => 'ele-Pointer',
},
// 输入框占位文本
placeholder: {
type: String,
default: () => '请输入内容搜索图标或者选择图标',
},
// 输入框占位文本
size: {
type: String,
default: () => 'default',
},
// 弹窗标题
title: {
type: String,
default: () => '请选择图标',
},
// 禁用
disabled: {
type: Boolean,
default: () => false,
},
// 是否可清空
clearable: {
type: Boolean,
default: () => true,
},
// 自定义空状态描述文字
emptyDescription: {
type: String,
default: () => '无相关图标',
},
// 双向绑定值,默认为 modelValue
// 参考https://v3.cn.vuejs.org/guide/migration/v-model.html#%E8%BF%81%E7%A7%BB%E7%AD%96%E7%95%A5
// 参考https://v3.cn.vuejs.org/guide/component-custom-events.html#%E5%A4%9A%E4%B8%AA-v-model-%E7%BB%91%E5%AE%9A
modelValue: String,
});
// 定义子组件向父组件传值/事件
const emit = defineEmits(['update:modelValue', 'get', 'clear']);
// 引入组件
const IconList = defineAsyncComponent(() => import('/@/components/IconSelector/list.vue'));
// 定义变量内容
const inputWidthRef = ref();
const state = reactive({
fontIconPrefix: '',
fontIconWidth: 0,
fontIconSearch: '',
fontIconPlaceholder: '',
fontIconTabActive: 'ali',
fontIconList: {
ali: [],
ele: [],
awe: [],
local: [],
},
});
// 处理 input 获取焦点时modelValue 有值时,改变 input 的 placeholder 值
const onIconFocus = () => {
if (!props.modelValue) return false;
state.fontIconSearch = '';
state.fontIconPlaceholder = props.modelValue;
};
// 处理 input 失去焦点时,为空将清空 input 值,为点击选中图标时,将取原先值
const onIconBlur = () => {
const list = fontIconTabNameList();
setTimeout(() => {
const icon = list.filter((icon: string) => icon === state.fontIconSearch);
if (icon.length <= 0) state.fontIconSearch = '';
}, 300);
};
// 图标搜索及图标数据显示
const fontIconSheetsFilterList = computed(() => {
const list = fontIconTabNameList();
if (!state.fontIconSearch) return list;
let search = state.fontIconSearch.trim().toLowerCase();
return list.filter((item: string) => {
if (item.toLowerCase().indexOf(search) !== -1) return item;
});
});
// 根据 tab name 类型设置图标
const fontIconTabNameList = () => {
let iconList: any = [];
if (state.fontIconTabActive === 'ali') iconList = state.fontIconList.ali;
else if (state.fontIconTabActive === 'ele') iconList = state.fontIconList.ele;
else if (state.fontIconTabActive === 'awe') iconList = state.fontIconList.awe;
else if (state.fontIconTabActive === 'local') iconList = state.fontIconList.local;
return iconList;
};
// 处理 icon 双向绑定数值回显
const initModeValueEcho = () => {
if (props.modelValue === '') return ((<string | undefined>state.fontIconPlaceholder) = props.placeholder);
(<string | undefined>state.fontIconPlaceholder) = props.modelValue;
(<string | undefined>state.fontIconPrefix) = props.modelValue;
};
// 处理 icon 类型用于回显时tab 高亮与初始化数据
const initFontIconName = () => {
let name = 'ali';
if (!props.modelValue) {
return name;
}
if (props.modelValue!.indexOf('iconfont') > -1) name = 'ali';
else if (props.modelValue!.indexOf('ele-') > -1) name = 'ele';
else if (props.modelValue!.indexOf('fa') > -1) name = 'awe';
else if (props.modelValue!.indexOf('local') > -1) name = 'local';
// 初始化 tab 高亮回显
state.fontIconTabActive = name;
return name;
};
// 初始化数据
const initFontIconData = async (name: string) => {
if (name === 'ali') {
// 阿里字体图标使用 `iconfont xxx`
if (state.fontIconList.ali.length > 0) return;
await initIconfont.ali().then((res: any) => {
state.fontIconList.ali = res.map((i: string) => `iconfont ${i}`);
});
} else if (name === 'ele') {
// element plus 图标
if (state.fontIconList.ele.length > 0) return;
await initIconfont.ele().then((res: any) => {
state.fontIconList.ele = res;
});
} else if (name === 'awe') {
// fontawesome字体图标使用 `fa xxx`
if (state.fontIconList.awe.length > 0) return;
await initIconfont.awe().then((res: any) => {
state.fontIconList.awe = res.map((i: string) => `fa ${i}`);
});
} else if (name === 'local') {
if (state.fontIconList.local.length > 0) return;
await initIconfont.local().then((res: any) => {
state.fontIconList.local = res.map((i: string) => `${i}`);
});
}
// 初始化 input 的 placeholder
// 参考单项数据流https://cn.vuejs.org/v2/guide/components-props.html?#%E5%8D%95%E5%90%91%E6%95%B0%E6%8D%AE%E6%B5%81
state.fontIconPlaceholder = props.placeholder;
// 初始化双向绑定回显
initModeValueEcho();
};
// 图标点击切换
const onIconClick = (pane: TabsPaneContext) => {
initFontIconData(pane.paneName as string);
inputWidthRef.value.focus();
};
// 获取当前点击的 icon 图标
const onColClick = (v: string) => {
state.fontIconPlaceholder = v;
state.fontIconPrefix = v;
emit('get', state.fontIconPrefix);
emit('update:modelValue', state.fontIconPrefix);
inputWidthRef.value.focus();
};
// 清空当前点击的 icon 图标
const onClearFontIcon = () => {
state.fontIconPrefix = '';
emit('clear', state.fontIconPrefix);
emit('update:modelValue', state.fontIconPrefix);
};
// 获取 input 的宽度
const getInputWidth = () => {
nextTick(() => {
state.fontIconWidth = inputWidthRef.value.$el.offsetWidth;
});
};
// 监听页面宽度改变
const initResize = () => {
window.addEventListener('resize', () => {
getInputWidth();
});
};
// 页面加载时
onMounted(() => {
initFontIconData(initFontIconName());
window.addEventListener('resize', getInputWidth);
getInputWidth();
});
onUnmounted(() => {
// 移除监听
window.removeEventListener("resize", getInputWidth);
})
// 监听双向绑定 modelValue 的变化
watch(
() => props.modelValue,
() => {
initModeValueEcho();
initFontIconName();
}
);
</script>

View File

@@ -0,0 +1,84 @@
<template>
<div class="icon-selector-warp-row">
<el-scrollbar ref="selectorScrollbarRef">
<el-row :gutter="10" v-if="props.list.length > 0">
<el-col :xs="6" :sm="4" :md="4" :lg="4" :xl="4" v-for="(v, k) in list" :key="k" @click="onColClick(v)">
<div class="icon-selector-warp-item" :class="{ 'icon-selector-active': prefix === v }">
<SvgIcon :name="v" />
</div>
</el-col>
</el-row>
<el-empty :image-size="100" v-if="list.length <= 0" :description="empty"></el-empty>
</el-scrollbar>
</div>
</template>
<script setup lang="ts" name="iconSelectorList">
// 定义父组件传过来的值
const props = defineProps({
// 图标列表数据
list: {
type: Array,
default: () => [],
},
// 自定义空状态描述文字
empty: {
type: String,
default: () => '无相关图标',
},
// 高亮当前选中图标
prefix: {
type: String,
default: () => '',
},
});
// 定义子组件向父组件传值/事件
const emit = defineEmits(['get-icon']);
// 当前 icon 图标点击时
const onColClick = (v: unknown | string) => {
emit('get-icon', v);
};
</script>
<style scoped lang="scss">
.icon-selector-warp-row {
height: 230px;
overflow: hidden;
.el-row {
padding: 15px;
}
.el-scrollbar__bar.is-horizontal {
display: none;
}
.icon-selector-warp-item {
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--el-border-color);
border-radius: 5px;
margin-bottom: 10px;
height: 30px;
i {
font-size: 20px;
color: var(--el-text-color-regular);
}
&:hover {
cursor: pointer;
background-color: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-5);
i {
color: var(--el-color-primary);
}
}
}
.icon-selector-active {
background-color: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-5);
i {
color: var(--el-color-primary);
}
}
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<div class="custom-link mt-[30px]">
<div class="flex flex-wrap items-center">
自定义链接
<div class="ml-4 flex-1 min-w-[100px]">
<el-input :model-value="modelValue.query?.url" placeholder="请输入链接地址" @input="handleInput" />
</div>
</div>
<div class="form-tips">请填写完整的带有https://”或“http://”的链接地址,链接的域名必须在微信公众平台设置业务域名</div>
</div>
</template>
<script lang="ts" setup>
import type { PropType } from 'vue';
import { LinkTypeEnum, type Link } from '.';
defineProps({
modelValue: {
type: Object as PropType<Link>,
default: () => ({}),
},
});
const emit = defineEmits<{
(event: 'update:modelValue', value: Link): void;
}>();
const handleInput = (value: string) => {
emit('update:modelValue', {
path: '/pages/webview/webview',
query: {
url: value,
},
type: LinkTypeEnum.CUSTOM_LINK,
});
};
</script>

View File

@@ -0,0 +1,11 @@
export enum LinkTypeEnum {
'SHOP_PAGES' = 'shop',
'CUSTOM_LINK' = 'custom',
}
export interface Link {
path: string;
name?: string;
type: string;
query?: Record<string, any>;
}

View File

@@ -0,0 +1,92 @@
<template>
<div class="flex link">
<el-menu :default-active="activeMenu" class="!w-[160px] min-h-[350px] link-menu" @select="handleSelect">
<el-menu-item v-for="(item, index) in menus" :index="item.type" :key="index">
<span>{{ item.name }}</span>
</el-menu-item>
</el-menu>
<div class="flex-1 pl-4">
<shop-pages v-model="activeLink" v-if="LinkTypeEnum.SHOP_PAGES == activeMenu" />
<custom-link v-model="activeLink" v-if="LinkTypeEnum.CUSTOM_LINK == activeMenu" />
</div>
</div>
</template>
<script lang="ts" setup>
import type { PropType } from 'vue';
import { LinkTypeEnum, type Link } from '.';
import ShopPages from './shop-pages.vue';
import CustomLink from './custom-link.vue';
const props = defineProps({
modelValue: {
type: Object as PropType<Link>,
required: true,
},
});
const emit = defineEmits<{
(event: 'update:modelValue', value: any): void;
}>();
const menus = ref([
{
name: '商城页面',
type: LinkTypeEnum.SHOP_PAGES,
link: {},
},
{
name: '自定义链接',
type: LinkTypeEnum.CUSTOM_LINK,
link: {},
},
]);
const activeLink = computed({
get() {
return menus.value.find((item) => item.type == activeMenu.value)?.link as Link;
},
set(value) {
menus.value.forEach((item) => {
if (item.type == activeMenu.value) {
item.link = value;
}
});
},
});
const activeMenu = ref<string>(LinkTypeEnum.SHOP_PAGES);
const handleSelect = (index: string) => {
activeMenu.value = index;
};
watch(activeLink, (value) => {
if (!value?.type) return;
emit('update:modelValue', value);
});
watch(
() => props.modelValue,
(value) => {
activeMenu.value = value.type;
activeLink.value = value;
},
{
immediate: true,
}
);
</script>
<style lang="scss" scoped>
.link-menu {
--el-menu-item-height: 40px;
:deep(.el-menu-item) {
border-color: transparent !important;
&.is-active {
border-right-width: 2px !important;
border-color: var(--el-color-primary) !important;
background-color: var(--el-color-primary-light-9) !important;
}
}
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<div class="flex-1 link-picker" @click="!disabled && popupRef?.open()">
<el-input :model-value="getLink" placeholder="请选择链接" readonly :disabled="disabled"> </el-input>
<popup ref="popupRef" width="700px" title="链接选择" @confirm="handleConfirm">
<link-content v-model="activeLink" />
</popup>
</div>
</template>
<script lang="ts" setup>
import {type Link, LinkTypeEnum} from '.';
import LinkContent from './index.vue';
import Popup from '/@/components/Popup/index.vue';
const props = defineProps({
modelValue: {
type: Object,
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits<{
(event: 'update:modelValue', value: any): void;
}>();
const popupRef = shallowRef<InstanceType<typeof Popup>>();
const activeLink = ref<Link>({ path: '', type: LinkTypeEnum.SHOP_PAGES });
const handleConfirm = () => {
emit('update:modelValue', activeLink.value);
};
const getLink = computed(() => {
switch (props.modelValue?.type) {
case LinkTypeEnum.SHOP_PAGES:
return props.modelValue.name;
case LinkTypeEnum.CUSTOM_LINK:
return props.modelValue.query?.url;
default:
return props.modelValue?.name;
}
});
watch(
() => props.modelValue,
(value) => {
if (value?.type) {
activeLink.value = value as Link;
}
},
{
immediate: true,
}
);
</script>
<style scoped lang="scss">
.link-picker {
:deep(.el-input) {
&.is-disabled {
.el-input__inner {
cursor: not-allowed;
}
.el-input__suffix {
cursor: not-allowed;
}
}
.el-input__inner {
cursor: pointer;
}
.el-input__suffix {
cursor: pointer;
}
}
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="shop-pages">
<div class="flex flex-wrap link-list">
<div
class="link-item border border-br px-5 py-[5px] rounded-[3px] cursor-pointer mr-[10px] mb-[10px]"
v-for="(item, index) in linkList"
:class="{
'border-primary text-primary': modelValue.path == item.path && modelValue.name == item.name,
}"
:key="index"
@click="handleSelect(item)"
>
{{ item.name }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { PropType } from 'vue';
import { LinkTypeEnum, type Link } from '.';
defineProps({
modelValue: {
type: Object as PropType<Link>,
default: () => ({}),
},
});
const emit = defineEmits<{
(event: 'update:modelValue', value: Link): void;
}>();
const linkList = ref([
{
path: '/pages/index/index',
name: '首页',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/news/news',
name: '文章资讯',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/user/user',
name: '个人中心',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/collection/collection',
name: '我的收藏',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/customer_service/customer_service',
name: '联系客服',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/user_set/user_set',
name: '个人设置',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/as_us/as_us',
name: '关于我们',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/user_data/user_data',
name: '个人资料',
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/agreement/agreement',
name: '隐私政策',
query: {
type: 'privacy',
},
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/agreement/agreement',
name: '服务协议',
query: {
type: 'service',
},
type: LinkTypeEnum.SHOP_PAGES,
},
{
path: '/pages/search/search',
name: '搜索',
type: LinkTypeEnum.SHOP_PAGES,
}
]);
const handleSelect = (value: Link) => {
emit('update:modelValue', value);
};
</script>

View File

@@ -0,0 +1,92 @@
<template>
<div>
<div class="relative flex items-center justify-center file-item" :style="{ height: fileSize, width: fileSize }">
<el-image class="image" v-if="type === 'image'" fit="contain" :src="uri"></el-image>
<video class="video" v-else-if="type === 'video'" :src="uri"></video>
<div
v-if="type == 'video'"
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] rounded-full w-5 h-5 flex justify-center items-center bg-[rgba(0,0,0,0.3)]"
>
<el-icon><CaretRight /></el-icon>
</div>
<div v-if="type === 'file'" class="flex items-center justify-center">
<img class="w-16" :src="getFileImage(uri)" />
</div>
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import txt from '/@/assets/txt.png';
import word from '/@/assets/word.png';
import excel from '/@/assets/excel.png';
import pdf from '/@/assets/pdf.png';
import ppt from '/@/assets/ppt.png';
import folder from '/@/assets/icon_folder.png';
export default defineComponent({
props: {
// 图片地址
uri: {
type: String,
},
// 图片尺寸
fileSize: {
type: String,
default: '100px',
},
// 文件类型
type: {
type: String,
default: 'image',
},
},
emits: ['close'],
methods: {
getFileImage(uri?: string) {
if (uri?.includes('txt')) {
return txt;
}
if (uri?.includes('xls')) {
return excel;
}
if (uri?.includes('doc')) {
return word;
}
if (uri?.includes('pdf')) {
return pdf;
}
if (uri?.includes('ppt')) {
return ppt;
}
return folder;
},
},
});
</script>
<style scoped lang="scss">
.file-item {
box-sizing: border-box;
position: relative;
border-radius: 4px;
overflow: hidden;
@apply bg-br-extra-light border border-br-extra-light;
.image,
.video {
display: block;
box-sizing: border-box;
width: 100%;
height: 100%;
}
}
</style>

View File

@@ -0,0 +1,200 @@
import { fileGroupAdd, fileGroupDelete, fileGroupUpdate, fileCateLists, fileDelete, fileList, fileMove, fileRename } from '/@/api/admin/file';
import { usePaging } from './usePaging';
import { ElMessage, ElTree, type CheckboxValueType } from 'element-plus';
import { shallowRef, type Ref } from 'vue';
import { useMessageBox } from '/@/hooks/message';
// 左侧分组的钩子函数
export function useCate(type: number) {
const treeRef = shallowRef<InstanceType<typeof ElTree>>();
// 分组列表
const cateLists = ref<any[]>([]);
// 选中的分组id
const cateId = ref<number | string>('');
// 获取分组列表
const getCateLists = async () => {
const { data } = await fileCateLists({
type,
});
const item: any[] = [
{
name: '全部',
id: '',
},
{
name: '未分组',
id: -1,
},
];
cateLists.value = data;
cateLists.value?.unshift(...item);
setTimeout(() => {
treeRef.value?.setCurrentKey(cateId.value);
}, 0);
};
// 添加分组
const handleAddCate = async (value: string) => {
await fileGroupAdd({
type,
name: value,
pid: -1,
});
getCateLists();
};
// 编辑分组
const handleEditCate = async (value: string, id: number) => {
await fileGroupUpdate({
id,
name: value,
});
getCateLists();
};
// 删除分组
const handleDeleteCate = async (id: number) => {
try {
await useMessageBox().confirm('确定要删除?');
} catch (error) {
return;
}
await fileGroupDelete({ id });
cateId.value = '';
getCateLists();
};
//选中分类
const handleCatSelect = (item: any) => {
cateId.value = item.id;
};
return {
treeRef,
cateId,
cateLists,
handleAddCate,
handleEditCate,
handleDeleteCate,
getCateLists,
handleCatSelect,
};
}
// 处理文件的钩子函数
export function useFile(cateId: Ref<string | number>, type: Ref<number>, limit: Ref<number>, size: number) {
const tableRef = shallowRef();
const listShowType = ref('normal');
const moveId = ref(-1);
const select = ref<any[]>([]);
const isCheckAll = ref(false);
const isIndeterminate = ref(false);
const fileParams = reactive({
original: '',
type: type,
groupId: cateId,
});
const { pager, getLists, resetPage } = usePaging({
fetchFun: fileList,
params: fileParams,
firstLoading: true,
size,
});
const getFileList = () => {
getLists();
};
const refresh = () => {
resetPage();
};
const isSelect = (id: number) => {
return !!select.value.find((item: any) => item.id == id);
};
const batchFileDelete = async (id?: number[]) => {
try {
await useMessageBox().confirm('确认删除后本地将同步删除,如文件已被使用,请谨慎操作!');
} catch {
return;
}
const ids = id ? id : select.value.map((item: any) => item.id);
await fileDelete({ ids });
getFileList();
clearSelect();
};
const batchFileMove = async () => {
const ids = select.value.map((item: any) => item.id);
await fileMove({ ids, groupId: moveId.value });
moveId.value = -1;
getFileList();
clearSelect();
};
const selectFile = (item: any) => {
const index = select.value.findIndex((items: any) => items.id == item.id);
if (index != -1) {
select.value.splice(index, 1);
return;
}
if (select.value.length == limit.value) {
if (limit.value == 1) {
select.value = [];
select.value.push(item);
return;
}
ElMessage.warning('已达到选择上限');
return;
}
select.value.push(item);
};
const clearSelect = () => {
select.value = [];
};
const cancelSelete = (id: number) => {
select.value = select.value.filter((item: any) => item.id != id);
};
const selectAll = (value: CheckboxValueType) => {
isIndeterminate.value = false;
tableRef.value?.toggleAllSelection();
if (value) {
select.value = [...pager.lists];
return;
}
clearSelect();
};
const handleFileRename = async (value: string, id: number) => {
await fileRename({
id,
original: value,
});
getFileList();
};
return {
listShowType,
tableRef,
moveId,
pager,
fileParams,
select,
isCheckAll,
isIndeterminate,
getFileList,
refresh,
batchFileDelete,
batchFileMove,
selectFile,
isSelect,
clearSelect,
cancelSelete,
selectAll,
handleFileRename,
};
}

View File

@@ -0,0 +1,18 @@
export default {
material: {
uploadFileTip: 'upload',
addGroup: 'add group',
editGroup: 'edit group',
delGroup: 'del group',
moveBtn: 'move',
preview: 'preview',
edit: 'edit',
view: 'view',
add: 'add',
allCheck: 'all check',
rename: 'rename',
download: 'download',
list: 'list',
grid: 'grid',
},
};

View File

@@ -0,0 +1,18 @@
export default {
material: {
uploadFileTip: '上传',
addGroup: '新增分组',
editGroup: '修改分组',
delGroup: '删除分组',
moveBtn: '移动',
preview: '预览',
edit: '修改',
view: '查看',
add: '添加',
allCheck: '全选',
rename: '重命名',
download: '下载',
list: '列表',
grid: '平铺',
},
};

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

View File

@@ -0,0 +1,283 @@
<template>
<div class="material-select">
<popup ref="popupRef" width="830px" custom-class="body-padding" :title="`选择${tipsText}`" @confirm="handleConfirm" @close="handleClose">
<template v-if="!hiddenUpload" #trigger>
<div class="clearfix material-select__trigger" @click.stop>
<draggable class="draggable" v-model="fileList" animation="300" item-key="id">
<template v-slot:item="{ element, index }">
<div
class="material-preview"
:class="{
'is-disabled': disabled,
'is-one': limit == 1,
}"
@click="showPopup(index)"
>
<del-wrap @close="deleteImg(index)">
<file-item :uri="element" :file-size="size" :type="type"></file-item>
</del-wrap>
<div class="text-xs text-center operation-btns">
<span>{{ $t('material.edit') }}</span>
|
<span @click.stop="handlePreview(element)">{{ $t('material.view') }}</span>
</div>
</div>
</template>
</draggable>
<div
class="material-upload"
@click="showPopup(-1)"
v-show="showUpload"
:class="{
'is-disabled': disabled,
'is-one': limit == 1,
[uploadClass]: true,
}"
>
<slot name="upload">
<div
class="upload-btn"
:style="{
width: size,
height: size,
}"
>
<el-icon><Plus /></el-icon>
<span>{{ $t('material.add') }}</span>
</div>
</slot>
</div>
</div>
</template>
<el-scrollbar>
<div class="material-wrap">
<material ref="materialRef" :type="type" :file-size="fileSize" :limit="meterialLimit" @change="selectChange" />
</div>
</el-scrollbar>
</popup>
<preview v-model="showPreview" :url="previewUrl" :type="type" />
</div>
</template>
<script lang="ts">
import Draggable from 'vuedraggable';
import Popup from '/@/components/popup/index.vue';
import FileItem from './file.vue';
import Material from './index.vue';
import Preview from './preview.vue';
import { useThrottleFn } from '@vueuse/shared';
export default defineComponent({
components: {
Popup,
Draggable,
FileItem,
Material,
Preview,
},
props: {
modelValue: {
type: [String, Array],
default: () => [],
},
// 文件类型
type: {
type: String,
default: 'image',
},
// 选择器尺寸
size: {
type: String,
default: '100px',
},
// 文件尺寸
fileSize: {
type: String,
default: '100px',
},
// 选择数量限制
limit: {
type: Number,
default: 1,
},
// 禁用选择
disabled: {
type: Boolean,
default: false,
},
// 隐藏上传框*(目前在富文本中使用到)
hiddenUpload: {
type: Boolean,
default: false,
},
uploadClass: {
type: String,
default: '',
},
//选择的url排出域名
excludeDomain: {
type: Boolean,
default: false,
},
},
emits: ['change', 'update:modelValue'],
setup(props, { emit }) {
const popupRef = ref<InstanceType<typeof Popup>>();
const materialRef = ref<InstanceType<typeof Material>>();
const previewUrl = ref('');
const showPreview = ref(false);
const fileList = ref<any[]>([]);
const select = ref<any[]>([]);
const isAdd = ref(true);
const currentIndex = ref(-1);
const { disabled, limit, modelValue } = toRefs(props);
const tipsText = computed(() => {
switch (props.type) {
case 'image':
return '图片';
case 'video':
return '视频';
case 'file':
return '文件';
default:
return '';
}
});
const showUpload = computed(() => {
return props.limit - fileList.value.length > 0;
});
const meterialLimit: any = computed(() => {
if (!isAdd.value) {
return 1;
}
if (limit.value == -1) return null;
return limit.value - fileList.value.length;
});
const handleConfirm = useThrottleFn(
() => {
const selectUri = select.value.map((item) => (props.excludeDomain ? item.path : item.uri));
if (!isAdd.value) {
fileList.value.splice(currentIndex.value, 1, selectUri.shift());
} else {
fileList.value = [...fileList.value, ...selectUri];
}
handleChange();
},
1000,
false
);
const showPopup = (index: number) => {
if (disabled.value) return;
if (index >= 0) {
isAdd.value = false;
currentIndex.value = index;
} else {
isAdd.value = true;
}
popupRef.value?.open();
};
const selectChange = (val: any[]) => {
select.value = val;
};
const handleChange = () => {
const valueImg = limit.value != 1 ? fileList.value : fileList.value[0] || '';
emit('update:modelValue', valueImg);
emit('change', valueImg);
handleClose();
};
const deleteImg = (index: number) => {
fileList.value.splice(index, 1);
handleChange();
};
const handlePreview = (url: string) => {
previewUrl.value = url;
showPreview.value = true;
};
const handleClose = () => {
nextTick(() => {
if (props.hiddenUpload) fileList.value = [];
materialRef.value?.clearSelect();
});
};
watch(
modelValue,
(val: any[] | string) => {
fileList.value = Array.isArray(val) ? val : val == '' ? [] : [val];
},
{
immediate: true,
}
);
provide('limit', props.limit);
provide('hiddenUpload', props.hiddenUpload);
return {
popupRef,
materialRef,
fileList,
tipsText,
handleConfirm,
meterialLimit,
showUpload,
showPopup,
selectChange,
deleteImg,
previewUrl,
showPreview,
handlePreview,
handleClose,
};
},
});
</script>
<style scoped lang="scss">
.material-select {
.material-upload,
.material-preview {
position: relative;
border-radius: 4px;
cursor: pointer;
margin-right: 8px;
margin-bottom: 8px;
box-sizing: border-box;
float: left;
&.is-disabled {
cursor: not-allowed;
}
&.is-one {
margin-bottom: 0;
}
&:hover {
.operation-btns {
display: block;
}
}
.operation-btns {
display: none;
position: absolute;
bottom: 0;
border-radius: 4px;
width: 100%;
line-height: 2;
color: #fff;
background-color: rgba(0, 0, 0, 0.3);
}
}
.material-upload {
:deep(.upload-btn) {
@apply text-tx-secondary box-border rounded border-br border-dashed border flex flex-col justify-center items-center;
}
}
}
.material-wrap {
min-width: 720px;
height: 430px;
@apply border-t border-b border-br;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<div v-show="modelValue">
<div v-if="type == 'image'">
<el-image-viewer v-if="previewLists.length" :url-list="previewLists" hide-on-click-modal @close="handleClose"/>
</div>
<div v-if="type == 'video'">
<el-dialog v-model="visible" width="740px" :title="$t('material.preview')" :before-close="handleClose">
<video-player ref="playerRef" :src="url" width="100%" height="450px"/>
</el-dialog>
</div>
<div v-if="type == 'file'">
<el-drawer v-model="visible" size="100%">
<iframe
:src="src"
width="100%" height="100%" frameborder="0" class="h-screen" v-if="src"></iframe>
<span v-else>未配置预览服务器请参考文档配置</span>
</el-drawer>
</div>
</div>
</template>
<script lang="ts" setup>
import {Base64} from 'js-base64';
import {validateNull} from "/@/utils/validate";
const VideoPlayer = defineAsyncComponent(() => import('/@/components/VideoPlayer/index.vue'));
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
url: {
type: String,
default: '',
},
fileName: {
type: String,
default: '',
},
type: {
type: String,
default: 'image',
},
});
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void;
}>();
const playerRef = shallowRef();
const visible = computed({
get() {
return props.modelValue;
},
set(value) {
emit('update:modelValue', value);
},
});
const handleClose = () => {
emit('update:modelValue', false);
};
const previewLists = ref<any[]>([]);
watch(
() => props.modelValue,
(value) => {
if (value) {
nextTick(() => {
previewLists.value = [props.url];
playerRef.value?.play();
});
} else {
nextTick(() => {
previewLists.value = [];
playerRef.value?.pause();
});
}
}
);
const kkServerURL = import.meta.env.VITE_KK_SERVER_URL
const localURL = import.meta.env.VITE_KK_LOCAL_URL
const src = computed(() => {
if (validateNull(kkServerURL)) {
return undefined;
}
return `${kkServerURL}?url=` + encodeURIComponent(Base64.encode(`${localURL}${props.url}&fullfilename=${props.fileName}`));
});
</script>

View File

@@ -0,0 +1,76 @@
import { isFunction } from 'lodash';
import { reactive, toRaw } from 'vue';
// 分页钩子函数
interface Options {
page?: number;
size?: number;
fetchFun: (_arg: any) => Promise<any>;
params?: Record<any, any>;
firstLoading?: boolean;
beforeRequest?(params: Record<any, any>): Record<any, any>;
afterRequest?(res: Record<any, any>): void;
}
export function usePaging(options: Options) {
const { page = 1, size = 15, fetchFun, params = {}, firstLoading = false, beforeRequest, afterRequest } = options;
// 记录分页初始参数
const paramsInit: Record<any, any> = Object.assign({}, toRaw(params));
// 分页数据
const pager = reactive({
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,
};
}

View File

@@ -0,0 +1,129 @@
$d-type: (
flex: flex,
block: block,
none: none,
);
$flex-jc: (
start: flex-start,
end: flex-end,
center: center,
between: space-between,
around: space-around,
);
$flex-ai: (
start: flex-start,
end: flex-end,
center: center,
stretch: stretch,
);
//spacing
$spacing-types: (
m: margin,
p: padding,
);
$spacing-directions: (
t: top,
r: right,
b: bottom,
l: left,
);
$spacing-base-size: 5px;
$spacing-sizes: (
0: 0,
1: 1,
2: 2,
3: 3,
4: 4,
5: 5,
);
@each $key, $value in $d-type {
.d-#{$key} {
display: $value;
}
}
.flex-column {
flex-direction: column;
}
.text-ellipsis {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.w-100 {
width: 100%;
}
.h-100 {
height: 100%;
}
.flex-wrap {
flex-wrap: wrap;
}
.flex-grow-1 {
flex: 1;
}
@each $dir in(top, bottom, right, left) {
.border-#{$dir} {
border-#{$dir}: 1px solid;
}
}
@each $key, $value in $flex-jc {
.jc-#{$key} {
justify-content: $value;
}
}
@each $key, $value in $flex-ai {
.ai-#{$key} {
align-items: $value;
}
}
//text
@each $var in (left, center, right) {
.text-#{$var} {
text-align: $var !important;
}
}
@each $typeKey, $type in $spacing-types {
@each $sizeKey, $size in $spacing-sizes {
.#{$typeKey}-#{$sizeKey} {
#{$type}: $size * $spacing-base-size;
}
}
@each $sizeKey, $size in $spacing-sizes {
.#{$typeKey}x-#{$sizeKey} {
#{$type}-left: $size * $spacing-base-size;
#{$type}-right: $size * $spacing-base-size;
}
.#{$typeKey}y-#{$sizeKey} {
#{$type}-top: $size * $spacing-base-size;
#{$type}-bottom: $size * $spacing-base-size;
}
}
@each $directionKey, $direction in $spacing-directions {
@each $sizeKey, $size in $spacing-sizes {
.#{$typeKey}#{$directionKey}-#{$sizeKey} {
#{$type}-#{$direction}: $size * $spacing-base-size;
}
}
}
}

View File

@@ -0,0 +1,105 @@
<template>
<div :class="mode == 'square' ? 'chatface' : 'brround avatar cover-image'" :style="transform" style="overflow: hidden; width: 40px; height: 40px">
<img v-if="faceUrl && !num" :src="faceUrl" class="w-100 h-100" />
<div v-else :style="styles" class="w-100 h-100 d-flex ai-center jc-center">{{ text }}</div>
</div>
</template>
<script>
export default {
name: 'nameAvatar',
props: {
scale: {
type: String,
default: '1',
},
num: [Number, String],
name: String,
mode: {
type: String,
default: '',
},
fontColor: {
type: String,
default: '#fff',
},
backgroundColor: {
type: String,
default: '#3696F2',
},
faceUrl: {
type: String,
default: '',
},
},
data() {
return {};
},
watch: {},
computed: {
// eslint-disable-next-line vue/return-in-computed-property
text() {
if (this.num !== undefined) {
return `+${this.num}`;
} else {
if (this.name) {
return this.name.slice(-2);
}
}
},
transform() {
let style = {};
if (this.scale) {
style['transform'] = `scale(${this.scale}, ${this.scale})`;
}
return style;
},
styles() {
let style = {};
if (this.size) {
style['font-size'] = '12px';
}
if (this.fontColor) {
style.color = this.fontColor;
}
if (this.backgroundColor) {
style['background'] = this.backgroundColor;
}
return style;
},
},
methods: {},
};
</script>
<style lang="scss" scoped>
@import './base.scss';
.avatar {
display: inline-block;
position: relative;
text-align: center;
vertical-align: bottom;
font-size: 8px;
user-select: none;
z-index: 10;
&:hover {
z-index: 100;
}
}
.brround {
border-radius: 50%;
}
.chatface {
display: block;
border-radius: 8px;
overflow: hidden;
img {
width: 100%;
height: 100%;
}
}
</style>

View File

@@ -0,0 +1,191 @@
<template>
<div class="notice-bar" :style="{ background, height: `${height}px` }" v-show="!state.isMode">
<div class="notice-bar-warp" :style="{ color, fontSize: `${size}px` }">
<i v-if="leftIcon" class="notice-bar-warp-left-icon" :class="leftIcon"></i>
<div class="notice-bar-warp-text-box" ref="noticeBarWarpRef">
<div class="notice-bar-warp-text" ref="noticeBarTextRef" v-if="!scrollable">{{ text }}</div>
<div class="notice-bar-warp-slot" v-else><slot /></div>
</div>
<SvgIcon :name="rightIcon" v-if="rightIcon" class="notice-bar-warp-right-icon" @click="onRightIconClick" />
</div>
</div>
</template>
<script setup lang="ts" name="noticeBar">
import { reactive, ref, onMounted, nextTick } from 'vue';
// 定义父组件传过来的值
const props = defineProps({
// 通知栏模式,可选值为 closeable link
mode: {
type: String,
default: () => '',
},
// 通知文本内容
text: {
type: String,
default: () => '',
},
// 通知文本颜色
color: {
type: String,
default: () => 'var(--el-color-warning)',
},
// 通知背景色
background: {
type: String,
default: () => 'var(--el-color-warning-light-9)',
},
// 字体大小单位px
size: {
type: [Number, String],
default: () => 14,
},
// 通知栏高度单位px
height: {
type: Number,
default: () => 40,
},
// 动画延迟时间 (s)
delay: {
type: Number,
default: () => 1,
},
// 滚动速率 (px/s)
speed: {
type: Number,
default: () => 100,
},
// 是否开启垂直滚动
scrollable: {
type: Boolean,
default: () => false,
},
// 自定义左侧图标
leftIcon: {
type: String,
default: () => '',
},
// 自定义右侧图标
rightIcon: {
type: String,
default: () => '',
},
});
// 定义子组件向父组件传值/事件
const emit = defineEmits(['close', 'link']);
// 定义变量内容
const noticeBarWarpRef = ref();
const noticeBarTextRef = ref();
const state = reactive({
order: 1,
oneTime: 0,
twoTime: 0,
warpOWidth: 0,
textOWidth: 0,
isMode: false,
});
// 初始化 animation 各项参数
const initAnimation = () => {
nextTick(() => {
state.warpOWidth = noticeBarWarpRef.value.offsetWidth;
state.textOWidth = noticeBarTextRef.value.offsetWidth;
document.styleSheets[0].insertRule(`@keyframes oneAnimation {0% {left: 0px;} 100% {left: -${state.textOWidth}px;}}`);
document.styleSheets[0].insertRule(`@keyframes twoAnimation {0% {left: ${state.warpOWidth}px;} 100% {left: -${state.textOWidth}px;}}`);
computeAnimationTime();
setTimeout(() => {
changeAnimation();
}, props.delay * 1000);
});
};
// 计算 animation 滚动时长
const computeAnimationTime = () => {
state.oneTime = state.textOWidth / props.speed;
state.twoTime = (state.textOWidth + state.warpOWidth) / props.speed;
};
// 改变 animation 动画调用
const changeAnimation = () => {
if (state.order === 1) {
noticeBarTextRef.value.style.cssText = `animation: oneAnimation ${state.oneTime}s linear; opactity: 1;}`;
state.order = 2;
} else {
noticeBarTextRef.value.style.cssText = `animation: twoAnimation ${state.twoTime}s linear infinite; opacity: 1;`;
}
};
// 监听 animation 动画的结束
const listenerAnimationend = () => {
noticeBarTextRef.value.addEventListener(
'animationend',
() => {
changeAnimation();
},
false
);
};
// 右侧 icon 图标点击
const onRightIconClick = () => {
if (!props.mode) return false;
if (props.mode === 'closeable') {
state.isMode = true;
emit('close');
} else if (props.mode === 'link') {
emit('link');
}
};
// 页面加载时
onMounted(() => {
if (props.scrollable) return false;
initAnimation();
listenerAnimationend();
});
</script>
<style scoped lang="scss">
.notice-bar {
padding: 0 15px;
width: 100%;
border-radius: 4px;
.notice-bar-warp {
display: flex;
align-items: center;
width: 100%;
height: inherit;
.notice-bar-warp-text-box {
flex: 1;
height: inherit;
display: flex;
align-items: center;
overflow: hidden;
position: relative;
.notice-bar-warp-text {
white-space: nowrap;
position: absolute;
left: 0;
}
.notice-bar-warp-slot {
width: 100%;
white-space: nowrap;
:deep(.el-carousel__item) {
display: flex;
align-items: center;
}
}
}
.notice-bar-warp-left-icon {
width: 24px;
font-size: inherit !important;
}
.notice-bar-warp-right-icon {
width: 24px;
text-align: right;
font-size: inherit !important;
&:hover {
cursor: pointer;
}
}
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,42 @@
/*
* @Date: 2022-08-29 14:00:42
* @LastEditors: StavinLi 495727881@qq.com
* @LastEditTime: 2023-03-29 15:53:05
* @FilePath: /Workflow-Vue3/src/components/dialog/common.js
*/
import {deptRoleList} from '/@/api/admin/role';
import {orgTree, orgTreeSearcheUser} from '/@/api/admin/dept';
export const searchVal = ref('');
export const departments = ref({
titleDepartments: [], childDepartments: [], roleList: [], employees: [],
});
export const roles = ref({});
export const getRoleList = async () => {
let {
data: {list},
} = await deptRoleList();
roles.value = list;
};
export const getDepartmentList = async (parentId = 0, type = 'org') => {
// let { data } = await getDepartments({ parentId })
let {data} = await orgTree(type, parentId);
departments.value = data;
};
export const getDebounceData = async (event: any, type = 1) => {
if (event) {
let data = {
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();
}
};

View File

@@ -0,0 +1,30 @@
.person_body {
border: 1px solid #f5f5f5;
height: 500px;
display: flex;
}
.tree_nav span {
display: inline-block;
padding-right: 10px;
margin-right: 5px;
max-width: 6em;
color: #38adff;
font-size: 12px;
cursor: pointer;
background: url(./assets/jiaojiao.png) no-repeat right center;
}
.tree_nav span:last-of-type {
background: none;
}
.person_tree {
padding: 10px 12px 0 8px;
width: 400px;
height: 100%;
border-right: 1px solid #f5f5f5;
}
.l {
float: left;
}

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

View File

@@ -0,0 +1,10 @@
export default {
orgSelecotr: {
org: 'org',
user: 'user',
dept: 'dept',
role: 'role',
select: 'select',
search: 'search'
},
};

View File

@@ -0,0 +1,10 @@
export default {
orgSelecotr: {
org: '组织',
user: '用户',
dept: '部门',
role: '角色',
select: '选择',
search: '搜索'
},
};

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

View File

@@ -0,0 +1,37 @@
<template>
<div>
<el-tag
v-for="(item, index) in data"
style="margin-right: 5px; margin-top: 5px"
:key="item.id"
:closable="!disabled"
@close="removeItem(index, item.id, item.type)"
:type="item.type === 'dept' ? 'primary' : item.type === 'user' ? 'warning' : 'success'"
size="large"
>
{{ item.name }}
</el-tag>
</div>
</template>
<script setup>
let emits = defineEmits(['update:data']);
let props = defineProps({
data: {
type: Array,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
});
const removeItem = (index, id, type) => {
emits(
'update:data',
props.data.filter((res) => !(res.id === id && res.type === type))
);
};
</script>

View File

@@ -0,0 +1,120 @@
<!--
* @Date: 2022-08-25 14:05:59
* * @LastEditors: StavinLi 495727881@qq.com
* @LastEditTime: 2023-03-15 14:59:19
* @FilePath: /Workflow-Vue3/src/components/dialog/roleDialog.vue
-->
<template>
<el-dialog title="选择角色" v-model="visibleDialog" :width="600" append-to-body class="promoter_person">
<div class="person_body clear">
<div class="person_tree l">
<input type="text" placeholder="搜索角色" v-model="searchVal" @input="getDebounceData($event, 2)"/>
<selectBox :list="list"/>
</div>
<selectResult :total="total" @del="delList" :list="resList"/>
</div>
<template #footer>
<el-button @click="closeDialog"> </el-button>
<el-button type="primary" @click="saveDialog"> </el-button>
</template>
</el-dialog>
</template>
<script setup>
import selectBox from './selectBox.vue';
import selectResult from './selectResult.vue';
import {computed, watch, ref} from 'vue';
import {roles, getDebounceData, getRoleList, searchVal} from './common';
let props = defineProps({
visible: {
type: Boolean,
default: false,
},
data: {
type: Array,
default: () => [],
},
});
let checkedRoleList = ref([]);
let emits = defineEmits(['update:visible', 'change']);
let list = computed(() => {
return [
{
type: 'role',
not: true,
data: roles.value,
isActive: (item) => toggleClass(checkedRoleList.value, item, 'roleId'),
change: (item) => {
checkedRoleList.value = [item];
},
},
];
});
let resList = computed(() => {
return [
{
type: 'role',
data: checkedRoleList.value,
cancel: (item) => removeEle(checkedRoleList.value, item, 'roleId'),
},
];
});
let visibleDialog = computed({
get() {
return props.visible;
},
set(val) {
closeDialog();
},
});
watch(
() => props.visible,
(val) => {
if (val) {
getRoleList();
searchVal.value = '';
checkedRoleList.value = props.data.map(({name, targetId}) => ({
roleName: name,
roleId: targetId,
}));
}
}
);
let total = computed(() => checkedRoleList.value.length);
const saveDialog = () => {
let checkedList = checkedRoleList.value.map((item) => ({
type: 2,
targetId: item.roleId,
name: item.roleName,
}));
emits('change', checkedList);
};
const delList = () => {
checkedRoleList.value = [];
};
const closeDialog = () => {
emits('update:visible', false);
};
const toggleClass = (arr, elem, key = 'id') => {
return arr.some((item) => {
return item[key] === elem[key];
});
}
const removeEle = (arr, elem, key = 'id') => {
let includesIndex;
arr.map((item, index) => {
if (item[key] === elem[key]) {
includesIndex = index;
}
});
arr.splice(includesIndex, 1);
}
</script>
<style>
@import './dialog.css';
</style>

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

View File

@@ -0,0 +1,136 @@
<!--
* @Date: 2022-08-26 16:29:24
* @LastEditors: StavinLi 495727881@qq.com
* @LastEditTime: 2022-09-21 14:36:30
* @FilePath: /Workflow-Vue3/src/components/selectResult.vue
-->
<template>
<div class="select-result l">
<p class="clear">
已选{{ total }}
<a @click="emits('del')">清空</a>
</p>
<ul>
<template v-for="{ type, data, cancel } in list" :key="type">
<template v-if="type === 'role'">
<li v-for="item in data" :key="item.id">
<div style="display: flex; flex-direction: row">
<div class="f11">
<el-icon style="font-size: 20px">
<Share />
</el-icon>
</div>
<div class="f12">{{ item.name }}</div>
<div class="f13">
<el-button size="small" text @click="cancel(item)" :icon="CircleClose"></el-button>
</div>
</div>
</li>
</template>
<template v-if="type === 'dept'">
<li v-for="item in data" :key="item.id">
<div style="display: flex; flex-direction: row">
<div class="f11">
<el-icon style="font-size: 20px">
<Grid />
</el-icon>
</div>
<div class="f12">{{ item.name }}</div>
<div class="f13">
<el-button size="small" text @click="cancel(item)" :icon="CircleClose"></el-button>
</div>
</div>
</li>
</template>
<template v-if="type === 'user'">
<li v-for="item in data" :key="item.id">
<div style="display: flex; flex-direction: row">
<div class="f11">
<upload-img v-model:image-url="item.avatar" disabled width="20px" height="20px" />
</div>
<div class="f12">{{ item.name }}</div>
<div class="f13">
<el-button size="small" text @click="cancel(item)" :icon="CircleClose"></el-button>
</div>
</div>
</li>
</template>
</template>
</ul>
</div>
</template>
<script setup>
import { CircleClose, Grid, Share } from '@element-plus/icons-vue';
import UploadImg from '/@/components/Upload/Image.vue';
defineProps({
total: {
type: Number,
default: 0,
},
list: {
type: Array,
default: () => [{ type: 'role', data, cancel }],
},
});
let emits = defineEmits(['del']);
</script>
<style scoped lang="scss">
.select-result {
width: 276px;
height: 100%;
font-size: 12px;
ul {
height: 460px;
overflow-y: auto;
li {
margin: 11px 26px 13px 19px;
line-height: 17px;
span {
vertical-align: middle;
}
img:first-of-type {
width: 14px;
vertical-align: middle;
margin-right: 5px;
}
img:last-of-type {
float: right;
margin-top: 2px;
width: 14px;
}
}
}
p {
padding-left: 19px;
padding-right: 20px;
line-height: 37px;
border-bottom: 1px solid #f2f2f2;
a {
float: right;
}
}
}
.f11 {
width: 30px;
}
.f12 {
width: calc(100% - 60px);
height: 25px;
line-height: 25px;
}
.f13 {
width: 30px;
}
</style>

View File

@@ -0,0 +1,5 @@
export interface OrgItem {
id: string | number;
name: string;
[key: string]: any;
}

View File

@@ -0,0 +1,52 @@
<template>
<el-pagination
@size-change="sizeChangeHandle"
@current-change="currentChangeHandle"
class="mt15"
:pager-count="5"
:page-sizes="props.pageSizes"
:current-page="props.current"
background
:page-size="props.size"
:layout="props.layout"
:total="props.total"
>
</el-pagination>
</template>
<script setup lang="ts" name="pagination">
const emit = defineEmits(['sizeChange', 'currentChange']);
const props = defineProps({
current: {
type: Number,
default: 1,
},
size: {
type: Number,
default: 10,
},
total: {
type: Number,
default: 0,
},
pageSizes: {
type: Array as () => number[],
default: () => {
return [1, 10, 20, 50, 100, 200];
},
},
layout: {
type: String,
default: 'total, sizes, prev, pager, next, jumper',
},
});
// 分页改变
const sizeChangeHandle = (val: number) => {
emit('sizeChange', val);
};
// 分页改变
const currentChangeHandle = (val: number) => {
emit('currentChange', val);
};
</script>

View File

@@ -0,0 +1,128 @@
<template>
<div @mouseenter="inPopover = true" @mouseleave="inPopover = false">
<el-popover
placement="top"
v-model:visible="visible"
:width="width"
trigger="contextmenu"
class="popover-input"
:teleported="teleported"
:persistent="false"
popper-class="!p-0"
>
<div class="flex p-3" @click.stop="">
<div class="popover-input__input mr-[10px] flex-1">
<el-select class="flex-1" :size="size" v-if="type == 'select'" v-model="inputValue" :teleported="teleported">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value"></el-option>
</el-select>
<el-input
v-else
v-model.trim="inputValue"
:maxlength="maxlength"
:show-word-limit="showLimit"
:type="type"
:size="size"
clearable
:placeholder="placeholder"
/>
</div>
<div class="flex-none popover-input__btns">
<el-button link @click="close">{{ $t('common.cancelButtonText') }}</el-button>
<el-button type="primary" :size="size" @click="handleConfirm">{{ $t('common.confirmButtonText') }}</el-button>
</div>
</div>
<template #reference>
<div class="inline" @click.stop="handleOpen">
<slot></slot>
</div>
</template>
</el-popover>
</div>
</template>
<script lang="ts" setup>
import { useEventListener } from '@vueuse/core';
import type { PropType } from 'vue';
const props = defineProps({
modelValue: {
type: String,
},
type: {
type: String,
default: 'text',
},
width: {
type: [Number, String],
default: '300px',
},
placeholder: String,
disabled: {
type: Boolean,
default: false,
},
options: {
type: Array as PropType<any[]>,
default: () => [],
},
size: {
type: String as PropType<'default' | 'small' | 'large'>,
default: 'default',
},
limit: {
type: Number,
default: 200,
},
maxlength: {
type: Number,
default: 20,
},
showLimit: {
type: Boolean,
default: false,
},
teleported: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['confirm', 'update:modelValue']);
const visible = ref(false);
const inPopover = ref(false);
const inputValue = ref();
const handleConfirm = () => {
close();
emit('confirm', inputValue.value);
emit('update:modelValue', inputValue.value);
};
const handleOpen = () => {
if (props.disabled) {
return;
}
visible.value = true;
inputValue.value = '';
};
const close = () => {
visible.value = false;
};
watch(
() => inputValue.value,
(value) => {
emit('update:modelValue', value);
},
{
immediate: true,
}
);
useEventListener(document.documentElement, 'click', () => {
if (inPopover.value) return;
close();
});
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,118 @@
<template>
<div class="dialog">
<div class="dialog__trigger" @click="open">
<!-- 触发弹窗 -->
<slot name="trigger"></slot>
</div>
<el-dialog
v-model="visible"
:custom-class="customClass"
:center="center"
:append-to-body="true"
:width="width"
:close-on-click-modal="clickModalClose"
@closed="close"
>
<!-- 弹窗内容 -->
<template v-if="title" #header>{{ title }}</template>
<!-- 自定义内容 -->
<slot>{{ content }}</slot>
<!-- 底部弹窗页脚 -->
<template #footer>
<div class="dialog-footer">
<el-button @click="handleEvent('cancel')">
{{ $t('common.cancelButtonText') }}
</el-button>
<el-button type="primary" @click="handleEvent('confirm')">
{{ $t('common.confirmButtonText') }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default defineComponent({
props: {
title: {
// 弹窗标题
type: String,
default: '',
},
content: {
// 弹窗内容
type: String,
default: '',
},
width: {
// 弹窗的宽度
type: String,
default: '400px',
},
disabled: {
// 是否禁用
type: Boolean,
default: false,
},
async: {
// 是否开启异步关闭
type: Boolean,
default: false,
},
clickModalClose: {
// 点击遮罩层关闭对话窗口
type: Boolean,
default: false,
},
center: {
// 是否居中布局
type: Boolean,
default: false,
},
customClass: {
type: String,
default: '',
},
},
emits: ['confirm', 'cancel', 'close', 'open'],
setup(props, { emit }) {
const visible = ref(false);
const handleEvent = (type: 'confirm' | 'cancel') => {
emit(type);
if (!props.async || type === 'cancel') {
close();
}
};
const close = () => {
visible.value = false;
nextTick(() => {
emit('close');
});
};
const open = () => {
if (props.disabled) {
return;
}
emit('open');
visible.value = true;
};
provide('visible', visible);
return {
visible,
handleEvent,
close,
open,
};
},
});
</script>
<style scoped lang="scss">
.dialog-body {
white-space: pre-line;
}
</style>

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

View File

@@ -0,0 +1,9 @@
export default {
queryTree: {
hideSearch: 'hideSearch',
displayTheSearch: 'displayTheSearch',
refresh: 'refresh',
print: 'print',
view: 'view'
},
};

View File

@@ -0,0 +1,9 @@
export default {
queryTree: {
hideSearch: '隐藏搜索',
displayTheSearch: '显示搜索',
refresh: '刷新',
print: '打印',
view: '视图'
},
};

View File

@@ -0,0 +1,188 @@
<template>
<div class="head-container">
<div class="head-container-header">
<div class="head-container-header-input">
<el-input v-model="searchName" suffix-icon="search" :placeholder="placeholder" clearable @change="getdeptTree" />
</div>
<div class="head-container-header-dropdown" v-if="showExpand">
<el-dropdown :hide-on-click="false">
<el-icon style="transform: rotate(90deg)">
<MoreFilled />
</el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>
<el-button
:class="buttonClass"
link
type="primary"
:icon="isExpand ? 'expand' : 'fold'"
@click="toggleRowExpansionAll(isExpand ? false : true)"
>
{{ isExpand ? '折叠' : '展开' }}
</el-button>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<el-tree
class="mt20"
:data="state.List"
:props="props.props"
:expand-on-click-node="false"
ref="deptTreeRef"
:loading="state.localLoading"
node-key="id"
highlight-current
default-expand-all
@node-click="handleNodeClick"
>
<template #default="{ node, data }" v-if="$slots.default">
<slot :node="node" :data="data"></slot>
</template>
</el-tree>
</div>
</template>
<script setup lang="ts" name="query-tree">
import { useMessage } from '/@/hooks/message';
const emit = defineEmits(['search', 'nodeClick']);
const props = defineProps({
/**
* 树结构属性配置。
*
* @default { label: 'name', children: 'children', value: 'id' }
*/
props: {
type: Object,
default: () => {
return {
label: 'name',
children: 'children',
value: 'id',
};
},
},
/**
* 输入框占位符。
*
* @default ''
*/
placeholder: {
type: String,
default: '',
},
/**
* 是否显示加载中状态。
*
* @default false
*/
loading: {
type: Boolean,
default: false,
},
/**
* 查询函数,必须返回 Promise 类型数据。
*/
query: {
type: Function,
required: true,
},
/**
* 是否显示折叠控制
*/
showExpand: {
type: Boolean,
default: false,
},
});
const state = reactive({
List: [], // 树形结构列表数据
localLoading: props.loading, // 是否加载中
});
const deptTreeRef = ref(); // 部门树形结构组件实例引用
const searchName = ref(); // 查询关键字
const isExpand = ref(true); // 是否展开所有节点
const buttonClass = computed(() => {
return ['!h-[20px]', 'reset-margin', '!text-gray-500', 'dark:!text-white', 'dark:hover:!text-primary'];
});
/**
* 点击树形结构节点触发的事件。
*
* @param item 被点击的节点数据。
*/
const handleNodeClick = (item: any) => {
emit('nodeClick', item);
};
/**
* 获取部门树形结构数据。
*/
const getdeptTree = () => {
if (props.query instanceof Function) {
state.localLoading = true;
// 调用传入的查询函数,并将查询关键字作为参数传入
const result = props.query(unref(searchName));
// 如果查询结果为 Promise 类型,则进行后续处理
if ((typeof result === 'object' || typeof result === 'function') && typeof result.then === 'function') {
result
.then((r: any) => {
state.List = r;
})
.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>

View File

@@ -0,0 +1,119 @@
<template>
<div class="top-right-btn" :style="style">
<el-row>
<!-- 搜索框控制 -->
<el-tooltip
class="item"
effect="dark"
:content="showSearch ? $t('queryTree.hideSearch') : $t('queryTree.displayTheSearch')"
placement="top"
v-if="search"
>
<el-button circle icon="Search" @click="toggleSearch()" />
</el-tooltip>
<!-- 导出 -->
<el-tooltip class="item" effect="dark" :content="$t('common.exportBtn')" placement="top" v-if="isExport()">
<el-button circle icon="Download" @click="handleExport()" />
</el-tooltip>
<!-- 刷新功能 -->
<el-tooltip class="item" effect="dark" :content="$t('queryTree.refresh')" placement="top">
<el-button circle icon="Refresh" @click="handleRefresh()" />
</el-tooltip>
<!-- 插槽 -->
<slot></slot>
</el-row>
</div>
</template>
<script setup name="right-toolbar">
import { auth } from '/@/utils/authFunction';
/**
* 通过 defineProps 函数定义组件 props
*/
const props = defineProps({
/**
* 是否显示搜索框
*/
showSearch: {
type: Boolean,
default: true,
},
/**
* 是否导出
*/
export: {
type: [String, Boolean],
default: null,
},
/**
* 是否显示搜索框
*/
search: {
type: Boolean,
default: true,
},
/**
* 列表项之间的间距
*/
gutter: {
type: Number,
default: 10,
},
});
const emits = defineEmits(['update:showSearch', 'queryTable', 'exportExcel']);
const style = computed(() => {
const ret = {};
// 如果props中有传入gutter属性则计算出marginRight
if (props.gutter) {
ret.marginRight = `${props.gutter / 2}px`;
}
return ret; // 返回计算后的样式对象
});
// 搜索
const toggleSearch = () => {
emits('update:showSearch', !props.showSearch);
};
// 刷新
const handleRefresh = () => {
emits('queryTable');
};
// 导出excel
const handleExport = () => {
emits('exportExcel');
};
// 是否导出
const isExport = () => {
if (props.export === true) {
return true;
}
// 字符串鉴权
return props.export && auth(props.export);
};
</script>
<style lang="scss" scoped>
:deep(.el-transfer__button) {
border-radius: 50%;
display: block;
margin-left: 0px;
}
:deep(.el-transfer__button:first-child) {
margin-bottom: 10px;
}
.my-el-transfer {
text-align: center;
}
</style>

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

View File

@@ -0,0 +1,90 @@
<template>
<div class="container">
<el-tag :color="randomColor()" class="container-tag">
<SvgIcon :name="props.icon" :size="25" color="#ffffff" />
</el-tag>
<span class="container-span">{{ $t(props.label) }}</span>
</div>
</template>
<script setup name="shortcut">
const props = defineProps({
icon: {
type: String,
default: () => 'menu-outlined',
required: false,
},
label: {
type: String,
default: () => '快捷方式',
required: false,
},
color: {
type: String,
default: () => '',
required: false,
},
});
// 颜色列表
const colorList = ['#7265E6', '#FFBF00', '#00A2AE', '#F56A00', '#1890FF', '#606D80'];
// 获取随机颜色
const randomColor = () => {
if (props.color) {
return props.color;
}
return colorList[randomNum(0, colorList.length - 1)];
};
// 获取minNum到maxNum内的随机数
const randomNum = (minNum, maxNum) => {
switch (arguments.length) {
case 1:
return parseInt(Math.random() * minNum + 1, 10);
// eslint-disable-next-line no-unreachable
break;
case 2:
return parseInt(Math.random() * (maxNum - minNum + 1) + minNum, 10);
// eslint-disable-next-line no-unreachable
break;
default:
return 0;
// eslint-disable-next-line no-unreachable
break;
}
};
</script>
<style scoped>
.container {
height: 60px;
/*border:1px solid var(--border-color-split);*/
border-radius: 5px;
display: flex;
align-items: center;
cursor: pointer;
/*实现渐变(时间变化效果)*/
-webkit-transition: all 0.5s;
-moz-transition: all 0.5s;
-ms-transition: all 0.5s;
-o-transition: all 0.5s;
transition: all 0.5s;
}
.container:hover {
background: var(--border-color-split);
}
.container-tag {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
margin-left: 10px;
font-size: 24px;
}
.container-span {
max-width: 60%;
font-weight: 500;
margin-left: 10px;
color: #6d6b6b;
}
</style>

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

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

View File

@@ -0,0 +1,118 @@
<template>
<div class="prefixCls relative" style="width: 100%">
<el-input v-model="innerValueRef" v-bind="$attrs" type="password" show-password @change="handleChange" style="width: 100%">
<template #[item]="data" v-for="item in Object.keys($slots)">
<slot :name="item" v-bind="data || {}"></slot>
</template>
</el-input>
<div class="prefixCls-bar">
<div class="prefixCls-bar--fill" :data-score="getPasswordStrength"></div>
</div>
</div>
</template>
<script setup lang="ts" name="StrengthMeter">
import { verifyPasswordStrength } from '/@/utils/toolsValidate';
const props = defineProps({
value: {
type: String,
},
showInput: {
type: Boolean,
default: () => {
return true;
},
},
disabled: {
type: Boolean,
},
});
const emit = defineEmits(['score', 'change', 'update:value']);
// 计算密码强度
const getPasswordStrength = computed(() => {
const { disabled } = props;
if (disabled) return -1;
const innerValue = unref(innerValueRef);
const score = innerValue ? verifyPasswordStrength(innerValue) : -1;
emit('score', score);
return score;
});
const innerValueRef = ref();
const handleChange = (e: any) => {
innerValueRef.value = e;
};
watchEffect(() => {
innerValueRef.value = props.value || '';
});
watch(
() => unref(innerValueRef),
(val) => {
emit('update:value', val);
emit('change', val);
}
);
</script>
<style scoped lang="scss">
.prefixCls {
&-bar {
position: relative;
height: 6px;
margin: 10px auto 6px;
background-color: grey;
border-radius: 6px;
&::before,
&::after {
position: absolute;
z-index: 10;
display: block;
width: 33%;
height: inherit;
background-color: transparent;
border-color: white;
border-style: solid;
border-width: 0 5px;
content: '';
}
&::before {
left: 33%;
}
//
//&::after {
// right: 33%;
//}
&--fill {
position: absolute;
width: 0;
height: inherit;
background-color: transparent;
border-radius: inherit;
transition: width 0.5s ease-in-out, background 0.25s;
&[data-score='1'] {
width: 33%;
background-color: var(--el-color-danger);
}
&[data-score='2'] {
width: 67%;
background-color: var(--el-color-warning);
}
&[data-score='3'] {
width: 100%;
background-color: var(--el-color-success);
}
}
}
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<i v-if="isShowIconSvg" class="el-icon" :style="setIconSvgStyle">
<component :is="getIconName" />
</i>
<div v-else-if="isShowIconImg" :style="setIconImgOutStyle">
<img :src="getIconName" :style="setIconSvgInsStyle" />
</div>
<svg v-else-if="isShowLocalSvg" class="svg-icon icon" :style="setIconImgOutStyle">
<use :href="`#${getIconName}`" :fill="color"/>
</svg>
<i v-else :class="getIconName" :style="setIconSvgStyle" />
</template>
<script setup lang="ts" name="svgIcon">
import { computed } from 'vue';
// 定义父组件传过来的值
const props = defineProps({
// svg 图标组件名字
name: {
type: String,
},
// svg 大小
size: {
type: Number,
default: () => 14,
},
// svg 颜色
color: {
type: String
},
});
// 在线链接、本地引入地址前缀
const linesString = ['https', 'http', '/src', '/assets', 'data:image', import.meta.env.VITE_PUBLIC_PATH];
// 获取 icon 图标名称
const getIconName = computed(() => {
return props?.name;
});
// 用于判断 element plus 自带 svg 图标的显示、隐藏
const isShowIconSvg = computed(() => {
return props?.name?.startsWith('ele-');
});
// 用于判断在线链接、本地引入等图标显示、隐藏
const isShowIconImg = computed(() => {
return linesString.find((str) => props.name?.startsWith(str));
});
const isShowLocalSvg = computed(() => {
return props?.name?.startsWith('local-');
});
// 设置图标样式
const setIconSvgStyle = computed(() => {
return `font-size: ${props.size}px;color: ${props.color};`;
});
// 设置图片样式
const setIconImgOutStyle = computed(() => {
return `width: ${props.size}px;height: ${props.size}px;display: inline-block;overflow: hidden;`;
});
// 设置图片样式
const setIconSvgInsStyle = computed(() => {
const filterStyle: string[] = [];
const compatibles: string[] = ['-webkit', '-ms', '-o', '-moz'];
compatibles.forEach((j) => filterStyle.push(`${j}-filter: drop-shadow(${props.color} 30px 0);`));
return `width: ${props.size}px;height: ${props.size}px;position: relative;left: -${props.size}px;${filterStyle.join('')}`;
});
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="flex flex-wrap items-center gap-2 max-w-full">
<el-tag
v-for="tag in tags"
:key="tag"
:type="tagType"
closable
:disable-transitions="false"
@close="handleClose(tag)"
>
{{ tag }}
</el-tag>
<el-input
v-if="inputVisible"
ref="InputRef"
v-model="inputValue"
class="w-20"
size="small"
@keyup.enter="handleInputConfirm"
@blur="handleInputConfirm"
/>
<el-button v-else class="button-new-tag" size="small" @click="showInput">
{{ buttonText }}
</el-button>
</div>
</template>
<script lang="ts" setup>
import {ref, nextTick, defineProps, defineEmits} from 'vue';
import {ElInput} from 'element-plus';
const props = defineProps({
modelValue: {
type: Array as () => string[],
default: () => []
},
buttonText: {
type: String,
default: '+ New Tag'
},
tagType: {
type: String,
default: 'primary',
validator: (value: string) => {
return ['primary', 'success', 'info', 'warning', 'danger'].includes(value);
}
}
});
const emits = defineEmits(['update:modelValue']);
const inputValue = ref('');
const inputVisible = ref(false);
const InputRef = ref<InstanceType<typeof ElInput>>();
const tags = ref([...props.modelValue]);
const handleClose = (tag: string) => {
tags.value.splice(tags.value.indexOf(tag), 1);
emits('update:modelValue', tags.value);
};
watch(() => props.modelValue, (val) => {
tags.value = val;
});
const showInput = () => {
inputVisible.value = true;
nextTick(() => {
InputRef.value!.input!.focus();
});
};
const handleInputConfirm = () => {
if (inputValue.value) {
tags.value.push(inputValue.value);
emits('update:modelValue', tags.value);
}
inputVisible.value = false;
inputValue.value = '';
};
</script>

View File

@@ -0,0 +1,18 @@
<template>
<el-tooltip class="box-item" effect="dark" :content="props.content" :placement="props.placement">
<el-icon><QuestionFilled /></el-icon>
<slot></slot>
</el-tooltip>
</template>
<script setup lang="ts" name="tip">
const props = defineProps({
content: {
type: String,
},
placement: {
type: String,
default: 'top-start',
},
});
</script>

View File

@@ -0,0 +1,181 @@
<template>
<div class="el-tree-select">
<el-select
style="width: 100%"
v-model="valueId"
ref="treeSelect"
:filterable="true"
:clearable="true"
@clear="clearHandle"
:filter-method="selectFilterData"
:placeholder="placeholder"
>
<el-option :value="valueId" :label="valueTitle">
<el-tree
id="tree-option"
ref="selectTree"
:accordion="accordion"
:data="options"
:props="objMap"
:node-key="objMap.value"
:expand-on-click-node="false"
:default-expanded-keys="defaultExpandedKey"
:filter-node-method="filterNode"
@node-click="handleNodeClick"
></el-tree>
</el-option>
</el-select>
</div>
</template>
<script setup>
const { proxy } = getCurrentInstance();
const props = defineProps({
/* 配置项 */
objMap: {
type: Object,
default: () => {
return {
value: 'id', // ID字段名
label: 'label', // 显示名称
children: 'children', // 子级字段名
};
},
},
/* 自动收起 */
accordion: {
type: Boolean,
default: () => {
return false;
},
},
/**当前双向数据绑定的值 */
value: {
type: [String, Number],
default: '',
},
/**当前的数据 */
options: {
type: Array,
default: () => [],
},
/**输入框内部的文字 */
placeholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:value']);
const valueId = computed({
get: () => props.value,
set: (val) => {
emit('update:value', val);
},
});
const valueTitle = ref('');
const defaultExpandedKey = ref([]);
/**
* 初始化下拉菜单选择器的默认值,并设置默认选中和默认展开。
*/
function initHandle() {
nextTick(() => {
const selectedValue = valueId.value;
if (selectedValue !== null && typeof selectedValue !== 'undefined') {
const node = proxy.$refs.selectTree.getNode(selectedValue);
if (node) {
valueTitle.value = node.data[props.objMap.label];
proxy.$refs.selectTree.setCurrentKey(selectedValue); // 设置默认选中
defaultExpandedKey.value = [selectedValue]; // 设置默认展开
}
} else {
clearHandle();
}
});
}
/**
* 点击某一节点时触发的事件,更新当前的选中值和展开状态。
* @param {Object} node - 被点击的节点对象
*/
function handleNodeClick(node) {
valueTitle.value = node[props.objMap.label];
valueId.value = node[props.objMap.value];
defaultExpandedKey.value = [];
proxy.$refs.treeSelect.blur();
selectFilterData('');
}
/**
* 搜索过滤函数,根据输入的值来过滤显示的节点。
* @param {String} val - 输入框内的搜索关键字
*/
function selectFilterData(val) {
proxy.$refs.selectTree.filter(val);
}
/**
* 根据输入的值来判断节点是否需要显示。
* @param {String} value - 输入框内的搜索关键字
* @param {Object} data - 当前处理的节点数据
* @returns {Boolean} - 是否需要显示此节点
*/
function filterNode(value, data) {
if (!value) return true;
return data[props.objMap['label']].indexOf(value) !== -1;
}
/**
* 清空当前的选中状态,并重置展开状态。
*/
function clearHandle() {
valueTitle.value = '';
valueId.value = '';
defaultExpandedKey.value = [];
clearSelected();
}
/**
* 删除所有选中状态的节点。
*/
function clearSelected() {
const allNode = document.querySelectorAll('#tree-option .el-tree-node');
allNode.forEach((element) => element.classList.remove('is-current'));
}
onMounted(() => {
initHandle();
});
watch(valueId, () => {
initHandle();
});
</script>
<style lang="scss" scoped>
@import '/@/assets/styles/variables.module.scss';
.el-scrollbar .el-scrollbar__view .el-select-dropdown__item {
padding: 0;
background-color: #fff;
height: auto;
}
.el-select-dropdown__item.selected {
font-weight: normal;
}
ul li .el-tree .el-tree-node__content {
height: auto;
padding: 0 20px;
box-sizing: border-box;
}
:deep(.el-tree-node__content:hover),
:deep(.el-tree-node__content:active),
:deep(.is-current > div:first-child),
:deep(.el-tree-node__content:focus) {
background-color: mix(#fff, $--color-primary, 90%);
color: $--color-primary;
}
</style>

View File

@@ -0,0 +1,165 @@
<!-- excel 导入组件 -->
<template>
<el-dialog :title="prop.title" v-model="state.upload.open" :close-on-click-modal="false" draggable>
<el-upload
ref="uploadRef"
:limit="1"
accept=".xlsx, .xls"
:headers="headers"
:action="baseURL + other.adaptationUrl(url)"
:disabled="state.upload.isUploading"
:on-progress="handleFileUploadProgress"
:on-success="handleFileSuccess"
:on-error="handleFileError"
:auto-upload="false"
drag
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">
{{ $t('excel.operationNotice') }}
<em>{{ $t('excel.clickUpload') }}</em>
</div>
<template #tip>
<div class="el-upload__tip text-center">
<span>{{ $t('excel.fileFormat') }}</span>
<el-link type="primary" :underline="false" style="font-size: 12px; vertical-align: baseline"
@click="downExcelTemp" v-if="tempUrl"
>{{ $t('excel.downloadTemplate') }}
</el-link>
</div>
</template>
</el-upload>
<template #footer>
<el-button type="primary" @click="submitFileForm">{{ $t('common.confirmButtonText') }}</el-button>
<el-button @click="state.upload.open = false">{{ $t('common.cancelButtonText') }}</el-button>
</template>
</el-dialog>
<!--校验失败错误数据-->
<el-dialog :title="$t('excel.validationFailureData')" v-model="state.errorVisible">
<el-table :data="state.errorData">
<el-table-column property="lineNum" :label="$t('excel.lineNumbers')" width="100"></el-table-column>
<el-table-column property="errors" :label="$t('excel.misDescription')" show-overflow-tooltip>
<template v-slot="scope">
<el-tag type="danger" v-for="error in scope.row.errors" :key="error">{{ error }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-dialog>
</template>
<script setup lang="ts" name="upload-excel">
import {useMessage} from '/@/hooks/message';
import other from '/@/utils/other';
import {Session} from '/@/utils/storage';
const emit = defineEmits(['sizeChange', 'refreshDataList']);
const prop = defineProps({
url: {
type: String,
},
title: {
type: String,
},
tempUrl: {
type: String,
},
});
const uploadRef = ref();
const state = reactive({
errorVisible: false,
errorData: [],
dialog: {
title: '',
isShowDialog: false,
},
upload: {
open: false,
isUploading: false,
},
});
/**
* 下载模板文件
*/
const downExcelTemp = async () => {
try {
await other.downBlobFile(other.adaptationUrl(prop.tempUrl), {}, 'temp.xlsx');
} catch (error) {
useMessage().error('模板下载失败,请先维护模板文件');
}
};
/**
* 上传进度条变化事件
*/
const handleFileUploadProgress = () => {
state.upload.isUploading = true;
};
/**
* 上传失败事件处理
*/
const handleFileError = () => {
useMessage().error('上传失败,数据格式不合法!');
state.upload.open = false;
};
/**
* 上传成功事件处理
* @param {any} response - 上传成功的响应结果
*/
const handleFileSuccess = (response: any) => {
state.upload.isUploading = false;
state.upload.open = false;
uploadRef.value.clearFiles();
// 校验失败
if (response.code === 1) {
useMessage().error('导入失败,以下数据不合法');
state.errorVisible = true;
state.errorData = response.data;
uploadRef.value.clearFiles();
// 刷新表格
emit?.('refreshDataList');
} else {
useMessage().success(response.msg ? response.msg : '导入成功');
// 刷新表格
emit?.('refreshDataList');
}
};
/**
* 提交表单,触发上传
*/
const submitFileForm = () => {
uploadRef.value.submit();
};
/**
* 显示上传文件对话框,并清除上传信息
*/
const show = () => {
state.upload.isUploading = false;
state.upload.open = true;
};
/**
* 计算请求头部信息
*/
const headers = computed(() => {
return {
Authorization: 'Bearer ' + Session.getToken(),
'TENANT-ID': Session.getTenant(),
};
});
// 暴露变量
defineExpose({
show,
});
</script>
<style scoped></style>

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

View File

@@ -0,0 +1,19 @@
export default {
excel: {
downloadTemplate: 'downloading the template',
fileFormat: 'only xls, xlsx format files are allowed',
operationNotice: 'Drag the file here and',
clickUpload: 'click upload',
lineNumbers: 'line numbers',
misDescription: 'misDescription',
validationFailureData: 'validation failure data',
pleaseUpload: 'please upload',
size: 'size not exceeding',
format: 'format',
file: 'file',
sizeErrorText: 'file size error, max ',
typeErrorText: 'file type error, upload ',
uploadLimit: 'Upload limit exceeded. Maximum',
files: 'files allowed',
},
};

View File

@@ -0,0 +1,19 @@
export default {
excel: {
downloadTemplate: '下载模板',
fileFormat: '仅允许导入xls、xlsx格式文件。',
operationNotice: '将文件拖到此处,或',
clickUpload: '点击上传',
lineNumbers: '行号',
misDescription: '错误描述',
validationFailureData: '校验失败数据',
pleaseUpload: '请上传',
size: '大小不超过',
format: '格式为',
file: '的文件',
sizeErrorText: '文件大小不超过',
typeErrorText: '文件类型错误,请上传 ',
uploadLimit: '上传文件数量超出限制,最多允许上传',
files: '个文件',
},
};

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

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

View File

@@ -0,0 +1,524 @@
<template>
<div v-show="showBox" :class="mode == 'pop' ? 'mask' : ''" @click="handleMaskClick">
<div :class="mode == 'pop' ? 'verifybox' : ''" :style="{ 'max-width': parseInt(imgSize.width) + 30 + 'px' }" @click.stop>
<div v-if="mode == 'pop'" class="verifybox-top">
{{ t('verify.complete') }}
<span class="verifybox-close" @click="closeBox">
<i class="iconfont icon-close"></i>
</span>
</div>
<div :style="{ padding: mode == 'pop' ? '15px' : '0' }" class="verifybox-bottom">
<!-- 验证码容器 -->
<component
:is="componentType"
v-if="componentType"
ref="instance"
:arith="arith"
:barSize="barSize"
:blockSize="blockSize"
:captchaType="captchaType"
:explain="explain"
:figure="figure"
:imgSize="imgSize"
:mode="mode"
:type="verifyType"
:vSpace="vSpace"
></component>
</div>
</div>
</div>
</template>
<script>
/**
* Verify 验证码组件
* @description 分发验证码使用
* */
import { computed, ref, toRefs, watchEffect, defineAsyncComponent } from 'vue';
import { useI18n } from 'vue-i18n';
const VerifySlide = defineAsyncComponent(() => import('/@/components/Verifition/Verify/VerifySlide.vue'));
const VerifyPoints = defineAsyncComponent(() => import('/@/components/Verifition/Verify/VerifyPoints.vue'));
export default {
name: 'Vue2Verify',
components: {
VerifySlide,
VerifyPoints,
},
props: {
captchaType: {
type: String,
required: true,
},
figure: {
type: Number,
},
arith: {
type: Number,
},
mode: {
type: String,
default: 'pop',
},
vSpace: {
type: Number,
},
explain: {
type: String,
},
imgSize: {
type: Object,
default() {
return {
width: '310px',
height: '155px',
};
},
},
blockSize: {
type: Object,
},
barSize: {
type: Object,
},
},
setup(props) {
const { t } = useI18n();
const { captchaType, mode } = toRefs(props);
const clickShow = ref(false);
const verifyType = ref(undefined);
const componentType = ref(undefined);
const instance = ref({});
const showBox = computed(() => {
if (mode.value == 'pop') {
return clickShow.value;
} else {
return true;
}
});
/**
* refresh
* @description 刷新
* */
const refresh = () => {
if (instance.value.refresh) {
instance.value.refresh();
}
};
const closeBox = () => {
clickShow.value = false;
refresh();
};
const show = () => {
if (mode.value == 'pop') {
clickShow.value = true;
}
};
watchEffect(() => {
switch (captchaType.value) {
case 'blockPuzzle':
verifyType.value = '2';
componentType.value = 'VerifySlide';
break;
case 'clickWord':
verifyType.value = '';
componentType.value = 'VerifyPoints';
break;
}
});
const handleMaskClick = (e) => {
if (e.target.classList.contains('mask')) {
closeBox();
}
};
return {
t,
clickShow,
verifyType,
componentType,
instance,
showBox,
closeBox,
show,
handleMaskClick,
};
},
};
</script>
<style>
.verifybox {
position: fixed;
box-sizing: border-box;
border-radius: 2px;
border: 1px solid #e4e7eb;
background-color: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
margin: auto;
height: 280px;
overflow: hidden;
min-width: 320px;
z-index: 1002;
}
.verifybox-top {
padding: 0 15px;
height: 50px;
line-height: 50px;
text-align: left;
font-size: 16px;
color: #45494c;
border-bottom: 1px solid #e4e7eb;
box-sizing: border-box;
}
.verifybox-bottom {
padding: 15px;
box-sizing: border-box;
height: calc(100% - 50px);
overflow: hidden;
}
.verifybox-close {
position: absolute;
top: 13px;
right: 9px;
width: 24px;
height: 24px;
text-align: center;
cursor: pointer;
}
.mask {
position: fixed;
top: 0;
left: 0;
z-index: 1001;
width: 100%;
height: 100vh;
background: transparent;
pointer-events: auto;
}
.verify-tips {
position: absolute;
left: 0px;
bottom: 0px;
width: 100%;
height: 30px;
line-height: 30px;
color: #fff;
}
.suc-bg {
background-color: rgba(92, 184, 92, 0.5);
filter: progid:DXImageTransform.Microsoft.gradient(startcolorstr=#7f5CB85C, endcolorstr=#7f5CB85C);
}
.err-bg {
background-color: rgba(217, 83, 79, 0.5);
filter: progid:DXImageTransform.Microsoft.gradient(startcolorstr=#7fD9534F, endcolorstr=#7fD9534F);
}
/* ---------------------------- */
/*常规验证码*/
.verify-code {
font-size: 20px;
text-align: center;
cursor: pointer;
margin-bottom: 5px;
border: 1px solid #ddd;
}
.cerify-code-panel {
height: 100%;
overflow: hidden;
}
.verify-code-area {
float: left;
}
.verify-input-area {
float: left;
width: 60%;
padding-right: 10px;
}
.verify-change-area {
line-height: 30px;
float: left;
}
.varify-input-code {
display: inline-block;
width: 100%;
height: 25px;
}
.verify-change-code {
color: #337ab7;
cursor: pointer;
}
.verify-btn {
width: 200px;
height: 30px;
background-color: #337ab7;
color: #ffffff;
border: none;
margin-top: 10px;
}
/*滑动验证码*/
.verify-bar-area {
position: relative;
background: #ffffff;
text-align: center;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
border: 1px solid #ddd;
-webkit-border-radius: 4px;
}
.verify-bar-area .verify-move-block {
position: absolute;
top: 0px;
left: 0;
background: #fff;
cursor: pointer;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
box-shadow: 0 0 2px #888888;
-webkit-border-radius: 1px;
transition: none;
}
.verify-bar-area .verify-move-block:hover {
background-color: #337ab7;
color: #ffffff;
}
.verify-bar-area .verify-left-bar {
position: absolute;
top: -1px;
left: -1px;
background: #f0fff0;
cursor: pointer;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
border: 1px solid #ddd;
transition: none;
}
.verify-img-panel {
margin: 0;
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
border-radius: 3px;
position: relative;
}
.verify-img-panel .verify-refresh {
width: 25px;
height: 25px;
text-align: center;
padding: 5px;
cursor: pointer;
position: absolute;
top: 0;
right: 0;
z-index: 2;
}
.verify-img-panel .icon-refresh {
font-size: 20px;
color: #fff;
}
.verify-img-panel .verify-gap {
background-color: #fff;
position: relative;
z-index: 2;
border: 1px solid #fff;
}
.verify-bar-area .verify-move-block .verify-sub-block {
position: absolute;
text-align: center;
z-index: 3;
/* border: 1px solid #fff; */
}
.verify-bar-area .verify-move-block .verify-icon {
font-size: 18px;
}
.verify-bar-area .verify-msg {
z-index: 3;
}
.iconfont {
font-family: 'iconfont' !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-check:before {
content: ' ';
display: block;
width: 16px;
height: 16px;
position: absolute;
margin: auto;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 9999;
background-size: contain;
}
.icon-close:before {
content: ' ';
display: block;
width: 16px;
height: 16px;
position: absolute;
margin: auto;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 9999;
background-size: contain;
}
.icon-right:before {
content: ' ';
display: block;
width: 16px;
height: 16px;
position: absolute;
margin: auto;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-size: cover;
z-index: 9999;
background-size: contain;
}
.icon-refresh:before {
content: ' ';
display: block;
width: 16px;
height: 16px;
position: absolute;
margin: auto;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 9999;
background-size: contain;
}
/* 在 <style> 标签内添加暗黑模式样式 */
html.dark {
.verifybox {
background-color: var(--el-bg-color);
border: none;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
}
.verifybox-top {
color: var(--el-text-color-primary);
border-bottom: 1px solid var(--el-border-color-darker);
background-color: var(--el-bg-color-darker);
}
.verify-bar-area {
background: var(--el-bg-color-darker);
border: none;
}
.verify-bar-area .verify-move-block {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.1);
}
.verify-bar-area .verify-left-bar {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.2));
border: none;
opacity: 1;
}
.verify-img-panel {
border: none;
background-color: var(--el-bg-color-darker);
}
.verify-img-panel .verify-gap {
background-color: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.2);
}
/* 暗黑模式下的图标颜色 */
.icon-refresh:before,
.icon-right:before,
.icon-close:before,
.verify-bar-area .verify-move-block:after {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23ffffff" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/></svg>');
opacity: 0.8;
}
.mask {
background: transparent;
}
}
/* 修改图标样式 */
.icon-refresh:before {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23666" d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>');
}
.icon-right:before {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23666" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/></svg>');
}
.icon-close:before {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23666" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>');
}
.verify-bar-area .verify-move-block {
display: flex;
align-items: center;
justify-content: center;
}
.verify-bar-area .verify-move-block:after {
content: '';
width: 16px;
height: 16px;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23666" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/></svg>');
background-size: contain;
background-repeat: no-repeat;
}
</style>

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

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

View File

@@ -0,0 +1,23 @@
/**
* 此处可直接引用自己项目封装好的 axios 配合后端联调
*/
import request from '/@/utils/request';
//获取验证图片 以及token
export function reqGet(data: Object) {
return request({
url: '/auth/code/create',
method: 'get',
data,
});
}
//滑动或者点选验证
export function reqCheck(data: Object) {
return request({
url: '/auth/code/check',
method: 'post',
params: data,
});
}

View File

@@ -0,0 +1,11 @@
export default {
verify: {
complete: 'Please complete security verification',
slide: {
explain: 'Slide right to verify',
success: 'Verification successful',
fail: 'Verification failed',
time: 'Verified in {time}s'
}
}
};

View File

@@ -0,0 +1,11 @@
export default {
verify: {
complete: '请完成安全验证',
slide: {
explain: '向右滑动完成验证',
success: '验证成功',
fail: '验证失败',
time: '{time}s验证成功'
}
}
}

View File

@@ -0,0 +1,11 @@
import CryptoJS from 'crypto-js';
/**
* @word 要加密的内容
* @keyWord String 服务器随机返回的关键字
* */
export function aesEncrypt(word, keyWord = 'XwKsGlMcdPMEhR1B') {
var key = CryptoJS.enc.Utf8.parse(keyWord);
var srcs = CryptoJS.enc.Utf8.parse(word);
var encrypted = CryptoJS.AES.encrypt(srcs, key, { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 });
return encrypted.toString();
}

View File

@@ -0,0 +1,97 @@
export function resetSize(vm) {
var img_width, img_height, bar_width, bar_height; //图片的宽度、高度,移动条的宽度、高度
var parentWidth = vm.$el.parentNode.offsetWidth || window.offsetWidth;
var parentHeight = vm.$el.parentNode.offsetHeight || window.offsetHeight;
if (vm.imgSize.width.indexOf('%') != -1) {
img_width = (parseInt(vm.imgSize.width) / 100) * parentWidth + 'px';
} else {
img_width = vm.imgSize.width;
}
if (vm.imgSize.height.indexOf('%') != -1) {
img_height = (parseInt(vm.imgSize.height) / 100) * parentHeight + 'px';
} else {
img_height = vm.imgSize.height;
}
if (vm.barSize.width.indexOf('%') != -1) {
bar_width = (parseInt(vm.barSize.width) / 100) * parentWidth + 'px';
} else {
bar_width = vm.barSize.width;
}
if (vm.barSize.height.indexOf('%') != -1) {
bar_height = (parseInt(vm.barSize.height) / 100) * parentHeight + 'px';
} else {
bar_height = vm.barSize.height;
}
return { imgWidth: img_width, imgHeight: img_height, barWidth: bar_width, barHeight: bar_height };
}
export const _code_chars = [
1,
2,
3,
4,
5,
6,
7,
8,
9,
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
];
export const _code_color1 = ['#fffff0', '#f0ffff', '#f0fff0', '#fff0f0'];
export const _code_color2 = ['#FF0033', '#006699', '#993366', '#FF9900', '#66CC66', '#FF33CC'];

View File

@@ -0,0 +1,72 @@
<template>
<div>
<video-play
ref="playerRef"
v-bind="options"
:src="src"
@play="onPlay"
@pause="onPause"
@timeupdate="onTimeupdate"
@canplay="onCanplay"
/>
</div>
</template>
<script setup lang="ts">
import { reactive, shallowRef } from 'vue'
import 'vue3-video-play/dist/style.css'
import VideoPlay from 'vue3-video-play'
const props = defineProps({
src: {
type: String,
required: true
},
width: String,
height: String,
poster: String
})
const playerRef = shallowRef()
const options = reactive({
color: 'var(--el-color-primary)', //主题色
muted: false, //静音
webFullScreen: false,
speedRate: ['0.75', '1.0', '1.25', '1.5', '2.0'], //播放倍速
autoPlay: true, //自动播放
loop: false, //循环播放
mirror: false, //镜像画面
ligthOff: false, //关灯模式
volume: 0.3, //默认音量大小
control: true, //是否显示控制器
title: '', //视频名称
poster: '', //封面
...props
})
const play = () => {
playerRef.value.play()
}
const pause = () => {
playerRef.value.pause()
}
const onPlay = (event: any) => {
console.log(event, '播放')
}
const onPause = (event: any) => {
console.log(event, '暂停')
}
const onTimeupdate = (event: any) => {
console.log(event, '时间更新')
}
const onCanplay = (event: any) => {
console.log(event, '可以播放')
}
defineExpose({
play,
pause
})
</script>

View File

@@ -0,0 +1,160 @@
<template>
<div></div>
</template>
<script setup lang="ts" name="global-websocket">
import { ElNotification } from 'element-plus';
import { Session } from '/@/utils/storage';
import other from "/@/utils/other";
const emit = defineEmits(['rollback']);
const props = defineProps({
uri: {
type: String,
},
});
const state = reactive({
webSocket: ref(), // webSocket实例
lockReconnect: false, // 重连锁,避免多次重连
maxReconnect: 6, // 最大重连次数, -1 标识无限重连
reconnectTime: 0, // 重连尝试次数
heartbeat: {
interval: 30 * 1000, // 心跳间隔时间
timeout: 10 * 1000, // 响应超时时间
pingTimeoutObj: ref(), // 延时发送心跳的定时器
pongTimeoutObj: ref(), // 接收心跳响应的定时器
pingMessage: JSON.stringify({ type: 'ping' }), // 心跳请求信息
},
});
const token = computed(() => {
return Session.getToken();
});
const tenant = computed(() => {
return Session.getTenant();
});
onMounted(() => {
initWebSocket();
});
onUnmounted(() => {
state.webSocket.close();
clearTimeoutObj(state.heartbeat);
});
const initWebSocket = () => {
// ws地址
let host = window.location.host;
// baseURL
let baseURL = import.meta.env.VITE_API_URL;
let wsUri = `ws://${host}${baseURL}${other.adaptationUrl(props.uri)}?access_token=${token.value}&TENANT-ID=${tenant.value}`;
// 建立连接
state.webSocket = new WebSocket(wsUri);
// 连接成功
state.webSocket.onopen = onOpen;
// 连接错误
state.webSocket.onerror = onError;
// 接收信息
state.webSocket.onmessage = onMessage;
// 连接关闭
state.webSocket.onclose = onClose;
};
const reconnect = () => {
if (!token) {
return;
}
if (state.lockReconnect || (state.maxReconnect !== -1 && state.reconnectTime > state.maxReconnect)) {
return;
}
state.lockReconnect = true;
setTimeout(() => {
state.reconnectTime++;
// 建立新连接
initWebSocket();
state.lockReconnect = false;
}, 5000);
};
/**
* 清空定时器
*/
const clearTimeoutObj = (heartbeat: any) => {
heartbeat.pingTimeoutObj && clearTimeout(heartbeat.pingTimeoutObj);
heartbeat.pongTimeoutObj && clearTimeout(heartbeat.pongTimeoutObj);
};
/**
* 开启心跳
*/
const startHeartbeat = () => {
const webSocket = state.webSocket;
const heartbeat = state.heartbeat;
// 清空定时器
clearTimeoutObj(heartbeat);
// 延时发送下一次心跳
heartbeat.pingTimeoutObj = setTimeout(() => {
// 如果连接正常
if (webSocket.readyState === 1) {
//这里发送一个心跳,后端收到后,返回一个心跳消息,
webSocket.send(heartbeat.pingMessage);
// 心跳发送后,如果服务器超时未响应则断开,如果响应了会被重置心跳定时器
heartbeat.pongTimeoutObj = setTimeout(() => {
webSocket.close();
}, heartbeat.timeout);
} else {
// 否则重连
reconnect();
}
}, heartbeat.interval);
};
/**
* 连接成功事件
*/
const onOpen = () => {
//开启心跳
startHeartbeat();
state.reconnectTime = 0;
};
/**
* 连接失败事件
* @param e
*/
const onError = () => {
//重连
reconnect();
};
/**
* 连接关闭事件
* @param e
*/
const onClose = () => {
//重连
reconnect();
};
/**
* 接收服务器推送的信息
* @param msgEvent
*/
const onMessage = (msgEvent: any) => {
//收到服务器信息,心跳重置并发送
startHeartbeat();
const text = msgEvent.data;
if (text.indexOf('pong') > 0) {
return;
}
ElNotification.warning({
title: '消息提醒',
dangerouslyUseHTMLString: true,
message: text + '请及时处理',
offset: 60,
});
emit('rollback', text);
};
</script>

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

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

View File

@@ -0,0 +1,101 @@
.avue-card {
&__item {
margin-bottom: 16px;
border: 1px solid #e8e8e8;
background-color: #fff;
box-sizing: border-box;
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5;
list-style: none;
font-feature-settings: 'tnum';
cursor: pointer;
height: 200px;
&:hover {
border-color: rgba(0, 0, 0, 0.09);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
}
&--add {
border: 1px dashed #000;
width: 100%;
color: rgba(0, 0, 0, 0.45);
background-color: #fff;
border-color: #d9d9d9;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
i {
margin-right: 10px;
}
&:hover {
color: #40a9ff;
background-color: #fff;
border-color: #40a9ff;
}
}
}
&__body {
display: flex;
padding: 24px;
}
&__detail {
flex: 1;
}
&__avatar {
width: 48px;
height: 48px;
border-radius: 48px;
overflow: hidden;
margin-right: 12px;
img {
width: 100%;
height: 100%;
}
}
&__title {
color: rgba(0, 0, 0, 0.85);
margin-bottom: 12px;
font-size: 16px;
&:hover {
color: #1890ff;
}
}
&__info {
color: rgba(0, 0, 0, 0.45);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
height: 64px;
}
&__menu {
display: flex;
justify-content: space-around;
height: 50px;
background: #f7f9fa;
color: rgba(0, 0, 0, 0.45);
text-align: center;
line-height: 50px;
&:hover {
color: #1890ff;
}
}
}
/** joolun 额外加的 */
.avue-comment__main {
flex: unset !important;
border-radius: 5px !important;
margin: 0 8px !important;
}
.avue-comment__header {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.avue-comment__body {
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
}

View File

@@ -0,0 +1,92 @@
/* 来自 https://github.com/nmxiaowei/avue/blob/master/styles/src/element-ui/comment.scss */
.avue-comment {
margin-bottom: 30px;
display: flex;
align-items: flex-start;
&--reverse {
flex-direction: row-reverse;
.avue-comment__main {
&:before,
&:after {
left: auto;
right: -8px;
border-width: 8px 0 8px 8px;
}
&:before {
border-left-color: #dedede;
}
&:after {
border-left-color: #f8f8f8;
margin-right: 1px;
margin-left: auto;
}
}
}
&__avatar {
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid transparent;
box-sizing: border-box;
vertical-align: middle;
}
&__header {
padding: 5px 15px;
background: #f8f8f8;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
}
&__author {
font-weight: 700;
font-size: 14px;
color: #999;
}
&__main {
flex: 1;
margin: 0 20px;
position: relative;
border: 1px solid #dedede;
border-radius: 2px;
&:before,
&:after {
position: absolute;
top: 10px;
left: -8px;
right: 100%;
width: 0;
height: 0;
display: block;
content: ' ';
border-color: transparent;
border-style: solid solid outset;
border-width: 8px 8px 8px 0;
pointer-events: none;
}
&:before {
border-right-color: #dedede;
z-index: 1;
}
&:after {
border-right-color: #f8f8f8;
margin-left: 1px;
z-index: 2;
}
}
&__body {
padding: 15px;
overflow: hidden;
background: #fff;
font-family: Segoe UI, Lucida Grande, Helvetica, Arial, Microsoft YaHei, FreeSans, Arimo, Droid Sans, wenquanyi micro hei, Hiragino Sans GB,
Hiragino Sans GB W3, FontAwesome, sans-serif;
color: #333;
font-size: 14px;
}
blockquote {
margin: 0;
font-family: Georgia, Times New Roman, Times, Kai, Kaiti SC, KaiTi, BiauKai, FontAwesome, serif;
padding: 1px 0 1px 15px;
border-left: 4px solid #ddd;
}
}

View File

@@ -0,0 +1,258 @@
<template>
<el-dialog title="用户消息" v-model="visible" :close-on-click-modal="false" draggable>
<div v-loading="mainLoading" class="msg-main">
<div id="msg-div" class="msg-div">
<div v-if="!tableLoading">
<div v-if="loadMore" class="el-table__empty-block" @click="loadingMore"><span class="el-table__empty-text">点击加载更多</span></div>
<div v-if="!loadMore" class="el-table__empty-block"><span class="el-table__empty-text">没有更多了</span></div>
</div>
<div v-for="item in tableData" :key="item.id" class="execution" id="msgTable">
<div class="avue-comment" :class="item.type === '2' ? 'avue-comment--reverse' : ''">
<div class="avatar-div">
<name-avatar v-if="item.type === '1'" scale="2" :name="item.nickName" />
<name-avatar v-if="item.type !== '1'" scale="2" :face-url="item.appLogo" />
</div>
<div class="avue-comment__main">
<div class="avue-comment__header">
<div class="avue-comment__create_time">{{ item.createTime }}</div>
</div>
<div class="avue-comment__body" :style="item.type === '2' ? 'background: #6BED72;' : ''">
<div v-if="item.repType === 'event' && item.repEvent === 'subscribe'">
<el-tag type="success" size="mini">关注</el-tag>
</div>
<div v-if="item.repType === 'event' && item.repEvent === 'unsubscribe'">
<el-tag type="danger" size="mini">取消关注</el-tag>
</div>
<div v-if="item.repType === 'event' && item.repEvent === 'CLICK'">
<el-tag size="mini">点击菜单</el-tag>
{{ item.repName }}
</div>
<div v-if="item.repType === 'event' && item.repEvent === 'VIEW'">
<el-tag size="mini">点击菜单链接</el-tag>
{{ item.repUrl }}
</div>
<div v-if="item.repType === 'event' && item.repEvent === 'scancode_waitmsg'">
<el-tag size="mini">扫码结果</el-tag>
{{ item.repContent }}
</div>
<div v-if="item.repType === 'text'">{{ item.repContent }}</div>
<div v-if="item.repType === 'image'">
<a target="_blank" :href="item.repUrl"><img :src="item.repUrl" style="width: 100px" /></a>
</div>
<div v-if="item.repType === 'voice'">
<SvgIcon name="local-wx-voice" :size="80" @click="loadVideo(item)"></SvgIcon>
</div>
<div v-if="item.repType === 'video'" style="text-align: center">
<SvgIcon name="local-wx-video" :size="80" @click="loadVideo(item)"></SvgIcon>
</div>
<div v-if="item.repType === 'shortvideo'" style="text-align: center">
<svg-icon name="local-wx-video" :size="80" @click="loadVideo(item)"></svg-icon>
</div>
<div v-if="item.repType === 'location'">
<el-link
type="primary"
target="_blank"
:href="
'https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=' +
item.repLocationY +
'&pointy=' +
item.repLocationX +
'&name=' +
item.repContent +
'&ref=joolun'
"
>
<img
:src="
'https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|' +
item.repLocationX +
',' +
item.repLocationY +
'&key=PFFBZ-RBM3V-IEEPP-UH6KE-6QUQE-C4BVJ&size=250*180'
"
/>
<p />
<i class="el-icon-map-location"></i>{{ item.repContent }}
</el-link>
</div>
<div v-if="item.repType === 'link'" class="avue-card__detail">
<el-link type="success" :underline="false" target="_blank" :href="item.repUrl">
<div class="avue-card__title"><i class="el-icon-link"></i>{{ item.repName }}</div>
</el-link>
<div class="avue-card__info" style="height: unset">{{ item.repDesc }}</div>
</div>
<div v-if="item.repType === 'news'" style="width: 300px">
<wx-news :obj-data="JSON.parse(item.content)"></wx-news>
</div>
<div v-if="item.repType === 'music'">
<el-link type="success" :underline="false" target="_blank" :href="item.repUrl">
<div class="avue-card__body" style="padding: 10px; background-color: #fff; border-radius: 5px">
<div class="avue-card__avatar"><img :src="item.repThumbUrl" alt="" /></div>
<div class="avue-card__detail">
<div class="avue-card__title" style="margin-bottom: unset">{{ item.repName }}</div>
<div class="avue-card__info" style="height: unset">{{ item.repDesc }}</div>
</div>
</div>
</el-link>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-loading="sendLoading" class="msg-send" @keyup.enter="sendMsg">
<wx-reply :objData="objData"></wx-reply>
<el-button type="success" class="send-but" @click="sendMsg">发送(S)</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts" name="wx-msg">
import { fetchList, addObj } from '/@/api/mp/wx-fans-msg';
import { useMessage } from '/@/hooks/message';
import { getMaterialVideo } from '/@/api/mp/wx-material';
const WxReply = defineAsyncComponent(() => import('../wx-reply/index.vue'));
const WxNews = defineAsyncComponent(() => import('../wx-news/index.vue'));
const NameAvatar = defineAsyncComponent(() => import('/@/components/NameAvatar/index.vue'));
const visible = ref(false);
// 各种loading
const mainLoading = ref(false);
const sendLoading = ref(false);
const loadMore = ref(false);
const objData = ref({
repType: 'text',
appId: '',
}) as any;
const page = reactive({
total: 0, // 总页数
currentPage: 1, // 当前页数
pageSize: 10, // 每页显示多少条
ascs: [], //升序字段
descs: 'create_time', //降序字段
});
const wxData = reactive({
appId: '',
wxUserId: '',
});
const sendMsg = () => {
if (objData.value) {
if (objData.value.repType === 'news') {
if (JSON.parse(objData.value.content).length > 1) {
useMessage().error('图文消息条数限制在1条以内已默认发送第一条');
objData.value.content = JSON.parse(objData.value.content)[0];
}
}
sendLoading.value = true;
addObj(
Object.assign(
{
wxUserId: wxData.wxUserId,
appId: wxData.appId,
},
objData.value
)
)
.then(() => {
tableData.value = [];
getData().then(() => {
//box-container是添加overflow的父div也就是出现滚动条的div
var scrollTarget = document.getElementById('msg-div');
//scrollTarget.scrollHeight是获取dom元素的高度然后设置scrollTop
scrollTarget.scrollTop = scrollTarget.scrollHeight;
});
})
.finally(() => {
sendLoading.value = false;
});
}
};
const tableData = ref([] as any);
const tableLoading = ref(false);
const openDialog = (data: any) => {
wxData.wxUserId = data.wxUserId;
wxData.appId = data.appId;
objData.value.appId = data.appId;
getData();
visible.value = true;
};
const getData = () => {
tableLoading.value = true;
return fetchList({
...page,
...wxData,
}).then((res) => {
const data = res.data.records.reverse();
tableData.value = [...data, ...tableData.value];
page.total = res.data.total;
tableLoading.value = false;
if (data.length < page.pageSize || data.length === 0) {
loadMore.value = false;
}
});
};
const loadVideo = (item) => {
getMaterialVideo({
mediaId: item.repMediaId,
appId: item.appId,
}).then((response) => {
const data = response.data;
window.open(data.downUrl, 'target', '');
});
};
const loadingMore = () => {
page.currentPage = page.currentPage + 1;
getData();
};
// 暴露变量
defineExpose({
openDialog,
});
</script>
<style lang="scss" scoped>
@import './comment.scss';
@import './card.scss';
.msg-main {
margin-top: -30px;
padding: 10px;
}
.msg-div {
height: 50vh;
overflow: auto;
background-color: #eaeaea;
margin-left: 10px;
margin-right: 10px;
}
.msg-send {
padding: 10px;
}
.avatar-div {
text-align: center;
width: 80px;
}
.send-but {
float: right;
margin-top: 8px !important;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<div class="news-home">
<div v-for="(news, index) in props.objData" :key="index" class="news-div">
<a v-if="index === 0" target="_blank" :href="news.url">
<div class="news-main">
<div class="news-content">
<img class="material-img" :src="news.thumbUrl" style="width: 280; height: 120" />
<div class="news-content-title">
<span>{{ news.title }}</span>
</div>
</div>
</div>
</a>
<a v-if="index > 0" target="_blank" :href="news.url">
<div class="news-main-item">
<div class="news-content-item">
<div class="news-content-item-title">{{ news.title }}</div>
<div class="news-content-item-img">
<img class="material-img" :src="news.thumbUrl" height="100%" />
</div>
</div>
</div>
</a>
</div>
</div>
</template>
<script setup lang="ts" name="wx-news">
const props = defineProps({
objData: {
type: Array,
default: () => [],
},
});
</script>
<style lang="scss" scoped>
.news-home {
background-color: #ffffff;
width: 100%;
margin: auto;
}
.news-main {
width: 100%;
margin: auto;
}
.news-content {
background-color: #acadae;
width: 100%;
position: relative;
}
.news-content-title {
display: inline-block;
font-size: 12px;
color: #ffffff;
position: absolute;
left: 0px;
bottom: 0px;
background-color: black;
width: 98%;
padding: 1%;
opacity: 0.65;
white-space: normal;
box-sizing: unset !important;
}
.news-main-item {
background-color: #ffffff;
padding: 5px 0px;
border-top: 1px solid #eaeaea;
}
.news-content-item {
position: relative;
}
.news-content-item-title {
display: inline-block;
font-size: 10px;
width: 70%;
margin-left: 1%;
white-space: normal;
}
.news-content-item-img {
display: inline-block;
width: 25%;
background-color: #acadae;
margin-right: 1%;
}
.material-img {
width: 100%;
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<el-tabs v-model="objData.repType" type="border-card" @tab-click="handleClick" style="width: 100%">
<el-tab-pane name="text" label="text">
<template #label><i class="el-icon-document"></i> 文本</template>
<el-input v-model="objData.repContent" type="textarea" :rows="5" placeholder="请输入内容"> </el-input>
</el-tab-pane>
<el-tab-pane name="image" label="image">
<template #label><i class="el-icon-picture"></i> 图片</template>
<el-row>
<div v-if="objData.repUrl" class="select-item">
<img class="material-img" :src="objData.repUrl" />
<p v-if="objData.repName" class="item-name">{{ objData.repName }}</p>
<el-row class="ope-row">
<el-button type="danger" icon="el-icon-delete" circle @click="deleteObj"></el-button>
</el-row>
</div>
<div v-if="!objData.repUrl" style="width: 100%">
<el-row style="text-align: center">
<el-col :span="12" class="col-select">
<el-button type="success" @click="openMaterial({ type: 'image', accountId: props.objData.appId })"
>素材库选择<i class="fansel-icon--right"></i>
</el-button>
</el-col>
<el-col :span="12" class="col-add">
<wx-file-upload :data="uploadData" @success="handelUpload"></wx-file-upload>
</el-col>
</el-row>
</div>
</el-row>
</el-tab-pane>
<el-tab-pane name="voice" label="voice">
<template #label><i class="el-icon-phone"></i> 语音</template>
<el-row>
<div v-if="objData.repName" class="select-item">
<p class="item-name">{{ objData.repName }}</p>
<div class="item-infos">
<img :src="WxVoice" style="width: 100px" @click="loadVideo(item)" />
</div>
<el-row class="ope-row">
<el-button type="danger" icon="el-icon-delete" circle @click="deleteObj"></el-button>
</el-row>
</div>
<div v-if="!objData.repName" style="width: 100%">
<el-row style="text-align: center">
<el-col :span="12" class="col-select">
<el-button type="success" @click="openMaterial({ type: 'voice', accountId: props.objData.appId })"
>素材库选择<i class="fansel-icon--right"></i>
</el-button>
</el-col>
<el-col :span="12" class="col-add">
<wx-file-upload :data="uploadData" @success="handelUpload"></wx-file-upload>
</el-col>
</el-row>
</div>
</el-row>
</el-tab-pane>
<el-tab-pane name="video" label="video">
<template #label><i class="el-icon-share"></i> 视频</template>
<el-row style="text-align: center; flex: 1">
<el-input v-if="objData.repUrl" v-model="objData.repName" placeholder="请输入标题" style="margin: 10px"></el-input>
<el-input v-if="objData.repUrl" v-model="objData.repDesc" placeholder="请输入描述" style="margin: 10px"></el-input>
</el-row>
<el-row style="text-align: center">
<el-col :span="12" class="col-select">
<a v-if="objData.repUrl" target="_blank" :href="objData.repUrl"> <SvgIcon name="local-wx-video" :size="45" /> </a
></el-col>
<el-col :span="12" class="col-add">
<el-button type="success" @click="openMaterial({ type: 'video', accountId: props.objData.appId })">素材库选择 </el-button>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane name="news" label="news">
<template #label><i class="el-icon-news"></i> 图文</template>
<el-row>
<div v-if="objData.content" class="select-item">
<wx-news :obj-data="JSON.parse(objData.content)"></wx-news>
<el-row class="ope-row">
<el-button type="danger" icon="el-icon-delete" circle @click="deleteObj"></el-button>
</el-row>
</div>
<div v-if="!objData.content" style="width: 100%">
<el-row style="text-align: center">
<el-col :span="24" class="col-select2">
<el-button type="success" @click="openMaterial({ type: 'news', accountId: props.objData.appId })"
>素材库选择<i class="fansel-icon--right"></i>
</el-button>
</el-col>
</el-row>
</div>
</el-row>
</el-tab-pane>
</el-tabs>
<wx-material-select ref="dialogNewsRef" @selectMaterial="selectMaterial"></wx-material-select>
</template>
<script setup lang="ts" name="wx-reply">
import { getMaterialVideo } from '/@/api/mp/wx-material';
import { useMessage } from '/@/hooks/message';
const WxMaterialSelect = defineAsyncComponent(() => import('/@/components/Wechat/wx-material-select/main.vue'));
const WxFileUpload = defineAsyncComponent(() => import('/@/components/Wechat/fileUpload/index.vue'));
const WxNews = defineAsyncComponent(() => import('/@/components/Wechat/wx-news/index.vue'));
const props = defineProps({
objData: {
type: Object,
default: () => {
return {
repType: '',
repContent: '',
repName: '',
repDesc: '',
repUrl: '',
};
},
},
});
const uploadData = reactive({
mediaType: props.objData.repType,
title: '',
introduction: '',
appId: props.objData.appId,
});
const tempObj = ref({}) as any;
const handleClick = (tab) => {
uploadData.mediaType = tab.paneName;
uploadData.appId = props.objData.appId;
const tempObjItem = tempObj.value[tab.paneName];
if (tempObjItem) {
props.objData.repName = tempObjItem.repName ? tempObjItem.repName : null;
props.objData.repMediaId = tempObjItem.repMediaId ? tempObjItem.repMediaId : null;
props.objData.media_id = tempObjItem.media_id ? tempObjItem.media_id : null;
props.objData.repUrl = tempObjItem.repUrl ? tempObjItem.repUrl : null;
props.objData.content = tempObjItem.content ? tempObjItem.content : null;
props.objData.repDesc = tempObjItem.repDesc ? tempObjItem.repDesc : null;
} else {
props.objData.repName = '';
props.objData.repMediaId = '';
props.objData.media_id = '';
props.objData.repUrl = '';
props.objData.content = '';
props.objData.repDesc = '';
}
};
const deleteObj = () => {
props.objData.repName = '';
props.objData.repUrl = '';
props.objData.content = '';
};
const openMaterial = (data: any) => {
dialogNewsRef.value.openDialog(data);
};
const dialogNewsRef = ref();
const selectMaterial = (item, appId) => {
let tempObjItem = {
repType: '',
repMediaId: '',
media_id: '',
content: '',
} as any;
tempObjItem.repType = props.objData.repType;
tempObjItem.repMediaId = item.mediaId;
tempObjItem.media_id = item.mediaId;
tempObjItem.content = item.content;
props.objData.repMediaId = item.mediaId;
props.objData.media_id = item.mediaId;
props.objData.content = item.content;
if (props.objData.repType === 'music') {
tempObjItem.repThumbMediaId = item.mediaId;
tempObjItem.repThumbUrl = item.url;
props.objData.repThumbMediaId = item.mediaId;
props.objData.repThumbUrl = item.url;
} else {
tempObjItem.repName = item.name;
tempObjItem.repUrl = item.url;
props.objData.repName = item.name;
props.objData.repUrl = item.url;
}
if (props.objData.repType === 'video') {
getMaterialVideo({
mediaId: item.mediaId,
appId: appId,
}).then((response) => {
const data = response.data;
tempObjItem.repDesc = data.description || '';
tempObjItem.repUrl = data.downUrl;
props.objData.repName = data.title;
props.objData.repDesc = data.description || '';
props.objData.repUrl = data.downUrl;
});
}
if (props.objData.repType === 'news') {
props.objData.content = JSON.stringify(item.content.newsItem);
}
tempObj.value[props.objData.repType] = tempObjItem;
};
const handelUpload = (response) => {
if (response.code === 0) {
const item = response.data;
selectMaterial(item, props.objData.appId);
} else {
useMessage().error('上传错误' + response.msg);
}
};
const loadVideo = (item) => {
getMaterialVideo({
mediaId: item.repMediaId,
appId: item.appId,
}).then((response) => {
const data = response.data;
window.open(data.downUrl, 'target', '');
});
};
</script>
<style scoped lang="scss">
.select-item {
width: 280px;
padding: 10px;
margin: 0 auto 10px auto;
border: 1px solid #eaeaea;
}
.select-item2 {
padding: 10px;
margin: 0 auto 10px auto;
border: 1px solid #eaeaea;
}
.ope-row {
padding-top: 10px;
text-align: center;
}
.item-name {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.el-form-item__content {
line-height: unset !important;
}
.col-select {
border: 1px solid rgb(234, 234, 234);
padding: 50px 0px;
height: 160px;
width: 49.5%;
}
.col-select2 {
border: 1px solid rgb(234, 234, 234);
padding: 50px 0px;
height: 160px;
}
.col-add {
border: 1px solid rgb(234, 234, 234);
padding: 50px 0px;
height: 160px;
width: 49.5%;
float: right;
}
.avatar-uploader-icon {
border: 1px solid #d9d9d9;
font-size: 28px;
color: #8c939d;
width: 100px !important;
height: 100px !important;
line-height: 100px !important;
text-align: center;
}
.material-img {
width: 100%;
}
.thumb-div {
display: inline-block;
text-align: center;
}
.item-infos {
width: 30%;
margin: auto;
}
</style>

View File

@@ -0,0 +1,26 @@
<template>
<slot v-if="getUserAuthBtnList" />
</template>
<script setup lang="ts" name="auth">
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useUserInfo } from '/@/stores/userInfo';
// 定义父组件传过来的值
const props = defineProps({
value: {
type: String,
default: () => '',
},
});
// 定义变量内容
const stores = useUserInfo();
const { userInfos } = storeToRefs(stores);
// 获取 pinia 中的用户权限
const getUserAuthBtnList = computed(() => {
return userInfos.value.authBtnList.some((v: string) => v === props.value);
});
</script>

View File

@@ -0,0 +1,27 @@
<template>
<slot v-if="getUserAuthBtnList" />
</template>
<script setup lang="ts" name="authAll">
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useUserInfo } from '/@/stores/userInfo';
import { judementSameArr } from '/@/utils/arrayOperation';
// 定义父组件传过来的值
const props = defineProps({
value: {
type: Array,
default: () => [],
},
});
// 定义变量内容
const stores = useUserInfo();
const { userInfos } = storeToRefs(stores);
// 获取 pinia 中的用户权限
const getUserAuthBtnList = computed(() => {
return judementSameArr(props.value, userInfos.value.authBtnList);
});
</script>

View File

@@ -0,0 +1,32 @@
<template>
<slot v-if="getUserAuthBtnList" />
</template>
<script setup lang="ts" name="auths">
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useUserInfo } from '/@/stores/userInfo';
// 定义父组件传过来的值
const props = defineProps({
value: {
type: Array,
default: () => [],
},
});
// 定义变量内容
const stores = useUserInfo();
const { userInfos } = storeToRefs(stores);
// 获取 pinia 中的用户权限
const getUserAuthBtnList = computed(() => {
let flag = false;
userInfos.value.authBtnList.map((val: string) => {
props.value.map((v) => {
if (val === v) flag = true;
});
});
return flag;
});
</script>

69
src/components/index.ts Normal file
View 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);
},
};

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

View File

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

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

View 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">&nbsp;</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>

View 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)">&nbsp;</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>

View 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">&nbsp;</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>

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

File diff suppressed because it is too large Load Diff

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

View 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