初始化

This commit is contained in:
2026-02-22 18:56:10 +08:00
commit 26677972a6
3112 changed files with 255972 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
# 应用标题
VITE_APP_TITLE=EasyFlow
# 应用命名空间用于缓存、store等功能的前缀确保隔离
VITE_APP_NAMESPACE=easyflow-web
# 对store进行加密的密钥在将store持久化到localStorage时会使用该密钥进行加密
VITE_APP_STORE_SECURE_KEY=please-replace-me-with-your-own-key

View File

@@ -0,0 +1,7 @@
# public path
VITE_BASE=/
# Basic interface address SPA
VITE_GLOB_API_URL=/api
VITE_VISUALIZER=true

View File

@@ -0,0 +1,13 @@
# 端口号
VITE_PORT=5090
VITE_BASE=/
# 接口地址
VITE_GLOB_API_URL=http://127.0.0.1:8111
# 是否打开 devtoolstrue 为打开false 为关闭
VITE_DEVTOOLS=false
# 是否注入全局loading
VITE_INJECT_APP_LOADING=true

View File

@@ -0,0 +1,19 @@
VITE_BASE=/
# 接口地址
VITE_GLOB_API_URL=
# 是否开启压缩,可以设置为 none, brotli, gzip
VITE_COMPRESS=none
# 是否开启 PWA
VITE_PWA=false
# vue-router 的模式
VITE_ROUTER_HISTORY=hash
# 是否注入全局loading
VITE_INJECT_APP_LOADING=true
# 打包后是否生成dist.zip
VITE_ARCHIVER=true

View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="renderer" content="webkit" />
<meta name="description" content="A Modern Back-end Management System" />
<meta name="keywords" content="EasyFlow Admin Vue3 Vite" />
<meta name="author" content="EasyFlow" />
<meta
name="viewport"
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
/>
<title><%= VITE_APP_TITLE %></title>
<link rel="icon" href="/favicon.svg" />
<script>
var _hmt = _hmt || [];
(function() {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?42639a1503843c26bc4d8b35185616d9";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();
</script>
</head>
<body>
<div id="app"></div>
<!-- 引入验证码初始化js -->
<script src="/load.min.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,52 @@
{
"name": "@easyflow/app",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "pnpm vite build --mode production",
"build:analyze": "pnpm vite build --mode analyze",
"dev": "pnpm vite --mode development",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit --skipLibCheck"
},
"imports": {
"#/*": "./src/*"
},
"dependencies": {
"@easyflow-core/shadcn-ui": "workspace:*",
"@easyflow/access": "workspace:*",
"@easyflow/common-ui": "workspace:*",
"@easyflow/constants": "workspace:*",
"@easyflow/hooks": "workspace:*",
"@easyflow/icons": "workspace:*",
"@easyflow/layouts": "workspace:*",
"@easyflow/locales": "workspace:*",
"@easyflow/plugins": "workspace:*",
"@easyflow/preferences": "workspace:*",
"@easyflow/request": "workspace:*",
"@easyflow/stores": "workspace:*",
"@easyflow/styles": "workspace:*",
"@easyflow/types": "workspace:*",
"@easyflow/utils": "workspace:*",
"@element-plus/icons-vue": "^2.3.2",
"@tinyflow-ai/vue": "^1.2.2",
"@vueuse/core": "catalog:",
"dayjs": "catalog:",
"dompurify": "^3.3.1",
"element-plus": "catalog:",
"fetch-event-stream": "^0.1.6",
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.0",
"pinia": "catalog:",
"radash": "^12.1.1",
"vue": "catalog:",
"vue-cropper": "^1.1.4",
"vue-element-plus-x": "catalog:",
"vue-router": "catalog:",
"vue3-json-viewer": "^2.4.1"
},
"devDependencies": {
"cssnano": "catalog:",
"unplugin-element-plus": "catalog:"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@easyflow/tailwind-config/postcss';

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,16 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="EasyFlow favicon">
<defs>
<linearGradient id="efFavGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1D4ED8"/>
<stop offset="100%" stop-color="#0891B2"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#efFavGrad)"/>
<path d="M17 22h15m14 0h1m-16 20h15m-15 0H17" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round"/>
<path d="M32 22v20m0 0l11-8" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="17" cy="22" r="4" fill="#FFFFFF"/>
<circle cx="47" cy="22" r="4" fill="#FFFFFF"/>
<circle cx="32" cy="42" r="4" fill="#FFFFFF"/>
<circle cx="17" cy="42" r="4" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 827 B

View File

@@ -0,0 +1 @@
const Math=window.Math,head=document.getElementsByTagName("head")[0],TIMEOUT=1e4,TAC_LOADING_DIV='<div id="tac-loading" style="\n border: 1px solid #eee;\n /* background-color: #409EFF; */\n border-radius: 5px;\n width: 318px;\n height: 318px;\n line-height: 318px;\n color: #606266;\n text-align: center;\n position: relative;\n box-sizing: border-box;\n">请稍等...</div>';function showLoading(e){var t=document.querySelector(e);t&&(t.innerHTML=TAC_LOADING_DIV)}function hideLoading(e){let t=document.querySelector(e);t&&(t.innerHTML="")}function loadCaptchaScript(e,t,n,r,o){const i=e.scriptUrls,c=e.cssUrls,l=e.timeout||TIMEOUT;let s=i.length+c.length;function d(e,i){if(s--,e&&0===s){if(hideLoading(t.bindEl),!window.TAC)throw new Error("TAC未加载请检查地址是否正确");r(new TAC(t,n))}else e||(hideLoading(t.bindEl),o(i))}setTimeout(()=>{0!==s&&showLoading(t.bindEl)},10),i.forEach(function(e){loadResource("string"==typeof e?{url:e}:e,d,"script",l)}),c.forEach(function(e){loadResource("string"==typeof e?{url:e}:e,d,"link",l)})}function loadResource(e,t,n="script",r){if(document.querySelector(`${n}[${"script"===n?"src":"href"}="${e.url}"]`))return void t(!0,e);let o=!1;const i=document.createElement(n);"link"===n?i.rel="stylesheet":i.async=!0,i["script"===n?"src":"href"]=e.url;let c;i.onload=i.onreadystatechange=(()=>{o||i.readyState&&"loaded"!==i.readyState&&"complete"!==i.readyState||function t(n){e.checkOnReady?c=setTimeout(()=>{e.checkOnReady()?n():t(n)},10):n()}(()=>{o=!0,setTimeout(()=>t(o,e),0)})}),i.onerror=(()=>{t(o=!1,e)}),head.appendChild(i),setTimeout(()=>{o||(c&&clearTimeout(c),i.onload=i.onerror=null,i.remove&&i.remove(),t(o,e))},r||TIMEOUT)}function loadTAC(e,t,n){return new Promise((r,o)=>{let i={..."string"==typeof e?{url:e}:e};i.url&&(i.url.endsWith("/")||(i.url+="/"),i.scriptUrls||(i.scriptUrls=[i.url+"js/tac.min.js"]),i.cssUrls||(i.cssUrls=[i.url+"css/tac.css"])),i.scriptUrls&&i.cssUrls?loadCaptchaScript(i,t,n,r,o):o("请按照文档配置tac")})}setTimeout(()=>{let e=document.scripts,t=null;for(let n=0;n<e.length;n++)if(e[n].src.indexOf("load.js")>1||e[n].src.indexOf("load.min.js")>1){t=e[n].src.substring(e[n].src.indexOf("/"),e[n].src.lastIndexOf("/"));break}},100),window.loadCaptchaScript=loadCaptchaScript,window.loadTAC=loadTAC,window.initTAC=loadTAC;

View File

@@ -0,0 +1,20 @@
<svg width="120" height="27" viewBox="0 0 120 27" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="EasyFlow logo">
<defs>
<linearGradient id="efGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2563EB"/>
<stop offset="100%" stop-color="#06B6D4"/>
</linearGradient>
</defs>
<g transform="translate(0,2)">
<rect x="0" y="0" width="23" height="23" rx="6" fill="url(#efGrad)"/>
<path d="M6 7.5h5.5m5 0H17m-7.5 7h5.5m-5.5 0H6" stroke="#FFFFFF" stroke-width="1.8" stroke-linecap="round"/>
<path d="M11.5 7.5v7m0 0l4.2-3.2" stroke="#FFFFFF" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="6" cy="7.5" r="1.8" fill="#FFFFFF"/>
<circle cx="17" cy="7.5" r="1.8" fill="#FFFFFF"/>
<circle cx="11.5" cy="14.5" r="1.8" fill="#FFFFFF"/>
<circle cx="6" cy="14.5" r="1.8" fill="#FFFFFF"/>
</g>
<text x="30" y="18" fill="#0F172A" font-family="Segoe UI, Arial, sans-serif" font-size="13" font-weight="700" letter-spacing="0.2">EasyFlow</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,20 @@
<svg width="120" height="27" viewBox="0 0 120 27" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="EasyFlow logo dark">
<defs>
<linearGradient id="efGradDark" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#3B82F6"/>
<stop offset="100%" stop-color="#22D3EE"/>
</linearGradient>
</defs>
<g transform="translate(0,2)">
<rect x="0" y="0" width="23" height="23" rx="6" fill="url(#efGradDark)"/>
<path d="M6 7.5h5.5m5 0H17m-7.5 7h5.5m-5.5 0H6" stroke="#FFFFFF" stroke-width="1.8" stroke-linecap="round"/>
<path d="M11.5 7.5v7m0 0l4.2-3.2" stroke="#FFFFFF" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="6" cy="7.5" r="1.8" fill="#FFFFFF"/>
<circle cx="17" cy="7.5" r="1.8" fill="#FFFFFF"/>
<circle cx="11.5" cy="14.5" r="1.8" fill="#FFFFFF"/>
<circle cx="6" cy="14.5" r="1.8" fill="#FFFFFF"/>
</g>
<text x="30" y="18" fill="#F8FAFC" font-family="Segoe UI, Arial, sans-serif" font-size="13" font-weight="700" letter-spacing="0.2">EasyFlow</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,16 @@
<svg width="24" height="22" viewBox="0 0 24 22" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="EasyFlow logo mini">
<defs>
<linearGradient id="efMiniGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2563EB"/>
<stop offset="100%" stop-color="#06B6D4"/>
</linearGradient>
</defs>
<rect x="0.5" y="0.5" width="21" height="21" rx="6" fill="url(#efMiniGrad)"/>
<path d="M6 7.5h5.5m5 0H17m-7.5 7h5.5m-5.5 0H6" stroke="#FFFFFF" stroke-width="1.8" stroke-linecap="round"/>
<path d="M11.5 7.5v7m0 0l4.2-3.2" stroke="#FFFFFF" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="6" cy="7.5" r="1.8" fill="#FFFFFF"/>
<circle cx="17" cy="7.5" r="1.8" fill="#FFFFFF"/>
<circle cx="11.5" cy="14.5" r="1.8" fill="#FFFFFF"/>
<circle cx="6" cy="14.5" r="1.8" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 859 B

View File

@@ -0,0 +1,77 @@
<svg width="1600" height="1080" viewBox="0 0 1600 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="140" y1="80" x2="1460" y2="980" gradientUnits="userSpaceOnUse">
<stop stop-color="#F1F6FF"/>
<stop offset="1" stop-color="#E7F0FF"/>
</linearGradient>
<linearGradient id="panel" x1="420" y1="180" x2="1220" y2="820" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF" stop-opacity="0.96"/>
<stop offset="1" stop-color="#F4F8FF" stop-opacity="0.98"/>
</linearGradient>
<linearGradient id="brand" x1="690" y1="520" x2="1010" y2="760" gradientUnits="userSpaceOnUse">
<stop stop-color="#3A92FF"/>
<stop offset="1" stop-color="#1D6CFF"/>
</linearGradient>
<linearGradient id="chipBlue" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#62B6FF"/>
<stop offset="1" stop-color="#3C87F6"/>
</linearGradient>
<linearGradient id="chipWarm" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#FFD48A"/>
<stop offset="1" stop-color="#FFB54D"/>
</linearGradient>
</defs>
<rect width="1600" height="1080" fill="url(#bg)"/>
<circle cx="188" cy="192" r="146" fill="#DDEBFF"/>
<circle cx="1412" cy="188" r="104" fill="#E4EEFF"/>
<circle cx="1384" cy="902" r="168" fill="#DBEEFF"/>
<rect x="356" y="184" width="890" height="646" rx="34" fill="url(#panel)"/>
<rect x="356" y="184" width="890" height="76" rx="34" fill="#F6FAFF"/>
<circle cx="410" cy="222" r="8" fill="#F97373"/>
<circle cx="436" cy="222" r="8" fill="#FBBF24"/>
<circle cx="462" cy="222" r="8" fill="#4ADE80"/>
<rect x="414" y="306" width="264" height="30" rx="12" fill="#E8F1FF"/>
<rect x="414" y="352" width="212" height="18" rx="9" fill="#EEF4FF"/>
<rect x="414" y="386" width="188" height="18" rx="9" fill="#EEF4FF"/>
<rect x="742" y="306" width="444" height="434" rx="24" fill="#FFFFFF"/>
<rect x="778" y="348" width="372" height="20" rx="10" fill="#EAF2FF"/>
<rect x="778" y="384" width="304" height="20" rx="10" fill="#F0F5FF"/>
<rect x="778" y="440" width="372" height="110" rx="20" fill="#F5F9FF"/>
<rect x="808" y="474" width="150" height="20" rx="10" fill="#DCEAFF"/>
<rect x="808" y="506" width="220" height="16" rx="8" fill="#E6EEFF"/>
<rect x="778" y="576" width="372" height="134" rx="20" fill="url(#brand)"/>
<rect x="810" y="618" width="214" height="18" rx="9" fill="white" fill-opacity="0.28"/>
<rect x="810" y="648" width="284" height="18" rx="9" fill="white" fill-opacity="0.18"/>
<rect x="414" y="430" width="264" height="280" rx="20" fill="#FFFFFF"/>
<rect x="446" y="476" width="200" height="20" rx="10" fill="#EAF2FF"/>
<rect x="446" y="508" width="160" height="18" rx="9" fill="#F0F5FF"/>
<rect x="446" y="564" width="206" height="20" rx="10" fill="#EAF2FF"/>
<rect x="446" y="596" width="170" height="18" rx="9" fill="#F0F5FF"/>
<rect x="446" y="652" width="144" height="20" rx="10" fill="#EAF2FF"/>
<rect x="256" y="408" width="306" height="132" rx="22" fill="#FFFFFF"/>
<rect x="290" y="446" width="142" height="20" rx="10" fill="url(#chipWarm)"/>
<rect x="290" y="478" width="188" height="16" rx="8" fill="#FFE7C5"/>
<text x="290" y="523" fill="#B66A13" font-family="'Segoe UI', 'PingFang SC', sans-serif" font-size="30" font-weight="700">Knowledge</text>
<rect x="1092" y="472" width="300" height="132" rx="22" fill="#FFFFFF"/>
<rect x="1128" y="510" width="138" height="20" rx="10" fill="url(#chipBlue)"/>
<rect x="1128" y="542" width="188" height="16" rx="8" fill="#D8EEFF"/>
<text x="1128" y="586" fill="#1F6CB5" font-family="'Segoe UI', 'PingFang SC', sans-serif" font-size="30" font-weight="700">Workflow</text>
<rect x="676" y="96" width="248" height="170" rx="30" fill="#FFFFFF"/>
<rect x="704" y="124" width="192" height="114" rx="22" fill="url(#brand)"/>
<path d="M768 170H830" stroke="white" stroke-width="10" stroke-linecap="round"/>
<path d="M768 198H812" stroke="white" stroke-width="10" stroke-linecap="round"/>
<path d="M856 204L830 230C826 234 820 234 816 230L796 212" stroke="white" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>
<text x="800" y="930" text-anchor="middle" fill="#1A2C45" font-family="'Segoe UI', 'PingFang SC', sans-serif" font-size="60" font-weight="700">EasyFlow</text>
<text x="800" y="986" text-anchor="middle" fill="#6B7F9F" font-family="'Segoe UI', 'PingFang SC', sans-serif" font-size="30" font-weight="500">Unified AI workspace for agents, workflows and knowledge</text>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

View File

@@ -0,0 +1,7 @@
#tianai-captcha-parent{box-shadow:0 0 11px 0 #999;width:318px;height:318px;overflow:hidden;position:relative;z-index:997;box-sizing:border-box;border-radius:5px;padding:8px}#tianai-captcha-parent #tianai-captcha-box{height:260px;width:100%;position:relative;overflow:hidden}#tianai-captcha-parent #tianai-captcha-box .loading{width:120px;height:20px;-webkit-mask:linear-gradient(90deg, #000 70%, rgba(0, 0, 0, 0) 0) 0/20%;background:linear-gradient(#f7b645 0 0) 0/0% no-repeat rgba(221,221,221,.4196078431);animation:cartoon 1s infinite steps(6);margin:120px auto}@keyframes cartoon{100%{background-size:120%}}#tianai-captcha-parent #tianai-captcha-box #tianai-captcha{transform-style:preserve-3d;will-change:transform;transition-duration:.45s;transform:translateX(-300px)}#tianai-captcha-parent #tianai-captcha-bg-img{background-color:#fff;background-position:top;background-size:cover;z-index:-1;width:100%;height:100%;top:0;left:0;position:absolute;border-radius:6px}#tianai-captcha-parent .slider-bottom{height:19px;width:100%}#tianai-captcha-parent .slider-bottom .close-btn{width:20px;height:20px;background-image:url(../images/icon.png);background-repeat:no-repeat;background-position:0 -14px;float:right;margin-right:2px;cursor:pointer}#tianai-captcha-parent .slider-bottom .refresh-btn{width:20px;height:20px;background-image:url(../images/icon.png);background-position:0 -167px;background-repeat:no-repeat;float:right;margin-right:10px;cursor:pointer}#tianai-captcha-parent .slider-bottom .logo{height:30px;float:left}#tianai-captcha-parent .slider-move-shadow{animation:myanimation 2s infinite;height:100%;width:5px;background-color:#fff;position:absolute;top:0;left:0;filter:opacity(0.5);box-shadow:1px 1px 1px #fff;border-radius:50%}#tianai-captcha-parent #tianai-captcha-slider-move-track-mask{border-width:1px;border-style:solid;border-color:#00f4ab;width:0;height:32px;background-color:#a9ffe5;opacity:.5;position:absolute;top:-1px;left:-1px;border-radius:5px}
#tianai-captcha{text-align:left;box-sizing:content-box;width:300px;height:260px;z-index:999}#tianai-captcha .slider-bottom .logo{height:30px}#tianai-captcha .slider-bottom{height:19px;width:100%}#tianai-captcha .content .tianai-captcha-tips{height:25px;width:100%;position:absolute;bottom:-25px;left:0;z-index:999;font-size:15px;line-height:25px;color:#fff;text-align:center;transition:bottom .3s ease-in-out}#tianai-captcha .content .tianai-captcha-tips.tianai-captcha-tips-error{background-color:#ff5d39}#tianai-captcha .content .tianai-captcha-tips.tianai-captcha-tips-success{background-color:#39c522}#tianai-captcha .content .tianai-captcha-tips.tianai-captcha-tips-on{bottom:0}#tianai-captcha .content #tianai-captcha-loading{z-index:9999;background-color:#f5f5f5;text-align:center;height:100%;overflow:hidden;position:relative;display:flex;justify-content:center;align-items:center}#tianai-captcha .content #tianai-captcha-loading img{display:block;width:45px;height:45px}#tianai-captcha #tianai-captcha-slider-bg-canvas{position:absolute;left:0;top:0;width:100%;height:100%;border-radius:5px}#tianai-captcha #tianai-captcha-slider-bg-div{position:absolute;left:0;top:0;width:100%;height:100%;border-radius:5px}#tianai-captcha #tianai-captcha-slider-bg-div .tianai-captcha-slider-bg-div-slice{position:absolute}@keyframes myanimation{from{left:0}to{left:289px}}
#tianai-captcha.tianai-captcha-slider{z-index:999;position:absolute;left:0;top:0;user-select:none}#tianai-captcha.tianai-captcha-slider .content{width:100%;height:180px;position:relative;overflow:hidden}#tianai-captcha.tianai-captcha-slider .bg-img-div{width:100%;height:100%;position:absolute;transform:translate(0px, 0px)}#tianai-captcha.tianai-captcha-slider .bg-img-div img{height:100%;width:100%;border-radius:5px}#tianai-captcha.tianai-captcha-slider .slider-img-div{height:100%;position:absolute;left:0;transform:translate(0px, 0px)}#tianai-captcha.tianai-captcha-slider .slider-img-div #tianai-captcha-slider-move-img{height:100%}#tianai-captcha.tianai-captcha-slider .slider-move{height:34px;width:100%;margin:11px 0;position:relative}#tianai-captcha.tianai-captcha-slider .slider-move-track{position:relative;height:32px;line-height:32px;text-align:center;background:#f5f5f5;color:#999;transition:0s;font-size:14px;box-sizing:content-box;border:1px solid #f5f5f5;border-radius:4px}#tianai-captcha.tianai-captcha-slider .refresh-btn,#tianai-captcha.tianai-captcha-slider .close-btn{display:inline-block}#tianai-captcha.tianai-captcha-slider .slider-move{line-height:38px;font-size:14px;text-align:center;white-space:nowrap;color:#88949d;-moz-user-select:none;-webkit-user-select:none;user-select:none;filter:opacity(0.8)}#tianai-captcha.tianai-captcha-slider .slider-move .slider-move-btn{transform:translate(0px, 0px);position:absolute;top:0;left:0;width:55px;height:100%;background-repeat:no-repeat;background-size:cover;background-position: center;border-radius:5px}#tianai-captcha.tianai-captcha-slider .slider-tip{margin-bottom:5px;font-weight:bold;font-size:15px;line-height:normal;color:#000}#tianai-captcha.tianai-captcha-slider .slider-move-btn:hover{cursor:move}
#tianai-captcha.tianai-captcha-rotate .rotate-img-div{height:100%;text-align:center}#tianai-captcha.tianai-captcha-rotate .rotate-img-div img{height:100%;transform:rotate(0deg);display:inline-block}
#tianai-captcha.tianai-captcha-concat .tianai-captcha-slider-concat-img-div{background-size:100% 180px;position:absolute;transform:translate(0px, 0px);z-index:1;width:100%}#tianai-captcha.tianai-captcha-concat .tianai-captcha-slider-concat-bg-img{width:100%;height:100%;position:absolute;transform:translate(0px, 0px);background-size:100% 180px}
#tianai-captcha.tianai-captcha-disable{z-index:999;position:absolute;left:0;top:0}#tianai-captcha.tianai-captcha-disable .content{width:100%;height:180px;position:relative;overflow:hidden}#tianai-captcha.tianai-captcha-disable .content .bg-img-div{background-image:url(../images/dun.jpeg);width:100%;height:100%;overflow:hidden}#tianai-captcha.tianai-captcha-disable .content .bg-img-div #content-span{color:#fff;overflow:hidden;margin-top:132px;display:block;text-align:center}
#tianai-captcha.tianai-captcha-word-click{box-sizing:border-box}#tianai-captcha.tianai-captcha-word-click .click-tip{position:relative;height:40px;width:100%}#tianai-captcha.tianai-captcha-word-click .click-tip .tip-img{height:35px;position:absolute;right:15px}#tianai-captcha.tianai-captcha-word-click .click-tip #tianai-captcha-click-track-font{font-size:18px;display:inline-block;height:40px;line-height:40px;position:absolute}#tianai-captcha.tianai-captcha-word-click .slider-bottom{position:relative;top:6px}#tianai-captcha.tianai-captcha-word-click .content #bg-img-click-mask{width:100%;height:100%;position:absolute;left:0;top:0}#tianai-captcha.tianai-captcha-word-click .content #bg-img-click-mask .click-span{position:absolute;left:0;top:0;border-radius:50px;background-color:#409eff;width:20px;height:20px;text-align:center;line-height:20px;color:#fff;border:2px solid #fff;box-sizing:content-box}#tianai-captcha.tianai-captcha-word-click .click-confirm-btn{width:100%;height:35px;border-radius:4px;background-image:linear-gradient(173deg, hsl(38.09, 91%, 57.89%) 0%, hsl(38.09, 89.38%, 71.74%) 100%);font-size:15px;text-align:center;box-sizing:border-box;line-height:35px;color:#fff;margin-top:3px}#tianai-captcha.tianai-captcha-word-click .click-confirm-btn:hover{cursor:pointer}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,331 @@
/**
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
* 可用于 easyflow-form、easyflow-modal、easyflow-drawer 等组件使用,
*/
import type { Component } from 'vue';
import type { BaseFormComponentType } from '@easyflow/common-ui';
import type { Recordable } from '@easyflow/types';
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@easyflow/common-ui';
import { $t } from '@easyflow/locales';
import { ElNotification } from 'element-plus';
const ElButton = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/button/index'),
import('element-plus/es/components/button/style/css'),
]).then(([res]) => res.ElButton),
);
const ElCheckbox = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/checkbox/index'),
import('element-plus/es/components/checkbox/style/css'),
]).then(([res]) => res.ElCheckbox),
);
const ElCheckboxButton = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/checkbox/index'),
import('element-plus/es/components/checkbox-button/style/css'),
]).then(([res]) => res.ElCheckboxButton),
);
const ElCheckboxGroup = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/checkbox/index'),
import('element-plus/es/components/checkbox-group/style/css'),
]).then(([res]) => res.ElCheckboxGroup),
);
const ElDatePicker = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/date-picker/index'),
import('element-plus/es/components/date-picker/style/css'),
]).then(([res]) => res.ElDatePicker),
);
const ElDivider = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/divider/index'),
import('element-plus/es/components/divider/style/css'),
]).then(([res]) => res.ElDivider),
);
const ElInput = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/input/index'),
import('element-plus/es/components/input/style/css'),
]).then(([res]) => res.ElInput),
);
const ElInputNumber = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/input-number/index'),
import('element-plus/es/components/input-number/style/css'),
]).then(([res]) => res.ElInputNumber),
);
const ElRadio = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/radio/index'),
import('element-plus/es/components/radio/style/css'),
]).then(([res]) => res.ElRadio),
);
const ElRadioButton = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/radio/index'),
import('element-plus/es/components/radio-button/style/css'),
]).then(([res]) => res.ElRadioButton),
);
const ElRadioGroup = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/radio/index'),
import('element-plus/es/components/radio-group/style/css'),
]).then(([res]) => res.ElRadioGroup),
);
const ElSelectV2 = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/select-v2/index'),
import('element-plus/es/components/select-v2/style/css'),
]).then(([res]) => res.ElSelectV2),
);
const ElSpace = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/space/index'),
import('element-plus/es/components/space/style/css'),
]).then(([res]) => res.ElSpace),
);
const ElSwitch = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/switch/index'),
import('element-plus/es/components/switch/style/css'),
]).then(([res]) => res.ElSwitch),
);
const ElTimePicker = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/time-picker/index'),
import('element-plus/es/components/time-picker/style/css'),
]).then(([res]) => res.ElTimePicker),
);
const ElTreeSelect = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/tree-select/index'),
import('element-plus/es/components/tree-select/style/css'),
]).then(([res]) => res.ElTreeSelect),
);
const ElUpload = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/upload/index'),
import('element-plus/es/components/upload/style/css'),
]).then(([res]) => res.ElUpload),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
return defineComponent({
name: component.name,
inheritAttrs: false,
setup: (props: any, { attrs, expose, slots }) => {
const placeholder =
props?.placeholder ||
attrs?.placeholder ||
$t(`ui.placeholder.${type}`);
// 透传组件暴露的方法
const innerRef = ref();
expose(
new Proxy(
{},
{
get: (_target, key) => innerRef.value?.[key],
has: (_target, key) => key in (innerRef.value || {}),
},
),
);
return () =>
h(
component,
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
slots,
);
},
});
};
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType =
| 'ApiSelect'
| 'ApiTreeSelect'
| 'Checkbox'
| 'CheckboxGroup'
| 'DatePicker'
| 'Divider'
| 'IconPicker'
| 'Input'
| 'InputNumber'
| 'RadioGroup'
| 'Select'
| 'Space'
| 'Switch'
| 'TimePicker'
| 'TreeSelect'
| 'Upload'
| BaseFormComponentType;
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载
// Button: () =>
// import('xxx').then((res) => res.Button),
ApiSelect: withDefaultPlaceholder(
{
...ApiComponent,
name: 'ApiSelect',
},
'select',
{
component: ElSelectV2,
loadingSlot: 'loading',
visibleEvent: 'onVisibleChange',
},
),
ApiTreeSelect: withDefaultPlaceholder(
{
...ApiComponent,
name: 'ApiTreeSelect',
},
'select',
{
component: ElTreeSelect,
props: { label: 'label', children: 'children' },
nodeKey: 'value',
loadingSlot: 'loading',
optionsPropName: 'data',
visibleEvent: 'onVisibleChange',
},
),
Checkbox: ElCheckbox,
CheckboxGroup: (props, { attrs, slots }) => {
let defaultSlot;
if (Reflect.has(slots, 'default')) {
defaultSlot = slots.default;
} else {
const { options, isButton } = attrs;
if (Array.isArray(options)) {
defaultSlot = () =>
options.map((option) =>
h(isButton ? ElCheckboxButton : ElCheckbox, option),
);
}
}
return h(
ElCheckboxGroup,
{ ...props, ...attrs },
{ ...slots, default: defaultSlot },
);
},
// 自定义默认按钮
DefaultButton: (props, { attrs, slots }) => {
return h(ElButton, { ...props, attrs, type: 'info' }, slots);
},
// 自定义主要按钮
PrimaryButton: (props, { attrs, slots }) => {
return h(ElButton, { ...props, attrs, type: 'primary' }, slots);
},
Divider: ElDivider,
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
iconSlot: 'append',
modelValueProp: 'model-value',
inputComponent: ElInput,
}),
Input: withDefaultPlaceholder(ElInput, 'input'),
InputNumber: withDefaultPlaceholder(ElInputNumber, 'input'),
RadioGroup: (props, { attrs, slots }) => {
let defaultSlot;
if (Reflect.has(slots, 'default')) {
defaultSlot = slots.default;
} else {
const { options } = attrs;
if (Array.isArray(options)) {
defaultSlot = () =>
options.map((option) =>
h(attrs.isButton ? ElRadioButton : ElRadio, option),
);
}
}
return h(
ElRadioGroup,
{ ...props, ...attrs },
{ ...slots, default: defaultSlot },
);
},
Select: (props, { attrs, slots }) => {
return h(ElSelectV2, { ...props, attrs }, slots);
},
Space: ElSpace,
Switch: ElSwitch,
TimePicker: (props, { attrs, slots }) => {
const { name, id, isRange } = props;
const extraProps: Recordable<any> = {};
if (isRange) {
if (name && !Array.isArray(name)) {
extraProps.name = [name, `${name}_end`];
}
if (id && !Array.isArray(id)) {
extraProps.id = [id, `${id}_end`];
}
}
return h(
ElTimePicker,
{
...props,
...attrs,
...extraProps,
},
slots,
);
},
DatePicker: (props, { attrs, slots }) => {
const { name, id, type } = props;
const extraProps: Recordable<any> = {};
if (type && type.includes('range')) {
if (name && !Array.isArray(name)) {
extraProps.name = [name, `${name}_end`];
}
if (id && !Array.isArray(id)) {
extraProps.id = [id, `${id}_end`];
}
}
return h(
ElDatePicker,
{
...props,
...attrs,
...extraProps,
},
slots,
);
},
TreeSelect: withDefaultPlaceholder(ElTreeSelect, 'select'),
Upload: ElUpload,
};
// 将组件注册到全局共享状态中
globalShareState.setComponents(components);
// 定义全局共享状态中的消息提示
globalShareState.defineMessage({
// 复制成功消息提示
copyPreferencesSuccess: (title, content) => {
ElNotification({
title,
message: content,
position: 'bottom-right',
duration: 0,
type: 'success',
});
},
});
}
export { initComponentAdapter };

View File

@@ -0,0 +1,41 @@
import type {
EasyFlowFormSchema as FormSchema,
EasyFlowFormProps,
} from '@easyflow/common-ui';
import type { ComponentType } from './component';
import { setupEasyFlowForm, useEasyFlowForm as useForm, z } from '@easyflow/common-ui';
import { $t } from '@easyflow/locales';
async function initSetupEasyFlowForm() {
setupEasyFlowForm<ComponentType>({
config: {
modelPropNameMap: {
Upload: 'fileList',
CheckboxGroup: 'model-value',
},
},
defineRules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
},
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
});
}
const useEasyFlowForm = useForm<ComponentType>;
export { initSetupEasyFlowForm, useEasyFlowForm, z };
export type EasyFlowFormSchema = FormSchema<ComponentType>;
export type { EasyFlowFormProps };

View File

@@ -0,0 +1,70 @@
import type { VxeTableGridOptions } from '@easyflow/plugins/vxe-table';
import { h } from 'vue';
import { setupEasyFlowVxeTable, useEasyFlowVxeGrid } from '@easyflow/plugins/vxe-table';
import { ElButton, ElImage } from 'element-plus';
import { useEasyFlowForm } from './form';
setupEasyFlowVxeTable({
configVxeTable: (vxeUI) => {
vxeUI.setConfig({
grid: {
align: 'center',
border: false,
columnConfig: {
resizable: true,
},
minHeight: 180,
formConfig: {
// 全局禁用vxe-table的表单配置使用formOptions
enabled: false,
},
proxyConfig: {
autoLoad: true,
response: {
result: 'items',
total: 'total',
list: 'items',
},
showActiveMsg: true,
showResponseMsg: false,
},
round: true,
showOverflow: true,
size: 'small',
} as VxeTableGridOptions,
});
// 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) {
const { column, row } = params;
const src = row[column.field];
return h(ElImage, { src, previewSrcList: [src] });
},
});
// 表格配置项可以用 cellRender: { name: 'CellLink' },
vxeUI.renderer.add('CellLink', {
renderTableDefault(renderOpts) {
const { props } = renderOpts;
return h(
ElButton,
{ size: 'small', link: true },
{ default: () => props?.text },
);
},
});
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
// vxeUI.formats.add
},
useEasyFlowForm,
});
export { useEasyFlowVxeGrid };
export type * from '@easyflow/plugins/vxe-table';

View File

@@ -0,0 +1,138 @@
import type {
AiLlm,
BotInfo,
ChatMessage,
RequestResult,
Session,
} from '@easyflow/types';
import { api } from '#/api/request.js';
/** 获取bot详情 */
export const getBotDetails = (id: string) => {
return api.get<RequestResult<BotInfo>>('/api/v1/bot/getDetail', {
params: { id },
});
};
export interface GetSessionListParams {
botId: string;
tempUserId: string;
}
/** 获取bot对话列表 */
export const getSessionList = (params: GetSessionListParams) => {
return api.get<RequestResult<{ cons: Session[] }>>(
'/api/v1/conversation/externalList',
{ params },
);
};
export interface SaveBotParams {
icon: string;
title: string;
alias: string;
description: string;
categoryId: any;
status: number;
}
/** 创建Bot */
export const saveBot = (params: SaveBotParams) => {
return api.post<RequestResult>('/api/v1/bot/save', { ...params });
};
export interface UpdateBotParams extends SaveBotParams {
id: string;
}
/** 修改Bot */
export const updateBotApi = (params: UpdateBotParams) => {
return api.post<RequestResult>('/api/v1/bot/update', { ...params });
};
/** 删除Bot */
export const removeBotFromId = (id: string) => {
return api.post<RequestResult>('/api/v1/bot/remove', { id });
};
export interface GetMessageListParams {
conversationId: string;
botId: string;
tempUserId: string;
}
/** 获取单个对话的信息列表 */
export const getMessageList = (params: GetMessageListParams) => {
return api.get<RequestResult<ChatMessage[]>>(
'/api/v1/botMessage/messageList',
{
params,
},
);
};
/** 更新Bot的LLM配置 */
export interface UpdateLlmOptionsParams {
id: string;
llmOptions: {
[key: string]: any;
};
}
export interface UpdateBotOptionsParams {
id: string;
options: {
[key: string]: any;
};
}
export const updateLlmOptions = (params: UpdateLlmOptionsParams) => {
return api.post<RequestResult>('/api/v1/bot/updateLlmOptions', {
...params,
});
};
export const updateBotOptions = (params: UpdateBotOptionsParams) => {
return api.post<RequestResult>('/api/v1/bot/updateOptions', {
...params,
});
};
/** 更新Bot的LLM配置 */
export interface GetAiLlmListParams {
[key: string]: any;
}
export const getAiLlmList = (params: GetAiLlmListParams) => {
return api.get<RequestResult<AiLlm[]>>('/api/v1/model/list', {
params,
});
};
/** 更新modelId */
export interface UpdateLlmIdParams {
id: string;
modelId: string;
}
export const updateLlmId = (params: UpdateLlmIdParams) => {
return api.post<RequestResult>('/api/v1/bot/updateLlmId', {
...params,
});
};
export const doPostBotPluginTools = (botId: string) => {
return api.post<RequestResult<any[]>>('/api/v1/pluginItem/tool/list', {
id: botId,
});
};
export const getPerQuestions = (presetQuestions: any[]) => {
if (!presetQuestions) {
return [];
}
return presetQuestions
.filter((item: any) => {
return (
typeof item.description === 'string' && item.description.trim() !== ''
);
})
.map((item: any) => ({
key: item.key,
description: item.description,
}));
};

View File

@@ -0,0 +1,2 @@
export * from './bot';
export * from './llm';

View File

@@ -0,0 +1,44 @@
import { api } from '#/api/request.js';
// 获取LLM供应商
export async function getLlmProviderList() {
return api.get('/api/v1/modelProvider/list');
}
// 保存LLM
export async function saveLlm(data: string) {
return api.post('/api/v1/model/save', data);
}
// 删除LLM
export async function deleteLlm(data: any) {
return api.post(`/api/v1/model/remove`, data);
}
// 修改LLM
export async function updateLlm(data: any) {
return api.post(`/api/v1/model/update`, data);
}
// 一键添加LLM
export async function quickAddLlm(data: any) {
return api.post(`/api/v1/model/quickAdd`, data);
}
export interface llmType {
id: string;
title: string;
modelProvider: {
icon: string;
providerName: string;
providerType: string;
};
withUsed: boolean;
llmModel: string;
icon: string;
description: string;
modelType: string;
groupName: string;
added: boolean;
aiLlmProvider: any;
}

View File

@@ -0,0 +1,27 @@
/**
* 格式化文件大小(字节转 B/KB/MB/GB/TB
* @param bytes - 文件大小(单位:字节 Byte
* @param decimalPlaces - 保留小数位数(默认 2 位)
* @returns 格式化后的大小字符串1.23 MB、456 B、7.8 GB
*/
export function formatFileSize(
bytes: number,
decimalPlaces: number = 2,
): string {
// 处理特殊情况bytes 为 0 或非数字
if (Number.isNaN(bytes) || bytes < 0) return '0 B';
if (bytes === 0) return '0 B';
// 单位数组(从 Byte 到 TB
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
// 计算合适的单位索引1 KB = 1024 B每次除以 1024 切换单位)
const unitIndex = Math.floor(Math.log(bytes) / Math.log(1024));
// 计算对应单位的大小(保留指定小数位)
const formattedSize = (bytes / 1024 ** unitIndex).toFixed(decimalPlaces);
// 移除末尾多余的 .00(如 2.00 MB → 2 MB1.50 KB → 1.5 KB
const sizeWithoutTrailingZeros = Number.parseFloat(formattedSize).toString();
// 返回格式化结果(单位与大小拼接)
return `${sizeWithoutTrailingZeros} ${units[unitIndex]}`;
}

View File

@@ -0,0 +1,9 @@
import { useAccessStore } from '@easyflow/stores';
export function hasPermission(codes: string[]) {
const accessStore = useAccessStore();
const userCodesSet = new Set(accessStore.accessCodes);
const intersection = codes.filter((item) => userCodesSet.has(item));
return intersection.length > 0;
}

View File

@@ -0,0 +1,52 @@
import { baseRequestClient, requestClient } from '#/api/request';
export namespace AuthApi {
/** 登录接口参数 */
export interface LoginParams {
password?: string;
username?: string;
}
/** 登录接口返回值 */
export interface LoginResult {
accessToken: string;
token: string;
}
export interface RefreshTokenResult {
data: string;
status: number;
}
}
/**
* 登录
*/
export async function loginApi(data: AuthApi.LoginParams) {
return requestClient.post<AuthApi.LoginResult>('/api/v1/auth/login', data);
}
/**
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
}
/**
* 退出登录
*/
export async function logoutApi() {
return requestClient.post('/api/v1/auth/logout', {
withCredentials: true,
});
}
/**
* 获取用户权限码
*/
export async function getAccessCodesApi() {
return requestClient.get<string[]>('/api/v1/auth/getPermissions');
}

View File

@@ -0,0 +1,3 @@
export * from './auth';
export * from './menu';
export * from './user';

View File

@@ -0,0 +1,12 @@
import type { RouteRecordStringComponent } from '@easyflow/types';
import { requestClient } from '#/api/request';
/**
* 获取用户所有菜单
*/
export async function getAllMenusApi() {
return requestClient.get<RouteRecordStringComponent[]>(
'/api/v1/sysMenu/treeV2',
);
}

View File

@@ -0,0 +1,10 @@
import type { UserInfo } from '@easyflow/types';
import { requestClient } from '#/api/request';
/**
* 获取用户信息
*/
export async function getUserInfoApi() {
return requestClient.get<UserInfo>('/api/v1/sysAccount/myProfile');
}

View File

@@ -0,0 +1,2 @@
export * from './ai';
export * from './core';

View File

@@ -0,0 +1,221 @@
import type { ServerSentEventMessage } from 'fetch-event-stream';
/**
* 该文件可自行根据业务逻辑进行调整
*/
import type { RequestClientOptions } from '@easyflow/request';
import { useAppConfig } from '@easyflow/hooks';
import { preferences } from '@easyflow/preferences';
import {
authenticateResponseInterceptor,
defaultResponseInterceptor,
errorMessageResponseInterceptor,
RequestClient,
} from '@easyflow/request';
import { useAccessStore } from '@easyflow/stores';
import { ElMessage } from 'element-plus';
import { events } from 'fetch-event-stream';
import { useAuthStore } from '#/store';
import { refreshTokenApi } from './core';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
const client = new RequestClient({
...options,
baseURL,
});
/**
* 重新认证逻辑
*/
async function doReAuthenticate() {
console.warn('Access token or refresh token is invalid or expired. ');
const accessStore = useAccessStore();
const authStore = useAuthStore();
accessStore.setAccessToken(null);
if (
preferences.app.loginExpiredMode === 'modal' &&
accessStore.isAccessChecked
) {
accessStore.setLoginExpired(true);
} else {
await authStore.logout();
}
}
/**
* 刷新token逻辑
*/
async function doRefreshToken() {
const accessStore = useAccessStore();
const resp = await refreshTokenApi();
const newToken = resp.data;
accessStore.setAccessToken(newToken);
return newToken;
}
function formatToken(token: null | string) {
return token ? `${token}` : null;
}
// 请求头处理
client.addRequestInterceptor({
fulfilled: async (config) => {
const accessStore = useAccessStore();
config.headers['easyflow-token'] = formatToken(accessStore.accessToken);
config.headers['Accept-Language'] = preferences.app.locale;
return config;
},
});
// 处理返回的响应数据格式
client.addResponseInterceptor(
defaultResponseInterceptor({
codeField: 'errorCode',
dataField: 'data',
showErrorMessage: (message) => {
ElMessage.error(message);
},
successCode: 0,
}),
);
// token过期的处理
client.addResponseInterceptor(
authenticateResponseInterceptor({
client,
doReAuthenticate,
doRefreshToken,
enableRefreshToken: preferences.app.enableRefreshToken,
formatToken,
}),
);
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
client.addResponseInterceptor(
errorMessageResponseInterceptor((msg: string, error) => {
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
// 当前mock接口返回的错误字段是 error 或者 message
const responseData = error?.response?.data ?? {};
const errorMessage = responseData?.error ?? responseData?.message ?? '';
// 如果没有错误信息,则会根据状态码进行提示
ElMessage.error(errorMessage || msg);
}),
);
return client;
}
export const requestClient = createRequestClient(apiURL, {
responseReturn: 'data',
});
export const api = createRequestClient(apiURL, {
responseReturn: 'body',
});
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
export interface SseOptions {
onMessage?: (message: ServerSentEventMessage) => void;
onError?: (err: any) => void;
onFinished?: () => void;
}
export class SseClient {
private controller: AbortController | null = null;
private currentRequestId = 0;
abort(): void {
if (this.controller) {
this.controller.abort();
this.controller = null;
}
}
isActive(): boolean {
return this.controller !== null;
}
async post(url: string, data?: any, options?: SseOptions): Promise<void> {
// 生成唯一的请求ID
const requestId = ++this.currentRequestId;
const currentRequestId = requestId;
// 如果已有请求,先取消
this.abort();
// 创建新的控制器
const controller = new AbortController();
this.controller = controller;
// 保存信号的引用到局部变量
const signal = controller.signal;
try {
const res = await fetch(apiURL + url, {
method: 'POST',
signal, // 使用局部变量 signal
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const error = new Error(`HTTP ${res.status}: ${res.statusText}`);
options?.onError?.(error);
return;
}
// 在开始事件流之前检查是否还是同一个请求
if (this.currentRequestId !== currentRequestId) {
return;
}
const msgEvents = events(res, signal);
try {
for await (const event of msgEvents) {
// 每次迭代都检查是否还是同一个请求
if (this.currentRequestId !== currentRequestId) {
break;
}
options?.onMessage?.(event);
}
} catch (innerError) {
options?.onError?.(innerError);
}
// 只有在还是同一个请求的情况下才调用 onFinished
if (this.currentRequestId === currentRequestId) {
options?.onFinished?.();
}
} catch (error) {
if (this.currentRequestId !== currentRequestId) {
return;
}
console.error('SSE错误:', error);
options?.onError?.(error);
} finally {
// 只有当还是当前请求时才清除 controller
if (this.currentRequestId === currentRequestId) {
this.controller = null;
}
}
}
private getHeaders() {
const accessStore = useAccessStore();
return {
Accept: 'text/event-stream',
'Content-Type': 'application/json',
'easyflow-token': accessStore.accessToken || '',
};
}
}
export const sseClient = new SseClient();

View File

@@ -0,0 +1,17 @@
<script lang="ts" setup>
import { useElementPlusDesignTokens } from '@easyflow/hooks';
import { ElConfigProvider } from 'element-plus';
import { elementLocale } from '#/locales';
defineOptions({ name: 'App' });
useElementPlusDesignTokens();
</script>
<template>
<ElConfigProvider :locale="elementLocale">
<RouterView />
</ElConfigProvider>
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="36px" height="36px" viewBox="0 0 36 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>编组备份</title>
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="知识库-数据集" transform="translate(-264, -116)">
<g id="编组备份" transform="translate(264, 116)">
<path d="M18,0 C27.9411255,-1.82615513e-15 36,8.0588745 36,18 C36,27.9411255 27.9411255,36 18,36 C8.0588745,36 1.21743675e-15,27.9411255 0,18 C-1.21743675e-15,8.0588745 8.0588745,1.82615513e-15 18,0 Z" id="Fill-335" fill="#EAF1FF"></path>
<g id="编组-25" transform="translate(7.5, 9)">
<rect id="矩形" x="0" y="0" width="21" height="21"></rect>
<path d="M9.45,19.95 L11.8384873,5.86086586 C11.8384873,5.86086586 14.402169,2.48291329 19.4944931,3.26848327 C19.7572846,3.31078357 19.95,3.54645402 19.95,3.81838281 L19.1090644,14.8437913 C18.9630681,16.1490466 18.1922128,17.2125873 16.9658506,17.3878294 C15.1146272,17.6537151 10.9041156,17.4603461 9.45,19.95 Z" id="Fill-265" fill="#0066FF"></path>
<path d="M9.40161931,19.95 L9.45,4.78125568 C9.45,4.78125568 6.79513951,1.44019973 1.52170595,2.2171891 C1.24956828,2.25902629 1.05,2.49212364 1.05,2.76108221 L1.05,13.8835004 C1.05,15.1505916 1.99945962,16.2264248 3.26943869,16.3997538 C5.19254882,16.6687113 7.89578781,17.4935171 9.40161931,19.95 Z" id="Fill-266" fill="#0066FF"></path>
<path d="M9.49837959,19.95 L9.45,4.78125568 C9.45,4.78125568 12.1048605,1.44019973 17.3782941,2.2171891 C17.6504317,2.25902629 17.85,2.49212364 17.85,2.76108221 L17.85,13.8835004 C17.85,15.1505916 16.9005382,16.2264248 15.6305602,16.3997538 C13.7074501,16.6687113 11.0042111,17.4935171 9.49837959,19.95 Z" id="Fill-267" fill="#8AB9FF"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>编组 9</title>
<defs>
<linearGradient x1="50%" y1="100%" x2="50%" y2="0%" id="linearGradient-1">
<stop stop-color="#009FFF" offset="0%"></stop>
<stop stop-color="#0066FF" offset="100%"></stop>
</linearGradient>
</defs>
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="知识库-数据集" transform="translate(-482, -323)">
<g id="编组-34备份-3" transform="translate(458, 313)">
<g id="编组-9" transform="translate(24, 10)">
<path d="M2.88,0 L21.12,0 C22.7105801,1.56462329e-15 24,1.28941992 24,2.88 L24,21.12 C24,22.7105801 22.7105801,24 21.12,24 L2.88,24 C1.28941992,24 2.75795744e-15,22.7105801 0,21.12 L0,2.88 C2.4929933e-16,1.28941992 1.28941992,7.3627403e-16 2.88,0 Z" id="矩形" fill="#E3F3FF"></path>
<g id="编组-33" transform="translate(4.32, 4.2337)">
<path d="M3.08240956,0.809720058 L12.2915386,0.806869334 C13.2458864,0.806039085 14.0197778,1.57945149 14.0200733,2.53379947 C14.0200733,2.53397777 14.0200733,2.53415608 14.0195385,2.53433438 L14.0195385,4.91283382 L14.0195385,4.91283382 L14.0137691,12.9913951 C14.0118457,13.9490909 13.2352873,14.725095 12.2775904,14.7263344 L3.08240965,14.7263344 C2.12422887,14.7263344 1.34746988,13.9495754 1.34746988,12.9913947 L1.34746988,2.54519688 C1.34800702,1.58722583 2.12443873,0.810553743 3.08240956,0.809720058 Z" id="矩形" fill="url(#linearGradient-1)"></path>
<path d="M2.29973554,0.531039382 L9.00664031,0.523209269 L9.00664031,0.523209269 L13.1748628,4.5511648 L13.1442992,12.4704581 C13.1406418,13.4181231 12.3717348,14.1846126 11.424063,14.1852859 L2.29565016,14.1917712 C1.34490763,14.1924466 0.573630977,13.4222651 0.572955526,12.4715226 C0.572954944,12.4707039 0.572954946,12.4698853 0.572955533,12.4690666 L0.580273692,2.25127731 C0.580953724,1.30180091 1.35025954,0.532147867 2.29973554,0.531039382 Z" id="矩形" fill-opacity="0.232025787" fill="#FFFFFF" transform="translate(6.8733, 7.3581) rotate(5) translate(-6.8733, -7.3581)"></path>
<path d="M9.41174744,0.884008985 L9.40983026,3.56082175 C9.40928551,4.32141576 10.0254272,4.93844065 10.7860212,4.9389854 C10.7866761,4.93898587 10.787331,4.93898587 10.787986,4.9389854 L13.579952,4.93700186 L13.579952,4.93700186 L9.41174744,0.884008985 Z" id="路径-6" fill-opacity="0.232025787" fill="#FFFFFF" transform="translate(11.4944, 2.912) rotate(5) translate(-11.4944, -2.912)"></path>
<rect id="矩形" fill="#FFFFFF" transform="translate(6.12, 5.8629) rotate(6) translate(-6.12, -5.8629)" x="3.6" y="5.52" width="5.04" height="1" rx="0.346987953"></rect>
<rect id="矩形备份-7" fill="#FFFFFF" transform="translate(4.5745, 7.7829) rotate(6) translate(-4.5745, -7.7829)" x="3.36" y="7.44" width="2.42891566" height="1" rx="0.346987953"></rect>
<rect id="矩形备份-9" fill="#FFFFFF" transform="translate(5.16, 9.7029) rotate(6) translate(-5.16, -9.7029)" x="3.12" y="9.36" width="4.08" height="1" rx="0.346987953"></rect>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,92 @@
import { createApp, watchEffect } from 'vue';
import {
BubbleList,
Conversations,
Sender,
Thinking,
XMarkdown,
} from 'vue-element-plus-x';
import { registerAccessDirective } from '@easyflow/access';
import { registerLoadingDirective } from '@easyflow/common-ui';
import { preferences } from '@easyflow/preferences';
import { initStores } from '@easyflow/stores';
import '@easyflow/styles';
import '@easyflow/styles/ele';
import { useTitle } from '@vueuse/core';
import { ElLoading } from 'element-plus';
import { $t, setupI18n } from '#/locales';
import { initComponentAdapter } from './adapter/component';
import { initSetupEasyFlowForm } from './adapter/form';
import App from './app.vue';
import { router } from './router';
async function bootstrap(namespace: string) {
// 初始化组件适配器
await initComponentAdapter();
// 初始化表单组件
await initSetupEasyFlowForm();
// // 设置弹窗的默认配置
// setDefaultModalProps({
// fullscreenButton: false,
// });
// // 设置抽屉的默认配置
// setDefaultDrawerProps({
// zIndex: 2000,
// });
const app = createApp(App);
// 注册Element Plus提供的v-loading指令
app.directive('loading', ElLoading.directive);
app.component('ElBubbleList', BubbleList);
app.component('ElConversations', Conversations);
app.component('ElSender', Sender);
app.component('ElXMarkdown', XMarkdown);
app.component('ElThinking', Thinking);
// 注册EasyFlow提供的v-loading和v-spinning指令
registerLoadingDirective(app, {
loading: false, // EasyFlow提供的v-loading指令和Element Plus提供的v-loading指令二选一即可此处false表示不注册EasyFlow提供的v-loading指令
spinning: 'spinning',
});
// 国际化 i18n 配置
await setupI18n(app);
// 配置 pinia-tore
await initStores(app, { namespace });
// 安装权限指令
registerAccessDirective(app);
// 初始化 tippy
const { initTippy } = await import('@easyflow/common-ui/es/tippy');
initTippy(app);
// 配置路由及路由守卫
app.use(router);
// 配置Motion插件
const { MotionPlugin } = await import('@easyflow/plugins/motion');
app.use(MotionPlugin);
// 动态更新标题
watchEffect(() => {
if (preferences.app.dynamicTitle) {
const routeTitle = router.currentRoute.value.meta?.title;
const pageTitle =
(routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name;
useTitle(pageTitle);
}
});
app.mount('#app');
}
export { bootstrap };

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import { ElAvatar } from 'element-plus';
import defaultBotAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
const props = defineProps<{
size?: number;
src?: string;
}>();
</script>
<template>
<ElAvatar :src="props.src ?? defaultBotAvatar" :size="props.size ?? 36" />
</template>

View File

@@ -0,0 +1,355 @@
<script setup>
import { computed } from 'vue';
import { preferences } from '@easyflow/preferences';
import { MoreFilled } from '@element-plus/icons-vue';
import {
ElAvatar,
ElButton,
ElCard,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElIcon,
} from 'element-plus';
import { hasPermission } from '#/api/common/hasPermission.ts';
const props = defineProps({
titleKey: {
type: String,
default: 'title',
},
avatarKey: {
type: String,
default: 'avatar',
},
/** 默认头像 */
defaultAvatar: {
type: String,
default: undefined,
},
descriptionKey: {
type: String,
default: 'description',
},
data: {
type: Array,
default: () => [],
},
// 操作按钮配置
actions: {
type: Array,
default: () => [],
},
});
// 定义组件事件
const emit = defineEmits(['actionClick']);
// 可见的操作按钮最多3个
const visibleActions = computed(() => {
return props.actions.slice(0, 3);
});
// 下拉菜单中的操作按钮
const dropdownActions = computed(() => {
return props.actions.slice(3);
});
// 处理操作按钮点击
const handleActionClick = (action, item) => {
emit('actionClick', { action, item });
};
const filteredActions = computed(() => {
return dropdownActions.value.filter((action) => {
return hasPermission([action.permission]);
});
});
</script>
<template>
<div class="card-list-container">
<div class="card-list">
<ElCard v-for="item in data" :key="item.id" class="card-item">
<div class="card-content">
<!-- 卡片头部头像和基本信息 -->
<div class="card-header">
<ElAvatar :src="item[avatarKey] ?? defaultAvatar" />
<div class="card-info">
<h3 class="card-title">{{ item[titleKey] }}</h3>
<p class="card-desc">{{ item[descriptionKey] }}</p>
</div>
</div>
<!-- 操作按钮区域 -->
<div class="card-actions">
<!-- 最多显示3个操作按钮 -->
<ElButton
v-for="(action, index) in visibleActions"
:key="index"
link
class="action-btn"
:icon="action.icon"
v-access:code="action.permission"
@click="handleActionClick(action, item)"
>
{{ action.label }}
<span
v-if="
index < visibleActions.length - 1 ||
(index === visibleActions.length - 1 &&
filteredActions.length > 0)
"
class="divider"
></span>
</ElButton>
<!-- 更多操作下拉菜单 -->
<ElDropdown
v-if="filteredActions.length > 0"
class="action-btn"
@command="(command) => handleActionClick(command, item)"
>
<ElIcon class="el-icon--right">
<MoreFilled />
</ElIcon>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="action in filteredActions"
:key="action.name"
:command="action"
:icon="action.icon"
:class="{
'delete-dropdown-item': action.name === 'delete',
}"
>
{{ action.label }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
</ElCard>
</div>
<div v-if="data.length === 0" class="empty-state">
<ElEmpty
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
/>
</div>
</div>
</template>
<style scoped>
.card-list-container {
width: 100%;
height: 100%;
}
.card-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
:deep(.el-card__body) {
padding: 20px 0 0 0;
}
.card-item {
transition: all 0.3s ease;
border-radius: 8px;
flex-shrink: 0;
height: 165px;
min-width: 0;
}
:deep(.delete-dropdown-item) {
color: #ff4d4f !important;
}
:deep(.delete-dropdown-item:hover) {
color: #ff7875 !important;
}
:deep(.delete-dropdown-item .el-icon) {
color: inherit !important;
}
.card-item:hover {
transform: translateY(-5px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.card-content {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--el-bg-color);
border-radius: 8px;
}
.card-header {
display: flex;
align-items: flex-start;
gap: 15px;
margin-bottom: 15px;
flex: 1;
padding: 0 20px 0 20px;
}
.card-info {
flex: 1;
min-width: 0;
max-width: 100%;
}
.card-title {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.4;
}
.card-desc {
margin: 0;
width: 100%;
font-size: 14px;
color: #606266;
line-height: 1.5;
height: 42px;
min-height: 42px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.card-actions {
display: flex;
align-items: center;
height: 48px;
background:
linear-gradient(
180deg,
var(--el-color-primary-light-9),
var(--el-bg-color)
),
var(--el-bg-color);
}
.action-btn {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
min-width: 0;
color: #acb7c6;
position: relative;
&:hover {
color: var(--el-color-primary);
}
}
.more-btn {
width: 100%;
justify-content: center;
}
.divider {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 1px;
height: 20px;
background-color: #e0e0e0;
}
/* 确保按钮内容不换行 */
.action-btn .el-button {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
justify-content: center;
}
.empty-state {
padding: 40px 0;
text-align: center;
}
/* 响应式设计 */
/* 超大屏幕 */
@media (min-width: 1920px) {
.card-list {
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 24px;
}
}
/* 大屏幕 */
@media (min-width: 1200px) and (max-width: 1919px) {
.card-list {
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
}
/* 中等屏幕 */
@media (min-width: 992px) and (max-width: 1199px) {
.card-list {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.card-header {
padding: 0 16px 0 16px;
gap: 12px;
}
.card-title {
font-size: 15px;
}
.card-desc {
font-size: 13px;
height: 36px;
min-height: 36px;
}
}
/* 小屏幕 - 平板 */
@media (min-width: 768px) and (max-width: 991px) {
.card-list {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.card-header {
padding: 0 12px 0 12px;
gap: 10px;
}
.card-title {
font-size: 14px;
margin-bottom: 6px;
}
.card-desc {
font-size: 12px;
height: 32px;
min-height: 32px;
-webkit-line-clamp: 2;
}
.card-actions {
height: 40px;
}
.action-btn .el-button {
font-size: 11px;
}
}
</style>

View File

@@ -0,0 +1,325 @@
<script setup lang="ts">
import { computed, onMounted, ref, toRefs } from 'vue';
import { $t } from '@easyflow/locales';
import { preferences } from '@easyflow/preferences';
import { Delete, Edit, MoreFilled, Plus } from '@element-plus/icons-vue';
import {
ElButton,
ElDialog,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElMessage,
ElMessageBox,
} from 'element-plus';
const props = defineProps({
title: {
type: String,
default: '数据管理',
},
categoryData: {
type: Array<Record<string, any>>,
default: () => [],
},
disabled: {
type: Boolean,
},
loading: {
type: Boolean,
},
titleKey: {
type: String,
default: 'title',
},
panelWidth: {
type: Number,
default: 200,
},
valueKey: {
type: String,
default: undefined,
},
defaultSelectedCategory: {
type: String,
default: null,
},
defaultFormData: {
type: Object,
default: () => ({}),
},
});
// 暴露给父组件的事件
const emit = defineEmits([
'add', // 新增提交时触发
'edit', // 编辑提交时触发
'delete', // 删除时触发
'click', // 点击时触发
]);
const finalValueKey = computed(() => {
// 父组件传递了 valueKey 就用 valueKey否则用 titleKey
return props.valueKey ?? props.titleKey;
});
const { defaultFormData } = toRefs(props);
// 状态管理
const dialogVisible = ref(false); // 弹窗显隐
const isEdit = ref(false); // 是否为编辑模式
const formData = ref({
...defaultFormData,
}); // 表单数据
/**
* 从 Proxy 对象提取原始数据
*/
const extractFromProxy = (proxyObj: any) => {
try {
if (proxyObj && typeof proxyObj === 'object') {
const raw = (proxyObj as any).__v_raw || proxyObj;
if (raw && typeof raw === 'object') {
const result: any = Array.isArray(raw) ? [] : {};
for (const key in raw) {
if (
key !== '__v_skip' &&
Object.prototype.hasOwnProperty.call(raw, key)
) {
result[key] = extractFromProxy(raw[key]);
}
}
return result;
}
return raw;
}
return proxyObj;
} catch (error) {
console.warn('提取 Proxy 对象失败,使用浅拷贝:', error);
return { ...proxyObj };
}
};
/**
* 新增按钮点击事件
*/
const handleAdd = () => {
isEdit.value = false;
formData.value = {
...defaultFormData,
};
dialogVisible.value = true;
};
/**
* 编辑按钮点击事件
* @param {object} row - 表格当前行数据
*/
const handleEdit = (row: any) => {
isEdit.value = true;
// 使用提取原始数据的方法
formData.value = extractFromProxy(row);
dialogVisible.value = true;
};
/**
* 删除按钮点击事件
* @param {object} row - 表格当前行数据
*/
const handleDelete = (row: any) => {
// 先提取原始数据
const rawData = extractFromProxy(row);
ElMessageBox.confirm(`此操作将永久删除该${props.title}, 是否继续?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
// 触发父组件删除事件,传递原始数据
emit('delete', rawData);
})
.catch(() => {
ElMessage({ type: 'info', message: '已取消删除' });
});
};
/**
* 表单提交事件
*/
const handleSubmit = () => {
// 触发对应事件,传递表单数据
if (isEdit.value) {
emit('edit', formData.value);
} else {
emit('add', formData.value);
}
// 提交后关闭弹窗
dialogVisible.value = false;
};
const selectedCategory = ref();
onMounted(() => {
// 初始化时,检查是否有默认选中的分类
if (props.defaultSelectedCategory) {
selectedCategory.value = props.defaultSelectedCategory;
}
});
const handleCategoryClick = (category: any) => {
// 选中值:用 finalValueKey父组件指定的字段
selectedCategory.value = category[finalValueKey.value];
emit('click', {
item: extractFromProxy(category),
value: category[finalValueKey.value], // 父组件指定字段的值
label: category[props.titleKey], // 分类名称
});
};
const handleEditClick = (event: any, item: any) => {
event.stopPropagation();
handleEdit(item);
};
const handleDeleteClick = (event: any, item: any) => {
event.stopPropagation();
handleDelete(item);
};
</script>
<template>
<div
class="flex h-full flex-col rounded-lg border border-[var(--el-border-color)] bg-[var(--el-bg-color)] p-2"
:style="{ width: `${panelWidth}px` }"
>
<div class="flex flex-1 flex-col gap-5">
<!-- <h3 class="text-base font-medium">{{ title }}</h3> -->
<div class="flex-1 overflow-scroll">
<div v-for="(item, index) in categoryData" :key="index">
<div
:class="{ selected: selectedCategory === item[finalValueKey] }"
class="crud-category-item"
@click="handleCategoryClick(item)"
>
<div>{{ item[titleKey] }}</div>
<div v-if="item.id !== '0'">
<ElDropdown @click.stop>
<span class="dropdown-trigger">
<ElIcon><MoreFilled /></ElIcon>
</span>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem @click="handleEditClick($event, item)">
<ElButton :icon="Edit" link>
{{ $t('button.edit') }}
</ElButton>
</ElDropdownItem>
<ElDropdownItem @click="handleDeleteClick($event, item)">
<ElButton type="danger" :icon="Delete" link>
{{ $t('button.delete') }}
</ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
</div>
</div>
</div>
<ElButton @click="handleAdd" :icon="Plus" :disabled="disabled" plain>
{{ $t('button.add') }}
</ElButton>
<!-- 无数据提示 -->
<div v-if="categoryData.length === 0 && !loading" class="no-data">
<ElEmpty
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
:description="$t('common.noDataAvailable')"
/>
</div>
<!-- 新增/编辑弹窗 -->
<ElDialog
:title="
isEdit ? `${$t('button.edit')}${title}` : `${$t('button.add')}${title}`
"
v-model="dialogVisible"
width="500px"
:close-on-click-modal="false"
>
<!-- 表单内容父组件通过插槽配置 -->
<slot name="form" :form-data="formData" :is-edit="isEdit"></slot>
<ElForm :model="formData" status-icon>
<ElFormItem :prop="titleKey">
<ElInput v-model.trim="formData[titleKey]" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">
{{ $t('button.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleSubmit">
{{ $t('button.confirm') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>
<style scoped>
.crud-category-item:hover {
background-color: var(--el-color-primary-light-9);
cursor: pointer;
}
.crud-category-item.selected {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.no-data {
text-align: center;
padding: 40px 0;
}
.crud-category-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
height: 40px;
border-radius: 8px;
font-size: 14px;
}
.crud-category-item :deep(.el-icon) {
outline: none !important;
}
.dropdown-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
.dropdown-trigger:hover {
background-color: #f0f0f0;
}
</style>

View File

@@ -0,0 +1,370 @@
<script setup>
import { computed, isVNode, onMounted, ref } from 'vue';
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue';
import { ElIcon } from 'element-plus';
// 定义组件属性
const props = defineProps({
// 分类数据,格式示例:[{ name: '分类1', icon: SomeIcon }, { name: '分类2' }]
categories: {
type: Array,
default: () => [],
required: true,
},
valueKey: {
type: String,
default: undefined,
},
titleKey: {
type: String,
default: 'name',
},
needHideCollapse: {
type: Boolean,
default: false,
},
iconKey: {
type: String,
default: 'icon',
},
// 自定义展开状态宽度默认300px
expandWidth: {
type: Number,
default: 120,
},
// 自定义收缩状态宽度默认48px
collapseWidth: {
type: Number,
default: 48,
},
// 默认选中的分类(用于初始化) 指定key
defaultSelectedCategory: {
type: String,
default: null,
},
iconSize: { type: [Number, String], default: 18 },
iconColor: { type: String, default: 'var(--el-text-color-primary)' },
// 新增:是否用 img 标签渲染 SVG 字符串(默认 false
useImgForSvg: { type: Boolean, default: false },
});
// 定义事件
const emit = defineEmits([
'click', // 分类项点击事件
'panelToggle', // 面板收缩状态改变事件
]);
const finalValueKey = computed(() => {
// 父组件传递了 valueKey 就用 valueKey否则用 titleKey
return props.valueKey ?? props.titleKey;
});
// -------------------------- 核心工具函数 --------------------------
/**
* SVG 字符串转 Data URL供 img 标签使用)
* @param {string} svgString - 清理后的 SVG 字符串
* @returns {string} Data URL
*/
const svgToDataUrl = (svgString) => {
// 1. 去除 SVG 中的换行和多余空格(优化编码后体积)
const cleanedSvg = svgString
.replaceAll('\n', '')
.replaceAll(/\s+/g, ' ')
.trim();
// 2. URL 编码 + 拼接 Data URL 格式
return `data:image/svg+xml;utf8,${encodeURIComponent(cleanedSvg)}`;
};
/**
* 判断是否为组件Element Plus 图标 / 自定义 SVG 组件)
*/
const isComponent = (icon) => {
return (
typeof icon === 'object' && (typeof icon === 'object' || isVNode(icon))
);
};
/**
* 判断是否为 SVG 字符串
*/
const isSvgString = (icon) => {
return typeof icon === 'string' && icon.trim().startsWith('<svg');
};
/**
* 判断是否为图片 URL
*/
const isImageUrl = (icon) => {
return (
typeof icon === 'string' &&
(icon.endsWith('.svg') ||
icon.endsWith('.png') ||
icon.endsWith('.jpg') ||
icon.startsWith('http://') ||
icon.startsWith('https://'))
);
};
// 面板收缩状态
const isCollapsed = ref(false);
// 检查是否有分类包含图标
const hasIcons = computed(() => {
return props.categories.some((item) => item[props.iconKey]);
});
// 动态计算面板宽度
const panelWidth = computed(() => {
if (isCollapsed.value) {
// 收缩状态:有图标用自定义收缩宽度,无图标保持最小适配宽度
return hasIcons.value ? props.collapseWidth : 120;
} else {
// 展开状态:使用自定义展开宽度
return props.expandWidth;
}
});
// 切换面板收缩状态
const togglePanel = () => {
isCollapsed.value = !isCollapsed.value;
emit('panelToggle', {
collapsed: isCollapsed.value,
currentWidth: panelWidth.value,
});
};
const selectedCategory = ref(null);
// 处理分类项点击
const handleCategoryClick = (category) => {
// 选中值:用 finalValueKey父组件指定的字段
selectedCategory.value = category[finalValueKey.value];
emit('click', {
item: category,
value: category[finalValueKey.value], // 父组件指定字段的值
label: category[props.titleKey], // 分类名称
});
};
onMounted(() => {
// 初始化时,检查是否有默认选中的分类
if (props.defaultSelectedCategory) {
selectedCategory.value = props.defaultSelectedCategory;
}
});
</script>
<template>
<div class="category-panel" :style="{ width: `${panelWidth}px` }">
<!-- 右上角收缩/展开按钮 -->
<div class="toggle-panel-btn" @click="togglePanel" v-if="!needHideCollapse">
<ElIcon>
<ArrowLeft v-if="!isCollapsed" />
<ArrowRight v-else />
</ElIcon>
</div>
<div style="margin-bottom: 48px" v-if="!needHideCollapse"></div>
<!-- 分类列表容器 -->
<div class="category-list" :class="{ collapsed: isCollapsed }">
<!-- 遍历一级分类数据 -->
<div
v-for="(category, index) in categories"
:key="index"
class="category-item"
>
<div
class="category-item-content"
:class="{ selected: selectedCategory === category[finalValueKey] }"
@click="handleCategoryClick(category)"
>
<!-- 图标 -->
<div v-if="category[iconKey]" class="category-icon">
<!-- 1. 组件类型图标Element Plus / 自定义 SVG 组件 -->
<ElIcon v-if="isComponent(category[iconKey])">
<component :is="category[iconKey]" />
</ElIcon>
<!-- 2. SVG 字符串支持 v-html img 两种渲染方式 -->
<template v-else-if="isSvgString(category[iconKey])">
<div
v-if="!useImgForSvg"
v-html="category[iconKey]"
class="custom-svg"
></div>
<img
v-else
:src="svgToDataUrl(category[iconKey])"
:alt="category[titleKey]"
class="svg-image"
/>
</template>
<!-- 3. 图片 URL本地/网络 SVG/PNG -->
<img
v-else-if="isImageUrl(category[iconKey])"
:src="category[iconKey]"
:alt="category[titleKey]"
class="svg-image"
/>
</div>
<!-- 分类名称收缩状态且有图标时隐藏文字 -->
<span
class="category-name"
:class="{ hidden: isCollapsed && category[iconKey] }"
>
{{ category[titleKey] }}
</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.category-panel {
position: relative; /* 相对定位,用于按钮绝对定位 */
overflow: hidden;
height: 100%;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* 平滑宽度过渡 */
box-sizing: border-box;
background: #ffffff;
border-radius: 8px;
border: 1px solid #f0f0f0;
padding: 20px;
}
/* 右上角收缩/展开按钮 */
.toggle-panel-btn {
position: absolute;
top: 8px;
right: 8px;
z-index: 10; /* 确保按钮在最上层 */
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--el-color-white);
border: 1px solid #e5e7eb;
cursor: pointer;
transition: all 0.2s;
/* 按钮不随面板收缩移动 */
transform: translateX(0);
}
.toggle-panel-btn:hover {
background-color: #f3f4f6;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.toggle-panel-btn .el-icon {
width: 16px;
height: 16px;
color: #666;
}
/* 分类列表容器 */
.category-list {
overflow: hidden;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 1px;
}
.category-item {
}
.category-item:last-child {
border-bottom: none;
}
.category-item-content {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
color: #333;
cursor: pointer;
transition: background-color 0.2s;
gap: 12px;
padding: 6px 0 6px 14px;
border-radius: 4px;
}
.category-item-content:hover {
background-color: var(--el-color-primary-light-9);
}
.category-icon {
width: v-bind(iconSize);
height: v-bind(iconSize);
color: v-bind(iconColor);
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.category-icon .el-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.category-item-content {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
color: #333;
cursor: pointer;
transition: background-color 0.2s;
gap: 12px;
}
.category-name {
transition:
opacity 0.2s,
transform 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family:
PingFangSC,
PingFang SC;
font-weight: 400;
font-size: 14px;
line-height: 20px;
text-align: left;
font-style: normal;
}
/* 收缩状态样式 */
.hidden {
display: none;
}
.collapsed .category-item-content {
justify-content: center;
padding: 12px 0;
}
/* 收缩状态下文字强制隐藏(避免无图标时文字溢出) */
.collapsed .category-name {
display: none;
}
/* 新增:选中态样式 */
.category-item-content.selected {
font-weight: 600;
background: rgba(0, 102, 255, 0.06);
color: #0066ff;
}
.category-item-content.selected:hover {
background: rgba(0, 102, 255, 0.06);
}
</style>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import type { Ref } from 'vue';
import { nextTick, ref } from 'vue';
import { $t } from '@easyflow/locales';
import { ElButton, ElDialog, ElForm, ElFormItem, ElInput } from 'element-plus';
interface BasicFormItem {
key: string;
description: string;
}
const emit = defineEmits(['success']);
const dialogVisible = ref(false);
const generateDefaultFormItems = (
data: BasicFormItem[] = [],
): BasicFormItem[] => {
return Array.from({ length: 5 }, (_, i) => ({
key: (i + 1).toString(),
description: data[i]?.description || '',
}));
};
const openDialog = (data: BasicFormItem[]) => {
nextTick(() => {
basicFormRef.value?.resetFields();
});
basicForm.value = generateDefaultFormItems(data);
dialogVisible.value = true;
};
const basicForm: Ref<BasicFormItem[]> = ref(generateDefaultFormItems());
const basicFormRef = ref();
defineExpose({
openDialog(data: BasicFormItem[]) {
openDialog(data);
},
});
const handleConfirm = () => {
basicFormRef.value?.validate((valid: boolean) => {
if (valid) {
emit('success', basicForm.value);
dialogVisible.value = false;
}
});
};
</script>
<template>
<ElDialog
v-model="dialogVisible"
:title="$t('button.add')"
width="700"
align-center
>
<ElForm
ref="basicFormRef"
style="width: 100%; margin-top: 20px"
:model="basicForm"
label-width="auto"
>
<template v-for="(item, index) in basicForm" :key="item.key">
<ElFormItem
:label="`${$t('bot.problemPresupposition')}${item.key}`"
:prop="`${index}.description`"
label-position="right"
>
<ElInput v-model="item.description" />
</ElFormItem>
</template>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">
{{ $t('button.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleConfirm">
{{ $t('button.confirm') }}
</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import type { Ref } from 'vue';
import { nextTick, ref } from 'vue';
import { $t } from '@easyflow/locales';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
} from 'element-plus';
import { api } from '#/api/request';
interface BasicFormItem {
weChatMpAppId: string;
weChatMpSecret: string;
weChatMpToken: string;
EncodingAESKey: string;
}
const emit = defineEmits(['reload']);
const dialogVisible = ref(false);
const botId = ref('');
const openDialog = (newBotId: string, options: BasicFormItem) => {
nextTick(() => {
basicFormRef.value?.resetFields();
});
botId.value = newBotId;
basicForm.value = { ...options };
dialogVisible.value = true;
};
const basicForm: Ref<BasicFormItem> = ref({
weChatMpAppId: '',
weChatMpSecret: '',
weChatMpToken: '',
EncodingAESKey: '',
});
const basicFormRef = ref<FormInstance>();
defineExpose({
openDialog(botId: string, options: BasicFormItem) {
openDialog(botId, options);
},
});
const rules = ref({
weChatMpAppId: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
],
weChatMpSecret: [
{
required: true,
message: $t('message.required'),
trigger: 'blur',
},
],
weChatMpToken: [
{
required: true,
message: $t('message.required'),
},
],
});
const handleConfirm = () => {
basicFormRef.value?.validate((valid: boolean) => {
if (valid) {
api
.post('/api/v1/bot/updateOptions', {
id: botId.value,
options: {
weChatMpAppId: basicForm.value?.weChatMpAppId,
weChatMpSecret: basicForm.value?.weChatMpSecret,
weChatMpToken: basicForm.value?.weChatMpToken,
EncodingAESKey: basicForm.value?.EncodingAESKey,
},
})
.then((res) => {
if (res.errorCode === 0) {
ElMessage.success($t('message.updateOkMessage'));
emit('reload');
} else {
ElMessage.error(res.message);
}
});
dialogVisible.value = false;
}
});
};
</script>
<template>
<ElDialog
v-model="dialogVisible"
:title="$t('bot.weChatOfficialAccountConfiguration')"
width="700"
align-center
>
<ElForm
ref="basicFormRef"
style="width: 100%; margin-top: 20px"
:model="basicForm"
label-width="auto"
:rules="rules"
>
<ElFormItem label="AppId" prop="weChatMpAppId" label-position="right">
<ElInput v-model="basicForm.weChatMpAppId" />
</ElFormItem>
<ElFormItem label="Secret" prop="weChatMpSecret" label-position="right">
<ElInput v-model="basicForm.weChatMpSecret" />
</ElFormItem>
<ElFormItem label="Token" prop="weChatMpToken" label-position="right">
<ElInput v-model="basicForm.weChatMpToken" />
</ElFormItem>
<ElFormItem
label="EncodingAESKey"
prop="EncodingAESKey"
label-position="right"
>
<ElInput v-model="basicForm.EncodingAESKey" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">
{{ $t('button.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleConfirm">
{{ $t('button.confirm') }}
</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,126 @@
<!--<script setup lang="ts">-->
<!--import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';-->
<!--import { ref } from 'vue';-->
<!--import { Attachments } from 'vue-element-plus-x';-->
<!--import { ElMessage } from 'element-plus';-->
<!--const senderRef = ref();-->
<!--const showHeaderFlog = ref(false);-->
<!--type SelfFilesCardProps = FilesCardProps & {-->
<!-- id?: number | string;-->
<!--};-->
<!--const files = ref<SelfFilesCardProps[]>([]);-->
<!--defineExpose( {-->
<!-- init(firstFile: File) {-->
<!-- console.log('firstFile', firstFile);-->
<!-- showHeaderFlog.value = true;-->
<!-- files.value = [-->
<!-- {-->
<!-- id: 0,-->
<!-- uid: `${firstFile.name}_${firstFile.size}`,-->
<!-- name: firstFile.name,-->
<!-- fileSize: firstFile.size,-->
<!-- imgFile: firstFile,-->
<!-- showDelIcon: true,-->
<!-- imgVariant: 'square',-->
<!-- },-->
<!-- ];-->
<!-- },-->
<!--});-->
<!--function closeHeader() {-->
<!-- showHeaderFlog.value = false;-->
<!-- senderRef.value.closeHeader();-->
<!--}-->
<!--function handlePasteFile(firstFile: File, fileList: FileList) {-->
<!-- showHeaderFlog.value = true;-->
<!-- senderRef.value.openHeader();-->
<!-- const fileArray = [...fileList];-->
<!-- fileArray.forEach((file, index) => {-->
<!-- files.value.push({-->
<!-- id: index,-->
<!-- uid: `${index}_${file.name}_${file.size}`,-->
<!-- name: file.name,-->
<!-- fileSize: file.size,-->
<!-- imgFile: file,-->
<!-- showDelIcon: true,-->
<!-- imgVariant: 'square',-->
<!-- });-->
<!-- });-->
<!--}-->
<!--async function handleHttpRequest(options: any) {-->
<!-- const formData = new FormData();-->
<!-- formData.append('file', options.file);-->
<!-- ElMessage.info('上传中...');-->
<!-- setTimeout(() => {-->
<!-- const res = {-->
<!-- message: '文件上传成功',-->
<!-- fileName: options.file.name,-->
<!-- uid: options.file.uid,-->
<!-- fileSize: options.file.size,-->
<!-- imgFile: options.file,-->
<!-- };-->
<!-- files.value.push({-->
<!-- id: files.value.length,-->
<!-- uid: res.uid,-->
<!-- name: res.fileName,-->
<!-- fileSize: res.fileSize,-->
<!-- imgFile: res.imgFile,-->
<!-- showDelIcon: true,-->
<!-- imgVariant: 'square',-->
<!-- });-->
<!-- ElMessage.success('上传成功');-->
<!-- }, 1000);-->
<!--}-->
<!--function handleDeleteCard(item: SelfFilesCardProps) {-->
<!-- files.value = files.value.filter((items: any) => items.id !== item.id);-->
<!-- ElMessage.success('删除成功');-->
<!--}-->
<!--</script>-->
<!--<template>-->
<!-- <div class="header-self-wrap">-->
<!-- <Attachments-->
<!-- :items="files"-->
<!-- :http-request="handleHttpRequest"-->
<!-- @delete-card="handleDeleteCard"-->
<!-- />-->
<!-- </div>-->
<!--</template>-->
<!--<style scoped>-->
<!--.header-self-wrap {-->
<!-- display: flex;-->
<!-- flex-direction: row;-->
<!-- padding: 16px;-->
<!-- width: 100%;-->
<!-- overflow: auto;-->
<!-- .header-self-title {-->
<!-- width: 100%;-->
<!-- display: flex;-->
<!-- height: 30px;-->
<!-- align-items: center;-->
<!-- justify-content: space-between;-->
<!-- padding-bottom: 8px;-->
<!-- }-->
<!-- .header-self-content {-->
<!-- flex: 1;-->
<!-- display: flex;-->
<!-- align-items: center;-->
<!-- justify-content: center;-->
<!-- font-size: 20px;-->
<!-- color: #626aef;-->
<!-- font-weight: 600;-->
<!-- }-->
<!--}-->
<!--</style>-->

View File

@@ -0,0 +1,700 @@
<script setup lang="ts">
import type { Sender } from 'vue-element-plus-x';
import type { BubbleListProps } from 'vue-element-plus-x/types/BubbleList';
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
import type { TypewriterInstance } from 'vue-element-plus-x/types/Typewriter';
import type { BotInfo, ChatMessage } from '@easyflow/types';
import { onMounted, ref, watchEffect } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { IconifyIcon } from '@easyflow/icons';
import { $t } from '@easyflow/locales';
import { useBotStore } from '@easyflow/stores';
import { cloneDeep, cn, uuid } from '@easyflow/utils';
import {
CircleCheck,
CopyDocument,
Paperclip,
RefreshRight,
} from '@element-plus/icons-vue';
import {
ElButton,
ElCollapse,
ElCollapseItem,
ElIcon,
ElMessage,
ElSpace,
} from 'element-plus';
import { tryit } from 'radash';
import { getMessageList, getPerQuestions } from '#/api';
import { api, sseClient } from '#/api/request';
import SendEnableIcon from '#/components/icons/SendEnableIcon.vue';
import SendIcon from '#/components/icons/SendIcon.vue';
import ShowJson from '#/components/json/ShowJson.vue';
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
import BotAvatar from '../botAvatar/botAvatar.vue';
import SendingIcon from '../icons/SendingIcon.vue';
type Think = {
reasoning_content?: string;
thinkingStatus?: ThinkingStatus;
thinlCollapse?: boolean;
};
type Tool = {
id: string;
name: string;
result: string;
status: 'TOOL_CALL' | 'TOOL_RESULT';
};
type MessageItem = ChatMessage & {
chains?: (Think | Tool)[];
};
const props = defineProps<{
bot?: BotInfo;
conversationId?: string;
// 是否显示对话列表
showChatConversations?: boolean;
}>();
const botStore = useBotStore();
interface historyMessageType {
role: string;
content: string;
}
interface presetQuestionsType {
key: string;
description: string;
}
const route = useRoute();
const botId = ref<string>((route.params.id as string) || '');
const router = useRouter();
const bubbleItems = ref<BubbleListProps<MessageItem>['list']>([]);
const senderRef = ref<InstanceType<typeof Sender>>();
const senderValue = ref('');
const sending = ref(false);
const getConversationId = async () => {
const res = await api.get('/api/v1/bot/generateConversationId');
return res.data;
};
const localeConversationId = ref<any>('');
const presetQuestions = ref<presetQuestionsType[]>([]);
defineExpose({
clear() {
bubbleItems.value = [];
messages.value = [];
},
});
const getPresetQuestions = () => {
api
.get('/api/v1/bot/detail', {
params: {
id: botId.value,
},
})
.then((res) => {
if (res.data.options?.presetQuestions) {
presetQuestions.value = res.data.options?.presetQuestions
.filter(
(item: presetQuestionsType) =>
item.description && item.description.trim() !== '',
)
.map((item: presetQuestionsType) => ({
key: item.key,
description: item.description,
}));
}
});
};
onMounted(async () => {
// 初始化 conversationId
localeConversationId.value =
props.conversationId && props.conversationId.length > 0
? props.conversationId
: await getConversationId();
getPresetQuestions();
});
watchEffect(async () => {
if (props.bot && props.conversationId) {
const [, res] = await tryit(getMessageList)({
conversationId: props.conversationId,
botId: props.bot.id,
tempUserId: uuid() + props.bot.id,
});
if (res?.errorCode === 0) {
bubbleItems.value = res.data.map((item) => ({
...item,
content:
item.role === 'assistant'
? item.content.replace(/^Final Answer:\s*/i, '')
: item.content,
placement: item.role === 'assistant' ? 'start' : 'end',
}));
}
} else {
bubbleItems.value = [];
}
});
const lastUserMessage = ref('');
const messages = ref<historyMessageType[]>([]);
const stopSse = () => {
sseClient.abort();
sending.value = false;
const lastBubbleItem = bubbleItems.value[bubbleItems.value.length - 1];
if (lastBubbleItem) {
bubbleItems.value[bubbleItems.value.length - 1] = {
...lastBubbleItem,
content: lastBubbleItem.content,
loading: false,
typing: false,
};
}
};
const clearSenderFiles = () => {
files.value = [];
attachmentsRef.value?.clearFiles();
openCloseHeader();
};
const handleSubmit = async (refreshContent: string) => {
const attachments = attachmentsRef.value?.getFileList();
const currentPrompt = refreshContent || senderValue.value.trim();
if (!currentPrompt) {
return;
}
sending.value = true;
lastUserMessage.value = currentPrompt;
messages.value.push({
role: 'user',
content: currentPrompt,
});
const copyMessages = [...messages.value];
const data = {
botId: botId.value,
prompt: currentPrompt,
conversationId: localeConversationId.value,
messages: copyMessages,
attachments,
};
clearSenderFiles();
messages.value.pop();
const mockMessages = generateMockMessages(refreshContent);
bubbleItems.value.push(...mockMessages);
senderRef.value?.clear();
sseClient.post('/api/v1/bot/chat', data, {
onMessage(message) {
const event = message.event;
const lastIndex = bubbleItems.value.length - 1;
const lastBubbleItem = bubbleItems.value[lastIndex];
// finish
if (event === 'done') {
sending.value = false;
return;
}
if (!message.data) {
return;
}
// 处理系统错误
const sseData = JSON.parse(message.data);
if (
sseData?.domain === 'SYSTEM' &&
sseData.payload?.code === 'SYSTEM_ERROR'
) {
const errorMessage = sseData.payload.message;
if (!lastBubbleItem) return;
bubbleItems.value[lastIndex] = {
...lastBubbleItem,
content: errorMessage,
loading: false,
typing: true,
};
return;
}
if (lastIndex >= 0 && sseData?.domain === 'TOOL') {
const chains = cloneDeep(lastBubbleItem?.chains ?? []);
const index = chains.findIndex(
(chain) =>
isTool(chain) && chain.id === sseData?.payload?.tool_call_id,
);
if (index === -1) {
chains.push({
id: sseData?.payload?.tool_call_id,
name: sseData?.payload?.name,
status: sseData?.type,
result:
sseData?.type === 'TOOL_CALL'
? sseData?.payload?.arguments
: sseData?.payload?.result,
});
} else {
chains[index] = {
...chains[index]!,
status: sseData?.type,
result:
sseData?.type === 'TOOL_CALL'
? sseData?.payload?.arguments
: sseData?.payload?.result,
};
}
bubbleItems.value[lastIndex]!.chains = chains;
stopThinking();
return;
}
// 处理流式消息
const delta = sseData.payload?.delta;
const role = sseData.payload?.role;
if (lastBubbleItem && delta) {
if (sseData.type === 'THINKING') {
const chains = cloneDeep(lastBubbleItem?.chains ?? []);
const index = chains.findIndex(
(chain) => isThink(chain) && chain.thinkingStatus === 'thinking',
);
if (index === -1) {
chains.push({
thinkingStatus: 'thinking',
thinlCollapse: true,
reasoning_content: delta,
});
} else {
const think = chains[index]! as Think;
chains[index] = {
...think,
reasoning_content: think.reasoning_content + delta,
};
}
bubbleItems.value[lastIndex]!.chains = chains;
} else if (sseData.type === 'MESSAGE') {
bubbleItems.value[lastIndex] = {
...lastBubbleItem,
content: (lastBubbleItem.content + delta).replaceAll(
'```echartsoption',
'```echarts\noption',
),
loading: false,
typing: true,
};
stopThinking();
}
}
// 是否需要保存聊天记录
if (event === 'needSaveMessage') {
messages.value.push({
role,
content: sseData.payload?.content,
});
}
},
onFinished() {
sending.value = false;
const lastIndex = bubbleItems.value.length - 1;
if (lastIndex) {
bubbleItems.value[lastIndex] = {
...bubbleItems.value[lastIndex]!,
loading: false,
};
}
stopThinking();
},
onError(err) {
console.error(err);
sending.value = false;
},
});
};
const isTool = (item: Think | Tool) => {
return 'id' in item;
};
const isThink = (item: Think | Tool): item is Think => {
return !('id' in item);
};
const stopThinking = () => {
const lastIndex = bubbleItems.value.length - 1;
if (lastIndex >= 0 && bubbleItems.value[lastIndex]?.chains) {
const chains = cloneDeep(bubbleItems.value[lastIndex].chains);
for (const chain of chains) {
if (isThink(chain) && chain.thinkingStatus === 'thinking') {
chain.thinkingStatus = 'end';
}
}
bubbleItems.value[lastIndex].chains = chains;
}
};
const handleComplete = (_: TypewriterInstance, index: number) => {
if (
index === bubbleItems.value.length - 1 &&
props.conversationId &&
props.conversationId.length <= 0 &&
sending.value === false
) {
setTimeout(() => {
router.replace({
params: { conversationId: localeConversationId.value },
});
}, 100);
}
};
const generateMockMessages = (refreshContent: string) => {
const userMessage: MessageItem = {
role: 'user',
id: Date.now().toString(),
fileList: [],
content: refreshContent || senderValue.value,
created: Date.now(),
updateAt: Date.now(),
placement: 'end',
};
const assistantMessage: MessageItem = {
role: 'assistant',
id: Date.now().toString(),
content: '',
loading: true,
created: Date.now(),
updateAt: Date.now(),
placement: 'start',
};
return [userMessage, assistantMessage];
};
const handleCopy = (content: string) => {
navigator.clipboard
.writeText(content)
.then(() => ElMessage.success($t('message.copySuccess')))
.catch(() => ElMessage.error($t('message.copyFail')));
};
const handleRefresh = () => {
handleSubmit(lastUserMessage.value);
};
const showHeaderFlog = ref(false);
function openCloseHeader() {
if (showHeaderFlog.value) {
senderRef.value?.closeHeader();
files.value = [];
} else {
senderRef.value?.openHeader();
}
showHeaderFlog.value = !showHeaderFlog.value;
}
const attachmentsRef = ref();
const files = ref<any[]>([]);
function handlePasteFile(_: any, fileList: FileList) {
showHeaderFlog.value = true;
senderRef.value?.openHeader();
files.value = [...fileList];
}
</script>
<template>
<div class="mx-auto h-full max-w-[780px]">
<div
:class="
cn(
'flex h-full w-full flex-col gap-3',
!localeConversationId && 'items-center justify-center gap-8',
)
"
>
<!-- 对话列表 -->
<div
v-if="localeConversationId || bubbleItems.length > 0"
class="message-container w-full flex-1 overflow-hidden"
>
<ElBubbleList
class="!h-full"
max-height="none"
:list="bubbleItems"
@complete="handleComplete"
>
<template #header="{ item }">
<div class="flex flex-col">
<span class="chat-bubble-item-time-style">
{{ new Date(item.created).toLocaleString() }}
</span>
<template v-if="item.chains">
<template
v-for="(chain, index) in item.chains"
:key="chain.id || index"
>
<ElThinking
v-if="isThink(chain)"
v-model="chain.thinlCollapse"
:content="chain.reasoning_content"
:status="chain.thinkingStatus"
/>
<ElCollapse v-else class="mb-2">
<ElCollapseItem :title="chain.name" :name="chain.id">
<template #title>
<div class="flex items-center gap-2 pl-5">
<ElIcon size="16">
<IconifyIcon icon="svg:wrench" />
</ElIcon>
<span>{{ chain.name }}</span>
<template v-if="chain.status === 'TOOL_CALL'">
<div
class="bg-secondary flex items-center gap-1 rounded-full px-2 py-0.5 leading-none"
>
<ElIcon size="16">
<IconifyIcon
icon="mdi:clock-time-five-outline"
/>
</ElIcon>
<span>{{ $t('bot.Running') }}...</span>
</div>
</template>
<template v-else>
<div
class="bg-secondary flex items-center gap-1 rounded-full px-2 py-0.5 leading-none"
>
<ElIcon size="16" color="var(--el-color-success)">
<CircleCheck />
</ElIcon>
<span>{{ $t('bot.Completed') }}</span>
</div>
</template>
</div>
</template>
<ShowJson :value="chain.result" />
</ElCollapseItem>
</ElCollapse>
</template>
</template>
<!-- <ElThinking
v-if="item.reasoning_content"
v-model="item.thinlCollapse"
:content="item.reasoning_content"
:status="item.thinkingStatus"
class="mb-3"
/> -->
<!-- <ElCollapse v-if="item.tools" class="mb-2">
<ElCollapseItem
class="mb-2"
v-for="tool in item.tools"
:key="tool.id"
:title="tool.name"
:name="tool.id"
>
<template #title>
<div class="flex items-center gap-2 pl-5">
<ElIcon size="16">
<IconifyIcon icon="svg:wrench" />
</ElIcon>
<span>{{ tool.name }}</span>
<template v-if="tool.status === 'TOOL_CALL'">
<ElIcon size="16">
<IconifyIcon icon="svg:spinner" />
</ElIcon>
</template>
<template v-else>
<ElIcon size="16" color="var(--el-color-success)">
<CircleCheck />
</ElIcon>
</template>
</div>
</template>
<ShowJson :value="tool.result" />
</ElCollapseItem>
</ElCollapse> -->
</div>
</template>
<!-- 自定义头像 -->
<template #avatar="{ item }">
<BotAvatar
v-if="item.role === 'assistant'"
:src="bot?.icon"
:size="40"
/>
</template>
<template #content="{ item }">
<ElXMarkdown :markdown="item.content" />
</template>
<!-- 自定义底部 -->
<template #footer="{ item }">
<ElSpace :size="10">
<ElSpace>
<span @click="handleRefresh()" style="cursor: pointer">
<ElIcon>
<RefreshRight />
</ElIcon>
</span>
<span @click="handleCopy(item.content)" style="cursor: pointer">
<ElIcon>
<CopyDocument />
</ElIcon>
</span>
</ElSpace>
</ElSpace>
</template>
</ElBubbleList>
</div>
<!-- 新对话显示bot信息 -->
<div v-else class="flex flex-col items-center gap-3.5">
<BotAvatar :src="bot?.icon" :size="88" />
<h1 class="text-base font-medium text-black/85">
{{ bot?.title }}
</h1>
<span class="text-sm text-[#757575]">{{ bot?.description }}</span>
</div>
<!--问题预设-->
<div
class="questions-preset-container"
v-if="botStore.presetQuestions.length > 0"
>
<ElButton
v-for="item in getPerQuestions(botStore.presetQuestions)"
:key="item.key"
@click="handleSubmit(item.description)"
>
{{ item.description }}
</ElButton>
</div>
<!-- Sender -->
<ElSender
ref="senderRef"
class="w-full"
v-model="senderValue"
:placeholder="$t('message.pleaseInputContent')"
variant="updown"
:auto-size="{ minRows: 3, maxRows: 6 }"
allow-speech
@submit="handleSubmit"
@paste-file="handlePasteFile"
>
<!-- 自定义头部内容 -->
<template #header>
<ChatFileUploader
ref="attachmentsRef"
:external-files="files"
:max-size="10"
/>
</template>
<template #action-list>
<ElSpace>
<ElButton circle @click="openCloseHeader">
<ElIcon><Paperclip /></ElIcon>
</ElButton>
<!--<ElButton circle @click="uploadRef.triggerFileSelect()">
<ElIcon><Paperclip /></ElIcon>
</ElButton>
<ElButton circle>
<ElIcon><Microphone /></ElIcon>
&lt;!&ndash; <ElIcon color="#0066FF"><RecordingIcon /></ElIcon> &ndash;&gt;
</ElButton>-->
<ElButton v-if="sending" circle @click="stopSse">
<ElIcon size="30" color="#409eff"><SendingIcon /></ElIcon>
</ElButton>
<template v-else>
<ElButton v-if="!senderValue" circle disabled>
<SendIcon />
</ElButton>
<ElButton v-else circle @click="handleSubmit('')">
<SendEnableIcon />
</ElButton>
</template>
</ElSpace>
</template>
</ElSender>
</div>
</div>
</template>
<style scoped>
.questions-preset-container {
display: flex;
flex-flow: row nowrap;
gap: 10px;
align-items: center;
justify-content: flex-start;
width: 100%;
overflow: auto;
}
.message-container {
padding: 8px;
background-color: var(--bot-chat-message-container);
border-radius: 8px;
}
.dark .message-container {
border: 1px solid hsl(var(--border));
}
:deep(.el-bubble-content-wrapper .el-bubble-content-filled[data-v-a52d8fe0]) {
background-color: var(--bot-chat-message-item-back);
}
.chat-bubble-item-time-style {
font-size: 12px;
color: var(--common-font-placeholder-color);
}
.el-bubble-list :deep(.el-bubble.el-bubble-start) {
--bubble-content-max-width: calc(
100% - var(--el-bubble-avatar-placeholder-gap)
) !important;
}
.el-bubble-list :deep(.el-bubble.el-bubble-end) {
--bubble-content-max-width: calc(
100% -
calc(
var(--el-bubble-avatar-placeholder-gap) + var(--el-avatar-size, 40px)
)
) !important;
}
:deep(.el-bubble-header) {
width: 100%;
}
:deep(.el-bubble-end .el-bubble-header) {
width: fit-content;
}
:deep(.el-thinking) {
margin: 0;
}
:deep(.el-thinking .content-wrapper) {
--el-thinking-content-wrapper-width: var(--bubble-content-max-width);
margin-bottom: 8px;
}
:deep(.el-collapse-item) {
overflow: hidden;
border-radius: 8px;
}
:deep(.el-collapse-item__content) {
padding-bottom: 0;
}
</style>

View File

@@ -0,0 +1,309 @@
<script setup lang="ts">
import { ref } from 'vue';
interface AccordionItem {
id: number;
title: string;
content: string;
isOpen: boolean;
}
const accordionData = ref<AccordionItem[]>([
{
id: 1,
title: 'Vue.js 简介',
content:
'Vue.js 是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是Vue 被设计为可以自底向上逐层应用。',
isOpen: true,
},
{
id: 2,
title: 'Composition API',
content:
'Composition API 是 Vue 3 中引入的一组 API允许您使用函数而不是通过选项来组织组件的逻辑。',
isOpen: false,
},
{
id: 3,
title: '响应式原理',
content:
'Vue 3 使用 Proxy 对象来实现响应式,相比 Vue 2 的 Object.definePropertyProxy 可以监听动态添加的属性。',
isOpen: false,
},
]);
const allowMultiple = ref(false);
const togglePanel = (index: number) => {
const item = accordionData.value[index];
if (!item) return;
if (!allowMultiple.value) {
accordionData.value.forEach((otherItem, i) => {
if (i !== index && otherItem) {
otherItem.isOpen = false;
}
});
}
item.isOpen = !item.isOpen;
};
const expandAll = () => {
accordionData.value.forEach((item) => {
if (item) {
item.isOpen = true;
}
});
};
const collapseAll = () => {
accordionData.value.forEach((item) => {
if (item) {
item.isOpen = false;
}
});
};
</script>
<template>
<div class="accordion-container">
<h1 class="title">Vue3 折叠面板</h1>
<p class="subtitle">使用 Vue3 Composition API 实现</p>
<!-- 控制面板 -->
<div class="controls">
<div class="control-group">
<label class="checkbox-label">
<input type="checkbox" v-model="allowMultiple" class="checkbox" />
允许多个同时展开
</label>
</div>
<div class="control-group">
<button @click="expandAll" class="control-btn">展开全部</button>
<button @click="collapseAll" class="control-btn">收起全部</button>
</div>
</div>
<!-- 折叠面板列表 -->
<div class="accordion-list">
<div
v-for="(item, index) in accordionData"
:key="item.id"
class="accordion-item"
:class="{ 'accordion-item--active': item.isOpen }"
>
<!-- 面板头部 -->
<div class="accordion-header" @click="togglePanel(index)">
<div class="column-header-container">
<div
class="accordion-icon"
:class="{ 'accordion-icon--rotated': item.isOpen }"
>
</div>
<h3 class="accordion-title">{{ item.title }}</h3>
</div>
</div>
<!-- 面板内容 -->
<div
class="accordion-content"
:class="{ 'accordion-content--open': item.isOpen }"
>
<div class="accordion-content-inner">
<p>{{ item.content }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.accordion-container {
max-width: 100%;
margin: 0 auto;
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.title {
text-align: center;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
font-size: 2rem;
font-weight: 600;
}
.subtitle {
text-align: center;
color: var(--el-text-color-secondary);
margin-bottom: 30px;
font-size: 1.1rem;
}
/* 控制面板样式 */
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background: var(--el-bg-color);
border-radius: 8px;
border: 1px solid #e9ecef;
}
.control-group {
display: flex;
align-items: center;
gap: 15px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--el-text-color-secondary);
cursor: pointer;
}
.checkbox {
width: 16px;
height: 16px;
}
.control-btn {
padding: 8px 16px;
border: 1px solid #3498db;
background: var(--el-bg-color);
color: var(--el-text-color-secondary);
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
}
.control-btn:hover {
background: #3498db;
background: var(--el-color-primary-light-9);
}
/* 折叠面板列表 */
.accordion-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.accordion-item {
border: 1px solid #e1e5e9;
border-radius: 8px;
overflow: hidden;
background: var(--el-bg-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.accordion-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.accordion-item--active {
border-color: #3498db;
}
/* 面板头部 */
.accordion-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: var(--el-bg-color);
cursor: pointer;
transition: background-color 0.3s ease;
user-select: none;
}
.accordion-header:hover {
background: var(--el-color-primary-light-9);
}
.accordion-title {
margin: 0;
font-size: 1.1rem;
font-weight: 500;
color: var(--el-text-color-secondary);
padding-left: 12px;
}
.accordion-icon {
transition: transform 0.3s ease;
color: #7f8c8d;
font-size: 12px;
}
.accordion-icon--rotated {
transform: rotate(180deg);
color: #3498db;
}
.accordion-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.4s ease;
background: var(--el-bg-color);
}
.accordion-content--open {
max-height: 200px;
}
.accordion-content-inner {
padding: 20px;
border-top: 1px solid #e1e5e9;
}
.accordion-content-inner p {
margin: 0;
line-height: 1.6;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.column-header-container {
display: flex;
justify-content: space-between;
align-items: center;
}
/* 响应式设计 */
@media (max-width: 768px) {
.accordion-container {
padding: 15px;
}
.controls {
flex-direction: column;
gap: 15px;
align-items: stretch;
}
.control-group {
justify-content: center;
}
.title {
font-size: 1.5rem;
}
.accordion-header {
padding: 14px 16px;
}
.accordion-title {
font-size: 1rem;
}
}
</style>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { $t } from '@easyflow/locales';
import { Delete } from '@element-plus/icons-vue';
import { ElAvatar, ElIcon, ElMessageBox } from 'element-plus';
const props = defineProps({
data: {
type: Array as any,
default: () => [],
},
titleKey: {
type: String,
default: 'title',
},
descriptionKey: {
type: String,
default: 'description',
},
});
const emits = defineEmits(['delete']);
const handleDelete = (item: any) => {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('button.confirm'),
cancelButtonText: $t('button.cancel'),
type: 'warning',
}).then(() => {
emits('delete', item);
});
};
</script>
<template>
<div class="collapse-item-container">
<div
v-for="(item, index) in props.data"
:key="index"
class="el-list-item-max-container"
>
<div class="el-list-item-container">
<div class="flex-center">
<ElAvatar :src="item.icon" v-if="item.icon" />
<ElAvatar v-else src="/favicon.svg" shape="circle" />
</div>
<div class="el-list-item-content">
<div class="title">{{ item[titleKey] }}</div>
<div class="description">{{ item[descriptionKey] }}</div>
</div>
</div>
<ElIcon
color="var(--el-color-danger)"
size="20px"
@click="handleDelete(item)"
class="el-list-item-delete-container"
>
<Delete />
</ElIcon>
</div>
</div>
</template>
<style scoped>
.el-list-item-max-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px 12px 12px;
background-color: hsl(var(--background));
border-radius: 8px;
}
.collapse-item-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 10px;
background-color: var(--bot-collapse-itme-back);
}
.el-list-item-container {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
justify-content: flex-start;
}
.el-list-item-content {
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.el-list-item-delete-container {
cursor: pointer;
}
.title {
font-family: PingFangSC, 'PingFang SC', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
text-align: left;
text-transform: none;
}
.description {
font-family: PingFangSC, 'PingFang SC', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 22px;
text-align: left;
text-transform: none;
opacity: 0.65;
}
</style>

View File

@@ -0,0 +1,536 @@
<script setup lang="ts">
import type { Component, PropType } from 'vue';
import { ref } from 'vue';
import { $t } from '@easyflow/locales';
import {
ElAvatar,
ElButton,
ElCheckbox,
ElCollapse,
ElCollapseItem,
ElDialog,
ElMessage,
ElText,
} from 'element-plus';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PageData from '#/components/page/PageData.vue';
export interface ButtonConfig {
key?: number | string;
text: string;
type?: 'danger' | 'default' | 'info' | 'primary' | 'success' | 'warning';
icon?: Component | string; // 支持字符串图标或组件图标
disabled?: boolean;
permission?: string; // 权限编码
[key: string]: any; // 允许传递自定义属性
}
interface SelectedMcpTool {
name: string;
description: string;
}
const props = defineProps({
title: { type: String, default: '' },
width: { type: String, default: '80%' },
extraQueryParams: { type: Object, default: () => ({}) },
searchParams: {
type: Array as PropType<string[]>,
default: () => [],
},
titleKey: { type: String, default: 'name' },
pageUrl: { type: String, default: '' },
hasParent: { type: Boolean, default: false },
isSelectMcp: { type: Boolean, default: false },
singleSelect: { type: Boolean, default: false },
footerButtons: {
type: Array as PropType<ButtonConfig[]>,
default: () => [],
},
});
const emit = defineEmits(['getData', 'buttonClick']);
const dialogVisible = ref(false);
const pageDataRef = ref();
const loading = ref(false);
const selectedIds = ref<(number | string)[]>([]);
// 存储上一级id与选中tool.name的关联关系
const selectedToolMap = ref<Record<number | string, SelectedMcpTool[]>>({});
defineExpose({
openDialog(defaultSelectedIds: (number | string)[]) {
selectedIds.value = defaultSelectedIds ? [...defaultSelectedIds] : [];
dialogVisible.value = true;
},
/**
* MCP专属弹窗打开方法适配MCP回显传递格式化后的MCP数据
* @param selectMcpMap - MCP已选数据映射MCP父级ID工具名称+描述数组)
*/
openMcpDialog(selectMcpMap: Record<number | string, SelectedMcpTool[]>) {
selectedIds.value = [];
selectedToolMap.value = structuredClone(selectMcpMap);
dialogVisible.value = true;
},
});
const isSelected = (id: number | string) => {
return selectedIds.value.includes(id);
};
const isSelectedMcp = (parentId: number | string, toolName: string) => {
// 查找当前parentId下是否存在该tool.name的工具
return !!selectedToolMap.value[parentId]?.some(
(tool) => tool.name === toolName,
);
};
const toggleSelectionMcp = (
mcpId: number | string,
toolName: string,
toolDescription: string,
checked: any,
) => {
if (checked) {
if (!selectedToolMap.value[mcpId]) {
selectedToolMap.value[mcpId] = []; // 初始化空数组
}
const isExisted = selectedToolMap.value[mcpId]?.some(
(tool) => tool.name === toolName,
);
if (!isExisted) {
selectedToolMap.value[mcpId]?.push({
name: toolName,
description: toolDescription,
});
}
} else {
if (selectedToolMap.value[mcpId]) {
selectedToolMap.value[mcpId] = selectedToolMap.value[mcpId].filter(
(tool) => tool.name !== toolName,
);
if (selectedToolMap.value[mcpId].length === 0) {
delete selectedToolMap.value[mcpId];
}
}
}
};
const toggleSelection = (id: number | string, checked: any) => {
if (checked) {
// 单选模式:先清空已选,再添加当前项
if (props.singleSelect) {
selectedIds.value = [id];
} else {
// 多选模式:追加当前项(避免重复)
if (!selectedIds.value.includes(id)) {
selectedIds.value.push(id);
}
}
} else {
// 取消选中:仅多选模式生效,单选模式不允许取消(可选)
if (!props.singleSelect) {
selectedIds.value = selectedIds.value.filter((i) => i !== id);
}
}
};
/**
* 封装获取MCP选中的结构化信息包含name和description
* @returns {Record<number | string, string[][]>[]} 符合要求的数据:[{ 上一级id: [[name1, description1], [name2, description2]] }]
*/
const getMcpSelectedInfo = (): Record<number | string, string[][]>[] => {
const mcpSelectedResult: Record<number | string, string[][]>[] = [];
Object.entries(selectedToolMap.value).forEach(([parentId, selectedTools]) => {
// 转换每个工具为 [name, description] 一维数组
const formattedToolList: string[][] = selectedTools.map((tool) => [
tool.name,
tool.description,
]);
mcpSelectedResult.push({
[parentId]: formattedToolList,
});
});
return mcpSelectedResult;
};
const handleSubmitRun = () => {
const hasSelected = props.isSelectMcp
? Object.keys(selectedToolMap.value).some(
(parentId) => (selectedToolMap.value[parentId] ?? []).length > 0,
)
: selectedIds.value.length > 0;
// 未选中内容时提示并返回
if (!hasSelected) {
ElMessage.error($t('message.selectTip'));
return;
}
const submitData = props.isSelectMcp
? getMcpSelectedInfo()
: selectedIds.value;
emit('getData', submitData);
// 关闭弹窗并返回数据
dialogVisible.value = false;
return submitData;
};
const handleSearch = (query: string) => {
const tempParams = {} as Record<string, string>;
props.searchParams.forEach((paramName) => {
tempParams[paramName] = query;
});
pageDataRef.value?.setQuery({
isQueryOr: true,
...tempParams,
});
};
</script>
<template>
<ElDialog
v-model="dialogVisible"
draggable
:close-on-click-modal="false"
:width="props.width"
align-center
>
<template #header>
<div>
<p class="el-dialog__title mb-4">{{ props.title }}</p>
<HeaderSearch @search="handleSearch" />
</div>
</template>
<div class="select-modal-container p-5">
<PageData
ref="pageDataRef"
:page-url="pageUrl"
:page-size="10"
:extra-query-params="extraQueryParams"
>
<template #default="{ pageList }">
<template v-if="hasParent">
<div class="container-second">
<ElCollapse
accordion
v-for="(item, index) in pageList"
:key="index"
>
<ElCollapseItem>
<template #title="{ isActive }">
<div
class="title-wrapper"
:class="[{ 'is-active': isActive }]"
>
<div>
<ElAvatar :src="item.icon" v-if="item.icon" />
<ElAvatar v-else src="/favicon.svg" shape="circle" />
</div>
<div class="title-right-container">
<ElText truncated class="title">
{{ item[titleKey] }}
</ElText>
<div class="desc">{{ item.description }}</div>
</div>
</div>
</template>
<!--选择插件-->
<div v-if="!isSelectMcp">
<div v-for="tool in item.tools" :key="tool.id">
<div
class="content-title-wrapper"
@click="toggleSelection(tool.id, !isSelected(tool.id))"
:class="{ 'item-selected': isSelected(tool.id) }"
>
<div class="content-left-container">
<div class="title-right-container">
<ElText truncated class="title">
{{ tool.name }}
</ElText>
<div class="desc">{{ tool.description }}</div>
</div>
</div>
<div>
<ElCheckbox
:model-value="isSelected(tool.id)"
@change="(val) => toggleSelection(tool.id, val)"
@click.stop
/>
</div>
</div>
</div>
</div>
<!--选择MCP-->
<div v-if="isSelectMcp">
<div v-for="tool in item.tools" :key="tool.name">
<!-- 2. MCP专属绑定点击事件取反MCP选中状态 -->
<div
class="content-title-wrapper"
@click="
toggleSelectionMcp(
item.id,
tool.name,
tool.description,
!isSelectedMcp(item.id, tool.name),
)
"
:class="{
'item-selected': isSelectedMcp(item.id, tool.name),
}"
>
<div class="content-left-container">
<div class="title-right-container">
<ElText truncated class="title">
{{ tool.name }}
</ElText>
<div class="desc">{{ tool.description }}</div>
</div>
</div>
<div>
<ElCheckbox
:model-value="isSelectedMcp(item.id, tool.name)"
@change="
(val) =>
toggleSelectionMcp(
item.id,
tool.name,
tool.description,
val,
)
"
@click.stop
/>
</div>
</div>
</div>
</div>
</ElCollapseItem>
</ElCollapse>
</div>
</template>
<template v-else>
<div class="container-second">
<div v-for="(item, index) in pageList" :key="index">
<div
class="content-title-wrapper"
@click="toggleSelection(item.id, !isSelected(item.id))"
:class="{ 'item-selected': isSelected(item.id) }"
>
<div class="content-sec-left-container">
<div>
<ElAvatar :src="item.icon" v-if="item.icon" />
<ElAvatar v-else src="/favicon.svg" shape="circle" />
</div>
<div class="title-sec-right-container">
<ElText truncated class="title">
{{ item.title }}
</ElText>
<div class="desc">{{ item.description }}</div>
</div>
</div>
<div>
<ElCheckbox
:model-value="isSelected(item.id)"
@change="(val) => toggleSelection(item.id, val)"
@click.stop
/>
</div>
</div>
</div>
</div>
</template>
</template>
</PageData>
</div>
<template #footer>
<ElButton @click="dialogVisible = false">
{{ $t('button.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleSubmitRun" :loading="loading">
{{ $t('button.confirm') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.select-modal-container {
/* height: 100%;
overflow: auto; */
background-color: var(--bot-collapse-itme-back);
border-radius: 8px;
}
.title-wrapper {
display: flex;
align-items: center;
}
.content-title-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
height: 113px;
padding: 20px 50px 20px 20px;
overflow: hidden;
cursor: pointer;
background-color: var(--el-bg-color);
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.title {
font-family: PingFangSC, 'PingFang SC';
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px;
text-align: left;
text-transform: none;
}
.content-left-container {
display: flex;
align-items: center;
}
.content-sec-left-container {
display: flex;
overflow: hidden;
}
.desc {
display: -webkit-box;
width: 100%;
/* height: 42px;
min-height: 42px; */
margin-top: 12px;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
font-family: PingFangSC, 'PingFang SC';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
text-align: left;
text-transform: none;
opacity: 0.65;
-webkit-box-orient: vertical;
}
.title-right-container {
/* display: flex;
flex-direction: column;
align-items: first baseline;
justify-content: center; */
padding-right: 10px;
margin-left: 10px;
overflow: hidden;
}
.title-sec-right-container {
/* display: flex;
flex-direction: column;
align-items: flex-start; */
padding-right: 10px;
margin-left: 10px;
overflow: hidden;
}
.container-second {
display: flex;
flex-direction: column;
gap: 12px;
/* padding: 20px 20px; */
}
.select-modal-container
:deep(.el-collapse-item__header .el-collapse-item__arrow) {
color: #666;
}
.select-modal-container
:deep(.el-collapse-item.is-active .el-collapse-item__arrow) {
color: #1976d2;
}
.select-modal-container
:deep(.el-collapse-item__content)
.content-title-wrapper:last-child {
margin-bottom: 0;
}
.select-modal-container :deep(.el-collapse-item__header) {
height: auto;
padding: 12px;
line-height: normal;
color: #333;
background-color: hsl(var(--background));
}
.select-modal-container :deep(.el-collapse-item__header:hover) {
background-color: hsl(var(--background));
border-color: hsl(var(--border));
}
.select-modal-container
:deep(.el-collapse-item.is-active .el-collapse-item__header) {
color: #1976d2;
background-color: hsl(var(--background));
border: none;
border-bottom-color: transparent;
}
.select-modal-container :deep(.el-collapse-item__content) {
padding: 12px;
background-color: hsl(var(--background));
border: none;
}
.select-modal-container :deep(.el-collapse-item__wrap) {
background-color: hsl(var(--background));
border: none;
}
.select-modal-container :deep(.el-collapse-item) {
margin-bottom: 8px;
background-color: hsl(var(--background));
}
.select-modal-container
:deep(.el-collapse-item__content)
.content-title-wrapper {
margin-top: 12px;
margin-bottom: 8px;
background-color: var(--bot-collapse-itme-back);
border: 1px solid hsl(var(--border));
border-radius: 6px;
}
.select-modal-container
:deep(.el-collapse-item__content)
.content-title-wrapper:hover {
background-color: var(--bot-collapse-itme-back);
}
.select-modal-container :deep(.el-collapse) {
overflow: hidden;
background-color: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 4px;
}
.select-modal-container :deep(.el-checkbox__inner) {
--el-checkbox-input-border: 1px solid hsl(var(--border));
}
</style>

View File

@@ -0,0 +1,281 @@
<script setup lang="ts">
import type { CronItemState } from './CronTabPane.vue';
import { computed, reactive, ref } from 'vue';
import {
ElButton,
ElCard,
ElDivider,
ElInput,
ElTable,
ElTableColumn,
ElTabPane,
ElTabs,
} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import CrontabPane from './CronTabPane.vue';
const emit = defineEmits(['useCron']);
const activeTab = ref('second');
// 默认状态工厂函数
const defaultState = (min: number, _: number): CronItemState => ({
type: 'every',
rangeStart: min,
rangeEnd: min + 1,
loopStart: min,
loopStep: 1,
specificList: [],
});
const state = reactive({
second: defaultState(0, 59),
minute: defaultState(0, 59),
hour: defaultState(0, 23),
day: { ...defaultState(1, 31), type: 'every' } as CronItemState,
month: defaultState(1, 12),
week: { ...defaultState(1, 7), type: 'none' } as CronItemState, // 默认为?
});
const weekAlias: Record<number, string> = {
1: $t('common.Sun'),
2: $t('common.Mon'),
3: $t('common.Tue'),
4: $t('common.Wed'),
5: $t('common.Thu'),
6: $t('common.Fri'),
7: $t('common.Sat'),
};
// 核心:格式化单个字段
const formatItem = (item: CronItemState): string => {
switch (item.type) {
case 'every': {
return '*';
}
case 'loop': {
return `${item.loopStart}/${item.loopStep}`;
}
case 'none': {
return '?';
}
case 'range': {
return `${item.rangeStart}-${item.rangeEnd}`;
}
case 'specific': {
if (item.specificList.length === 0) return '*';
return item.specificList.sort((a, b) => a - b).join(',');
}
default: {
return '*';
}
}
};
// 互斥逻辑
const handleDayChange = () => {
if (state.day.type !== 'none') {
state.week.type = 'none';
}
};
const handleWeekChange = () => {
if (state.week.type !== 'none') {
state.day.type = 'none';
}
};
// 计算最终 Cron 字符串
const cronResult = computed(() => {
const s = formatItem(state.second);
const m = formatItem(state.minute);
const h = formatItem(state.hour);
const d = formatItem(state.day);
const M = formatItem(state.month);
const w = formatItem(state.week);
return `${s} ${m} ${h} ${d} ${M} ${w}`;
});
// 表格展示数据
const resultTableData = computed(() => [
{
second: formatItem(state.second),
minute: formatItem(state.minute),
hour: formatItem(state.hour),
day: formatItem(state.day),
month: formatItem(state.month),
week: formatItem(state.week),
},
]);
const copyCron = () => {
// if (navigator.clipboard) {
// navigator.clipboard.writeText(cronResult.value).then(() => {
// ElMessage.success('Cron 表达式已复制');
// });
// } else {
// ElMessage.warning('浏览器不支持剪贴板 API');
// }
emit('useCron', cronResult.value);
};
const nextTimes = ref<any[]>([]);
function getNextTimes() {
api
.get('/api/v1/sysJob/getNextTimes', {
params: {
cronExpression: cronResult.value,
},
})
.then((res: any) => {
nextTimes.value = res.errorCode === 0 ? res.data : [];
});
}
</script>
<template>
<div class="cron-generator">
<ElCard class="box-card" shadow="never">
<template #header>
<div class="card-header">
<span>{{ $t('cron.cronExpressionGenerator') }}</span>
</div>
</template>
<ElTabs v-model="activeTab" type="border-card">
<!-- -->
<ElTabPane :label="$t('common.Second')" name="second">
<CrontabPane
v-model="state.second"
:min="0"
:max="59"
:label="$t('common.Second')"
/>
</ElTabPane>
<!-- -->
<ElTabPane :label="$t('common.Min')" name="minute">
<CrontabPane
v-model="state.minute"
:min="0"
:max="59"
:label="$t('common.Min')"
/>
</ElTabPane>
<!-- -->
<ElTabPane :label="$t('common.Hour')" name="hour">
<CrontabPane
v-model="state.hour"
:min="0"
:max="23"
:label="$t('common.Hour')"
/>
</ElTabPane>
<!-- -->
<ElTabPane :label="$t('common.Day')" name="day">
<CrontabPane
v-model="state.day"
:min="1"
:max="31"
:label="$t('common.Day')"
week-mode-check
@change="handleDayChange"
/>
</ElTabPane>
<!-- -->
<ElTabPane :label="$t('common.Month')" name="month">
<CrontabPane
v-model="state.month"
:min="1"
:max="12"
:label="$t('common.Month')"
/>
</ElTabPane>
<!-- -->
<ElTabPane :label="$t('common.Week')" name="week">
<CrontabPane
v-model="state.week"
:min="1"
:max="7"
:label="$t('common.Week')"
:alias-map="weekAlias"
day-mode-check
@change="handleWeekChange"
/>
</ElTabPane>
</ElTabs>
<!-- 结果展示区域 -->
<div class="result-area">
<ElDivider content-position="left">
{{ $t('cron.GenerateResult') }}
</ElDivider>
<div class="result-row">
<ElInput
v-model="cronResult"
readonly
:placeholder="$t('cron.CronExpression')"
>
<template #prepend>{{ $t('cron.CronExpression') }}</template>
</ElInput>
<ElButton type="primary" @click="copyCron" style="margin-left: 10px">
{{ $t('cron.UseThisValue') }}
</ElButton>
<ElButton
type="primary"
@click="getNextTimes"
style="margin-left: 10px"
>
{{ $t('cron.CheckLast5ExecutionTimes') }}
</ElButton>
</div>
<div class="preview-table">
<ElTable
:data="resultTableData"
border
style="width: 100%"
size="small"
>
<ElTableColumn prop="second" :label="$t('common.Second')" />
<ElTableColumn prop="minute" :label="$t('common.Min')" />
<ElTableColumn prop="hour" :label="$t('common.Hour')" />
<ElTableColumn prop="day" :label="$t('common.Day')" />
<ElTableColumn prop="month" :label="$t('common.Month')" />
<ElTableColumn prop="week" :label="$t('common.Week')" />
</ElTable>
</div>
<ElDivider content-position="left">
{{ $t('cron.Last5ExecutionTimes') }}
</ElDivider>
<div v-for="(item, idx) in nextTimes" :key="idx">
{{ item }}
</div>
</div>
</ElCard>
</div>
</template>
<style scoped>
.cron-generator {
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.result-area {
margin-top: 20px;
}
.result-row {
display: flex;
margin-bottom: 15px;
}
</style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ElButton, ElDialog } from 'element-plus';
import CronGenerator from './CronGenerator.vue';
defineProps({
modelValue: {
type: String,
default: '',
},
});
// 定义 emit 事件
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
// 使用本地变量来管理对话框显示
const showCron = ref(false);
function useCron(value: string) {
emit('update:modelValue', value);
showCron.value = false;
}
</script>
<template>
<div>
<ElDialog
draggable
title="Cron"
v-model="showCron"
width="60%"
append-to-body
>
<CronGenerator @use-cron="useCron" />
</ElDialog>
<ElButton class="mt-2" @click="showCron = true">
{{ $t('cron.ClickGenerate') }}
</ElButton>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
ElInputNumber,
ElOption,
ElRadio,
ElRadioGroup,
ElSelect,
} from 'element-plus';
// 定义接口,方便父组件引用(如果使用 TS
export interface CronItemState {
type: 'every' | 'loop' | 'none' | 'range' | 'specific';
rangeStart: number;
rangeEnd: number;
loopStart: number;
loopStep: number;
specificList: number[];
}
const props = defineProps({
modelValue: {
type: Object as () => CronItemState,
required: true,
},
label: { type: String, default: '' },
min: { type: Number, default: 0 },
max: { type: Number, default: 59 },
aliasMap: {
type: Object as () => Record<number, string>,
default: () => ({}),
}, // 用于周的转换
weekModeCheck: { type: Boolean, default: false }, // 是否是日Tab
dayModeCheck: { type: Boolean, default: false }, // 是否是周Tab
});
const emit = defineEmits(['update:modelValue', 'change']);
// 计算属性处理单选框的 v-model
const radioType = computed({
get: () => props.modelValue.type,
set: (val) => {
// 更新 type 时,触发 update
emit('update:modelValue', { ...props.modelValue, type: val });
emit('change', val);
},
});
// 通用更新函数
const updateVal = (key: keyof CronItemState, val: any) => {
emit('update:modelValue', { ...props.modelValue, [key]: val });
// 这里是否触发 change 取决于你的需求,通常数值改变不需要触发 tab 互斥检查,所以这里不一定非要 emit('change')
// 但为了保险起见,可以保留
};
// 生成下拉选项
const specificOptions = computed(() => {
const options = [];
for (let i = props.min; i <= props.max; i++) {
const label = props.aliasMap[i] ? `${props.aliasMap[i]} (${i})` : `${i}`;
options.push({ value: i, label });
}
return options;
});
</script>
<template>
<div class="crontab-pane">
<ElRadioGroup class="cron-radio-group" v-model="radioType">
<!-- 1. xxx -->
<div class="radio-line">
<ElRadio value="every">{{ $t('cron.Per') }}{{ label }} (*)</ElRadio>
</div>
<!-- 2. 不指定 (?) - 仅用于日和周 -->
<div class="radio-line" v-if="weekModeCheck || dayModeCheck">
<ElRadio value="none">{{ $t('cron.NotSpecified') }} (?)</ElRadio>
</div>
<!-- 3. 周期 (Loop) -->
<div class="radio-line">
<ElRadio value="loop">{{ $t('cron.Cycle') }}</ElRadio>
<span class="text">{{ $t('cron.From') }}</span>
<ElInputNumber
:model-value="modelValue.loopStart"
@update:model-value="(v) => updateVal('loopStart', v)"
:min="min"
:max="max"
size="small"
controls-position="right"
/>
<span class="text">{{ label }}{{ $t('cron.StartPer') }}</span>
<ElInputNumber
:model-value="modelValue.loopStep"
@update:model-value="(v) => updateVal('loopStep', v)"
:min="1"
:max="max"
size="small"
controls-position="right"
/>
<span class="text">{{ label }}{{ $t('cron.ExecuteOnce') }}</span>
</div>
<!-- 4. 区间 (Range) -->
<div class="radio-line">
<ElRadio value="range">{{ $t('cron.Rang') }}</ElRadio>
<span class="text">{{ $t('cron.From') }}</span>
<ElInputNumber
:model-value="modelValue.rangeStart"
@update:model-value="(v) => updateVal('rangeStart', v)"
:min="min"
:max="max"
size="small"
controls-position="right"
/>
<span class="text">{{ $t('cron.To') }}</span>
<ElInputNumber
:model-value="modelValue.rangeEnd"
@update:model-value="(v) => updateVal('rangeEnd', v)"
:min="min"
:max="max"
size="small"
controls-position="right"
/>
<span class="text">{{ label }}</span>
</div>
<!-- 5. 指定 (Specific) -->
<div class="radio-line">
<ElRadio value="specific">{{ $t('cron.Specify') }}</ElRadio>
<ElSelect
:model-value="modelValue.specificList"
@update:model-value="(v) => updateVal('specificList', v)"
multiple
:placeholder="$t('dictSelect.placeholder')"
style="width: 100%; min-width: 200px; margin-left: 10px"
size="small"
>
<ElOption
v-for="item in specificOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</div>
</ElRadioGroup>
</div>
</template>
<style scoped>
.cron-radio-group {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.radio-line {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 12px;
}
.text {
margin: 0 5px;
font-size: 14px;
}
:deep(.ElInputNumber) {
width: 100px;
}
</style>

View File

@@ -0,0 +1,190 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { ElMessage, ElOption, ElSelect } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
// 字典项接口
interface DictItem {
value: number | string;
label: string;
disabled?: boolean;
[key: string]: any;
}
interface Props {
modelValue: Array<number | string> | null | number | string | undefined;
dictCode: string; // 字典编码
placeholder?: string;
clearable?: boolean;
filterable?: boolean;
disabled?: boolean;
multiple?: boolean;
collapseTags?: boolean;
collapseTagsTooltip?: boolean;
showCode?: boolean; // 是否显示字典编码前缀
immediate?: boolean; // 是否立即加载
extraOptions?: DictItem[];
}
interface Emits {
(
e: 'update:modelValue',
value: Array<number | string> | null | number | string,
): void;
(
e: 'change',
value: Array<number | string> | null | number | string,
dictItem?: DictItem | DictItem[],
): void;
(e: 'blur'): void;
(e: 'dictLoaded', options: DictItem[]): void;
}
const props = withDefaults(defineProps<Props>(), {
placeholder: undefined,
clearable: true,
filterable: true,
disabled: false,
multiple: false,
collapseTags: false,
collapseTagsTooltip: false,
showCode: false,
immediate: true,
extraOptions: () => [],
});
const emit = defineEmits<Emits>();
// 使用计算属性处理placeholder
const placeholderText = computed(() => {
// 如果父组件传入了placeholder优先使用
if (props.placeholder !== undefined) {
return props.placeholder;
}
// 否则使用默认的国际化文本
return $t('dictSelect.placeholder');
});
// 响应式数据
const dictOptions = ref<DictItem[]>([]);
const loading = ref(false);
const loadedCodes = ref<Set<string>>(new Set()); // 已加载的字典编码缓存
// 处理值变化
const handleChange = (
value: Array<number | string> | null | number | string,
) => {
emit('update:modelValue', value);
// 找到对应的字典项
const selectedItems: DictItem | DictItem[] | undefined =
props.multiple && Array.isArray(value)
? (value
.map((v) => dictOptions.value.find((item) => item.value === v))
.filter(Boolean) as DictItem[])
: dictOptions.value.find((item) => item.value === value);
emit('change', value, selectedItems);
};
// 处理失去焦点
const handleBlur = () => {
emit('blur');
};
// 获取字典数据
const fetchDictData = async (code: string) => {
// 如果已经加载过,直接返回缓存
if (loadedCodes.value.has(code)) {
return;
}
loading.value = true;
try {
// 这里调用你的后端API
const data = await getDictListByCode(code);
// extraOptions 放最前面
dictOptions.value = [...props.extraOptions, ...data];
loadedCodes.value.add(code);
emit('dictLoaded', data);
} catch (error) {
console.error(`${$t('dictSelect.getError')}: ${code}`, error);
ElMessage.error(`${$t('dictSelect.getError')}: ${code}`);
dictOptions.value = [];
} finally {
loading.value = false;
}
};
// 模拟后端API调用 - 实际项目中替换为你的真实API
const getDictListByCode = async (code: string): Promise<DictItem[]> => {
const requestPromise = api.get(`/api/v1/dict/items/${code}`);
const dictData = await requestPromise;
return dictData.data;
};
// 重新加载字典
const reloadDict = () => {
if (props.dictCode) {
fetchDictData(props.dictCode);
}
};
// 监听字典编码变化
watch(
() => props.dictCode,
(newCode) => {
if (newCode) {
fetchDictData(newCode);
}
},
);
// 组件挂载时加载字典
onMounted(() => {
if (props.immediate && props.dictCode) {
fetchDictData(props.dictCode);
}
});
// 暴露方法给父组件
defineExpose({
reloadDict,
getOptions: () => dictOptions.value,
});
</script>
<template>
<ElSelect
:model-value="modelValue"
@update:model-value="handleChange"
@blur="handleBlur"
:placeholder="placeholderText"
:clearable="clearable"
:filterable="filterable"
:disabled="disabled || loading"
:loading="loading"
:multiple="multiple"
:collapse-tags="collapseTags"
:collapse-tags-tooltip="collapseTagsTooltip"
>
<ElOption
v-for="item in dictOptions"
:key="item.value"
:label="item.label"
:value="item.value"
:disabled="item.disabled"
/>
<template #prefix v-if="showCode && dictCode">
<span class="dict-select__prefix">{{ dictCode }}</span>
</template>
</ElSelect>
</template>
<style scoped>
.dict-select__prefix {
margin-right: 4px;
font-size: 12px;
color: #909399;
}
</style>

View File

@@ -0,0 +1,221 @@
<script setup>
import { computed, ref } from 'vue';
import { ArrowDown } from '@element-plus/icons-vue';
import {
ElButton,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElIcon,
ElInput,
} from 'element-plus';
import { hasPermission } from '#/api/common/hasPermission.ts';
import { $t } from '#/locales';
// 定义组件属性
const props = defineProps({
// 按钮配置数组
buttons: {
type: Array,
default: () => [],
validator: (value) => {
return value.every((button) => {
return (
typeof button.text === 'string' &&
(button.key || typeof button.key === 'string')
);
});
},
},
// 最大显示按钮数量(不包括下拉菜单)
maxVisibleButtons: {
type: Number,
default: 3,
},
// 搜索框占位符
searchPlaceholder: {
type: String,
default: $t('common.searchPlaceholder'),
},
});
const emit = defineEmits(['search', 'button-click', 'buttonClick']);
// 搜索值
const searchValue = ref('');
// 计算显示的按钮
const visibleButtons = computed(() => {
return props.buttons.slice(0, props.maxVisibleButtons);
});
// 计算下拉菜单中的按钮
const dropdownButtons = computed(() => {
const dropdownButtonsTemp = props.buttons.slice(props.maxVisibleButtons);
if (dropdownButtonsTemp.length === 0) {
return [];
}
return dropdownButtonsTemp.value.filter((action) => {
return hasPermission([action.permission]);
});
});
// 处理搜索
const handleSearch = () => {
emit('search', searchValue.value);
};
const handleReset = () => {
searchValue.value = '';
emit('search', '');
};
// 处理按钮点击
const handleButtonClick = (button) => {
emit('buttonClick', {
type: 'button',
key: button.key,
button,
data: button.data,
});
};
// 处理下拉菜单点击
const handleDropdownClick = (button) => {
emit('buttonClick', {
type: 'dropdown',
key: button.key,
button,
data: button.data,
});
};
</script>
<template>
<div class="custom-header">
<!-- 左侧搜索区域 -->
<div class="header-left">
<div class="search-container">
<div>
<ElInput
v-model="searchValue"
:placeholder="$t('common.searchPlaceholder')"
class="search-input"
@keyup.enter="handleSearch"
clearable
/>
</div>
<div>
<ElButton type="primary" auto-insert-space @click="handleSearch">
{{ $t('button.query') }}
</ElButton>
</div>
<div>
<ElButton auto-insert-space @click="handleReset">
{{ $t('button.reset') }}
</ElButton>
</div>
</div>
</div>
<!-- 右侧按钮区域 -->
<div class="header-right">
<!-- 显示的按钮最多3个 -->
<template
v-for="(button, index) in visibleButtons"
:key="button.key || index"
>
<ElButton
:type="button.type || 'default'"
:icon="button.icon"
:disabled="button.disabled"
v-access:code="button.permission"
@click="handleButtonClick(button)"
>
{{ button.text }}
</ElButton>
</template>
<!-- 下拉菜单隐藏的按钮 -->
<ElDropdown
v-if="dropdownButtons.length > 0"
@command="handleDropdownClick"
>
<ElButton>
{{ $t('button.more')
}}<ElIcon class="el-icon--right"><ArrowDown /></ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="button in dropdownButtons"
:key="button.key"
:command="button"
:disabled="button.disabled"
>
<ElIcon v-if="button.icon">
<component :is="button.icon" />
</ElIcon>
<span style="margin-left: 8px">{{ button.text }}</span>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
</template>
<style scoped>
/* 响应式设计 */
@media (max-width: 768px) {
.custom-header {
flex-direction: column;
gap: 16px;
padding: 12px 16px;
}
.header-left,
.header-right {
justify-content: center;
width: 100%;
}
.search-container {
width: 100%;
}
.header-right {
flex-wrap: wrap;
}
}
.custom-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.header-left {
display: flex;
align-items: center;
}
.search-container {
display: flex;
gap: 12px;
}
.search-input {
width: 300px;
border-radius: 4px;
}
.header-right {
display: flex;
gap: 12px;
align-items: center;
}
</style>

View File

@@ -0,0 +1,31 @@
<script setup>
const props = defineProps({
className: {
type: String,
default: '',
},
});
</script>
<template>
<svg
:class="props.className"
width="13.6333333px"
height="13.6333333px"
viewBox="0 0 13.6333333 13.6333333"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.66166667,10.1666667 C7.66166667,11.55014 8.78319337,12.6716667 10.1666667,12.6716667 C11.55014,12.6716667 12.6716667,11.55014 12.6716667,10.1666667 C12.6716667,8.78319337 11.55014,7.66166667 10.1666667,7.66166667 L8.04,7.66166667 C7.82966667,7.66166667 7.662,7.82966667 7.662,8.03966667 L7.662,10.1666667 L7.66166667,10.1666667 Z M10.1666667,6.99999866 C11.9155684,6.99999866 13.3333333,8.41776496 13.3333333,10.1666667 C13.3333333,11.9155684 11.9155684,13.3333333 10.1666667,13.3333333 C8.41776496,13.3333333 6.99999807,11.9155684 6.99999807,10.1666667 L6.99999807,8.04 C6.99946799,7.76406988 7.10882245,7.49928316 7.3039036,7.30413947 C7.49898474,7.10899578 7.7637364,6.99955644 8.03966667,6.99999866 L10.1666667,6.99999866 L10.1666667,6.99999866 Z M6.33333469,10.1666667 C6.33333469,11.9155684 4.91556838,13.3333333 3.16666667,13.3333333 C1.41776496,13.3333333 0,11.9155684 0,10.1666667 C0,8.41776496 1.41776496,6.99999866 3.16666667,6.99999866 L5.29366667,6.99999866 C5.56953908,6.99955669 5.83424002,7.10895032 6.02931152,7.30402182 C6.22438303,7.49909332 6.33377666,7.76379426 6.33333469,8.03966667 L6.33333469,10.1666667 Z M3.16666667,7.66166667 C1.78319337,7.66166667 0.661666672,8.78319337 0.661666672,10.1666667 C0.661666672,11.55014 1.78319337,12.6716667 3.16666667,12.6716667 C4.55013997,12.6716667 5.67166667,11.55014 5.67166667,10.1666667 L5.67166667,8.04 C5.67166667,7.83 5.504,7.662 5.29366667,7.662 L3.16666667,7.662 L3.16666667,7.66166667 Z M6.99999866,3.16633334 C7.00018409,1.41747504 8.41803055,-0.000122713956 10.1668889,7.79661757e-09 C11.9157472,0.000122729549 13.3333333,1.41791945 13.3333333,3.16677777 C13.3333333,4.91563609 11.915525,6.33333528 10.1666667,6.33333528 L8.04,6.33333528 C7.76406988,6.33386536 7.49928316,6.22451089 7.30413947,6.02942974 C7.10899578,5.8343486 6.99955644,5.56959695 6.99999866,5.29366667 L6.99999866,3.16666667 L6.99999866,3.16633334 Z M10.1666667,5.67166667 C11.55014,5.67166667 12.6716667,4.55013997 12.6716667,3.16666667 C12.6716667,1.78319337 11.55014,0.661666672 10.1666667,0.661666672 C8.78319337,0.661666672 7.66166667,1.78319337 7.66166667,3.16666667 L7.66166667,5.29366667 C7.66166667,5.50366667 7.82933334,5.67166667 8.03966667,5.67166667 L10.1666667,5.67166667 L10.1666667,5.67166667 Z M5.67166667,3.16666667 C5.67166667,1.78319337 4.55013997,0.661666672 3.16666667,0.661666672 C1.78319337,0.661666672 0.661666672,1.78319337 0.661666672,3.16666667 C0.661666672,4.55013997 1.78319337,5.67166667 3.16666667,5.67166667 L5.29366667,5.67166667 C5.504,5.67166667 5.67166667,5.50366667 5.67166667,5.29366667 L5.67166667,3.16666667 Z M3.16666667,6.33333469 C1.41776496,6.33333469 0,4.91556838 0,3.16666667 C0,1.41776496 1.41776496,0 3.16666667,0 C4.91556838,0 6.33333469,1.41776496 6.33333469,3.16666667 L6.33333469,5.29366667 C6.33377666,5.56953908 6.22438303,5.83424002 6.02931152,6.02931152 C5.83424002,6.22438303 5.56953908,6.33377666 5.29366667,6.33333469 L3.16666667,6.33333469 Z"
id="形状"
/>
</svg>
</template>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<svg
t="1765503751020"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="9797"
width="1em"
height="1em"
>
<path
d="M827.712 341.312H452.288a25.6 25.6 0 0 1-25.6-25.6V256H320c-29.44 0-54.592 10.432-75.456 31.232a102.784 102.784 0 0 0-31.232 75.456c0 29.44 10.432 54.592 31.232 75.392A102.784 102.784 0 0 0 320 469.312h384c53.056 0 98.24 18.816 135.744 56.256A184.96 184.96 0 0 1 896 661.312c0 53.056-18.752 98.304-56.256 135.808a184.96 184.96 0 0 1-135.744 56.192H554.688v59.776a25.6 25.6 0 0 1-25.6 25.6H153.6a25.6 25.6 0 0 1-25.6-25.6v-204.8a25.6 25.6 0 0 1 25.6-25.6h375.488a25.6 25.6 0 0 1 25.6 25.6V768H704c29.44 0 54.592-10.432 75.456-31.232a102.912 102.912 0 0 0 31.232-75.456c0-29.44-10.432-54.592-31.232-75.392A102.848 102.848 0 0 0 704 554.688H320a185.088 185.088 0 0 1-135.744-56.256A184.96 184.96 0 0 1 128 362.688c0-53.056 18.752-98.304 56.256-135.808A184.96 184.96 0 0 1 320 170.688h106.688v-59.776a25.6 25.6 0 0 1 25.6-25.6h375.424a25.6 25.6 0 0 1 25.6 25.6v204.8a25.6 25.6 0 0 1-25.6 25.6zM537.6 256h204.8a25.6 25.6 0 0 0 25.6-25.6v-34.112a25.6 25.6 0 0 0-25.6-25.6H537.6a25.6 25.6 0 0 0-25.6 25.6V230.4a25.6 25.6 0 0 0 25.6 25.6z m-68.288 571.712V793.6a25.6 25.6 0 0 0-25.6-25.6h-204.8a25.6 25.6 0 0 0-25.6 25.6v34.112a25.6 25.6 0 0 0 25.6 25.6h204.8a25.6 25.6 0 0 0 25.6-25.6z"
p-id="9798"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
const props = defineProps({
className: {
type: String,
default: '',
},
});
</script>
<template>
<svg
:class="props.className"
width="18px"
height="18px"
viewBox="0 0 16 16"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<rect id="矩形" opacity="0" x="0" y="0" width="16" height="16" />
<path
d="M8.93,2.138 C9.12713347,1.95017863 9.4383122,1.95462404 9.63,2.148 L12.355,4.8965 C12.4522665,4.99447053 12.5046974,5.12829734 12.499868,5.26626717 C12.4950385,5.404237 12.4333787,5.53407024 12.3295,5.625 L5.7635,11.376 C5.66962616,11.4583382 5.54834097,11.5025716 5.4235,11.4999997 L2.9895,11.448 C2.71746669,11.442286 2.49999999,11.2200933 2.49999999,10.948 L2.49999999,8.476 C2.49999999,8.33921115 2.55599094,8.20838431 2.655,8.114 L8.93,2.138 Z M13.341,10.25 C13.5196328,10.2534833 13.682838,10.3520015 13.7691377,10.5084437 C13.8554375,10.664886 13.8517208,10.8554849 13.7593877,11.0084438 C13.6670547,11.1614026 13.5001328,11.2534833 13.3215,11.25 L9.574,11.177 C9.39536721,11.1734273 9.23220968,11.0748266 9.1459873,10.9183397 C9.05976491,10.7618529 9.06357689,10.571254 9.1559873,10.4183397 C9.24839771,10.2654255 9.41536721,10.1734273 9.594,10.177 L13.341,10.25 Z M9.265,3.2 L3.5,8.69 L3.5,10.4585 L5.25,10.496 L11.2705,5.223 L9.265,3.2 Z M13.3165,12.677 C13.5926424,12.6749289 13.8181789,12.8971076 13.8202644,13.17325 C13.8223211,13.4493924 13.6001424,13.6749289 13.324,13.677 L3.504,13.75 C3.22785763,13.7520711 3.00232108,13.5298924 3.00023565,13.25375 C2.99817894,12.9776076 3.22035763,12.7520711 3.4965,12.75 L13.3165,12.677 Z"
id="形状"
/>
</svg>
</template>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<svg
width="1em"
height="1em"
viewBox="0 0 16 16"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<title>wand</title>
<defs>
<linearGradient
x1="99.8250786%"
y1="6.53996342e-13%"
x2="12.639287%"
y2="77.6796996%"
id="linearGradient-1"
>
<stop stop-color="#F17E47" offset="0%" />
<stop stop-color="#D85ABF" offset="49.9043925%" />
<stop stop-color="#717AFF" offset="100%" />
</linearGradient>
</defs>
<g
id="页面-1"
stroke="none"
stroke-width="1"
fill="none"
fill-rule="evenodd"
>
<g id="设置bot" transform="translate(-444, -80)" fill-rule="nonzero">
<g id="编组-5备份-2" transform="translate(96, 64)">
<g id="编组-3" transform="translate(336, 10)">
<g id="wand" transform="translate(12, 6)">
<rect
id="矩形"
fill="#000000"
opacity="0"
x="0"
y="0"
width="16"
height="16"
/>
<path
d="M7.06,7.81 C7.28982243,8.20717228 7.64510124,8.51672214 8.07,8.69 L3.89,13.75 C3.77849314,13.8884057 3.61587696,13.9759881 3.43895054,13.9929279 C3.26202413,14.0098676 3.08574352,13.9547328 2.95,13.84 C2.67186371,13.6019746 2.63208206,13.1864774 2.86,12.9 Z M10.0751606,10.342259 C10.1007529,10.3593205 10.1142973,10.3895436 10.11,10.42 L10.05,11 C10.0349786,11.0209157 10.0349786,11.0490843 10.05,11.07 L10.5,11.46 C10.5414214,11.4627614 10.5727614,11.4985786 10.57,11.54 C10.5672386,11.5814214 10.5314214,11.6127614 10.49,11.61 L9.92,11.73 L9.86,11.73 L9.63,12.27 C9.61591049,12.2954653 9.58910316,12.3112702 9.56,12.3112702 C9.53089684,12.3112702 9.50408951,12.2954653 9.49,12.27 L9.2,11.75 C9.18101383,11.7409941 9.15898617,11.7409941 9.14,11.75 L8.55,11.69 C8.52147194,11.6857655 8.49743138,11.6665036 8.4870782,11.6395853 C8.47672503,11.612667 8.48166207,11.5822599 8.5,11.56 L8.9,11.13 C8.90944584,11.1076226 8.90944584,11.0823774 8.9,11.06 L8.77,10.48 C8.76794796,10.4523802 8.78032765,10.4256638 8.80272638,10.4093738 C8.82512511,10.3930838 8.85435607,10.3895379 8.88,10.4 L9.42,10.64 C9.44091574,10.6550214 9.46908426,10.6550214 9.49,10.64 L9.99,10.34 C10.0164608,10.3243193 10.0495684,10.3251975 10.0751606,10.342259 Z M11.6310879,2.06789708 C11.723319,2.12580967 11.773603,2.23194717 11.76,2.34 L11.52,4.48 C11.5119641,4.57445213 11.5490415,4.66714566 11.62,4.73 L13.24,6.14 C13.3130482,6.21823611 13.3351022,6.33127196 13.2968207,6.4312292 C13.2585392,6.53118644 13.1666211,6.6005734 13.06,6.61 L11,7 C10.9100357,7.02152566 10.835738,7.0846787 10.8,7.17 L9.96,9.17 C9.91461323,9.27007429 9.81488549,9.33434966 9.705,9.33434966 C9.59511451,9.33434966 9.49538677,9.27007429 9.45,9.17 L8.34,7.35 C8.29373222,7.26570734 8.2061264,7.21238206 8.11,7.21 L6,7 C5.89344307,6.98628907 5.8031975,6.91479202 5.76547441,6.8141971 C5.72775131,6.71360218 5.74873279,6.60039499 5.82,6.52 L7.23,5 C7.31119031,4.92297534 7.33871717,4.80500308 7.3,4.7 L6.82,2.61 C6.79547628,2.50301623 6.8334527,2.39135455 6.91811751,2.32150608 C7.00278233,2.2516576 7.11962606,2.23559266 7.22,2.28 L9.22,3.17 C9.30451125,3.21445166 9.40548875,3.21445166 9.49,3.17 L11.33,2.07 C11.4214132,2.01080476 11.5388567,2.00998449 11.6310879,2.06789708 Z M4.38174625,4.39636653 C4.40885368,4.41513321 4.42353002,4.44721982 4.42,4.48 L4.35,5.09 C4.33924996,5.11209516 4.33924996,5.13790484 4.35,5.16 L4.82,5.57 C4.84386802,5.58382847 4.86007635,5.60783875 4.8639774,5.63514609 C4.86787845,5.66245342 4.85904132,5.69004162 4.84,5.71 L4.23,5.83 L4.17,5.83 L3.93,6.4 C3.91332529,6.42513807 3.88516568,6.44025063 3.855,6.44025063 C3.82483432,6.44025063 3.79667471,6.42513807 3.78,6.4 L3.47,5.86 C3.45075739,5.85221598 3.42924261,5.85221598 3.41,5.86 L2.79,5.8 C2.75945815,5.7951621 2.73353862,5.77498342 2.72135759,5.74656103 C2.70917657,5.71813864 2.71243996,5.68545299 2.73,5.66 L3.15,5.21 C3.16429062,5.18524791 3.16429062,5.15475209 3.15,5.13 L3,4.55 C2.99439094,4.51822247 3.00622377,4.48587394 3.03101332,4.46521598 C3.05580287,4.44455803 3.08975488,4.43875251 3.12,4.45 L3.68,4.71 C3.70475209,4.72429062 3.73524791,4.72429062 3.76,4.71 L4.29,4.39 C4.31943968,4.37515728 4.35463882,4.37759984 4.38174625,4.39636653 Z"
id="形状结合"
fill="url(#linearGradient-1)"
/>
</g>
</g>
</g>
</g>
</g>
</svg>
</template>

View File

@@ -0,0 +1,40 @@
<script setup>
const props = defineProps({
className: {
type: String,
default: '',
},
});
</script>
<template>
<svg
width="16px"
height="16px"
:class="props.className"
viewBox="0 0 16 16"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-483, -587)" fill="#FFFFFF" fill-rule="nonzero">
<g transform="translate(468, 579)">
<g transform="translate(15, 8)">
<rect opacity="0" x="0" y="0" width="16" height="16" />
<path
d="M2.08296875,4.633625 L13.9143281,4.633625 C14.2234219,4.633625 14.4760625,4.38098438 14.4760625,4.07190625 C14.4760625,3.7628125 14.2261094,3.51015625 13.9143438,3.51015625 L2.08296875,3.51015625 C1.773875,3.51015625 1.52123437,3.7628125 1.52123437,4.07189063 C1.52123437,4.38098438 1.77389062,4.633625 2.08295313,4.633625 L2.08296875,4.633625 Z M13.9143281,7.43960938 L2.08298438,7.43960938 C1.77389063,7.43960938 1.52125,7.69226563 1.52125,8.00134375 C1.52125,8.3104375 1.77390625,8.56307813 2.08296875,8.56307813 L13.9143281,8.56307813 C14.2234219,8.56307813 14.4760625,8.31042188 14.4760625,8.00134375 C14.4760625,7.69225 14.2234063,7.439625 13.9143437,7.439625 L13.9143281,7.43960938 Z M13.9143281,11.366375 L2.08298438,11.366375 C1.77389063,11.366375 1.52125,11.6163281 1.52125,11.9280938 C1.52125,12.2398594 1.77390625,12.4898438 2.08296875,12.4898438 L13.9143281,12.4898438 C14.2234219,12.4898438 14.4760625,12.239875 14.4760625,11.9281094 C14.4760625,11.6163438 14.2234063,11.366375 13.9143437,11.366375 L13.9143281,11.366375 Z"
/>
</g>
</g>
</g>
</g>
</svg>
</template>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,46 @@
<script setup>
const props = defineProps({
className: {
type: String,
default: '',
},
});
</script>
<template>
<svg
:class="props.className"
width="18px"
height="18px"
viewBox="0 0 18 18"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<title>编组 12</title>
<g
id="页面-1"
stroke="none"
stroke-width="1"
fill="none"
fill-rule="currentColor"
>
<g id="知识库" transform="translate(-25, -161)">
<g id="编组-12" transform="translate(24, 160)">
<rect id="矩形" x="0" y="0" width="20" height="20" />
<path
d="M16.4912888,10.7980951 C15.7969114,10.7980951 15.3264751,11.0844769 15.3264751,11.0844769 C14.9748359,11.2984761 14.687007,11.1435874 14.687007,10.7402321 L14.687007,6.42579246 C14.687007,6.0224371 14.3475878,5.6924189 13.9327401,5.6924189 L8.1353671,5.6924189 C7.72051942,5.6924189 7.59855437,5.4390385 7.86435829,5.12933468 C7.86435829,5.12933468 8.43699845,4.46211097 8.43699845,3.61543104 C8.43699845,2.17097789 7.23265922,1 5.74698006,1 C4.26137547,1 3.05703726,2.17097789 3.05703726,3.61543104 C3.05703726,4.46211097 3.62960182,5.12933468 3.62960182,5.12933468 C3.89540472,5.4390385 3.77344069,5.6924189 3.35859301,5.6924189 L1.75426685,5.6924189 C1.33942018,5.6924189 1,6.0224371 1,6.42579246 L1,9.6803585 C1,10.0837149 1.29439047,10.2495305 1.65425121,10.0487328 C1.65425121,10.0487328 1.86348477,9.93197883 2.39124538,9.93197883 C3.60735002,9.93197883 4.59317781,10.8904256 4.59317781,12.0728439 C4.59317781,13.2552633 3.60735002,14.2137826 2.39124538,14.2137826 C1.86348477,14.2137826 1.65425121,14.097103 1.65425121,14.097103 C1.29439047,13.8965249 1,14.0621945 1,14.4655509 L1,18.2666264 C1,18.6699818 1.33942018,19 1.75426685,19 L4.66566218,19 C5.08050986,19 5.24682528,18.7161113 5.03517879,18.3690793 C5.03517879,18.3690793 4.90536911,18.1563257 4.90536911,17.6363653 C4.90536911,16.4705942 5.87716669,15.525935 7.07584745,15.525935 C8.27460483,15.525935 9.24640241,16.4708137 9.24640241,17.6363653 C9.24640241,18.1563257 9.11659274,18.3690793 9.11659274,18.3690793 C8.90502081,18.7158908 9.07133623,19 9.4861829,19 L13.9327401,19 C14.3475878,19 14.687007,18.6699818 14.687007,18.2666264 L14.687007,16.0863791 C14.687007,15.6830238 14.9746101,15.5281351 15.3263239,15.7422803 C15.3263239,15.7422803 15.7969114,16.0285896 16.4912888,16.0285896 C17.9767432,16.0285896 18.8728875,14.0825824 18.8728875,14.0825824 C19.0423708,13.7144286 19.0423708,13.1120347 18.8728875,12.7438818 C18.8728875,12.7441013 17.9767432,10.7980951 16.4912888,10.7980951 Z"
id="Fill-257"
fill="#ACB7C6"
/>
</g>
</g>
</g>
</svg>
</template>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,31 @@
<script setup>
const props = defineProps({
className: {
type: String,
default: '',
},
});
</script>
<template>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
xmlns="http://www.w3.org/2000/svg"
:class="props.className"
>
<path
d="M9.702 14L1.335 14C0.586 14 0 13.393 0 12.69L0 10.676C0 10.516 0.065 10.388 0.195 10.292C0.326 10.196 0.488 10.164 0.619 10.196C0.781 10.228 0.944 10.26 1.074 10.26C2.019 10.26 2.8 9.493 2.8 8.566C2.8 7.639 2.019 6.872 1.074 6.872C0.944 6.872 0.781 6.904 0.619 6.936C0.456 6.968 0.326 6.936 0.195 6.84C0.065 6.744 0 6.616 0 6.457L0 4.443C0 3.708 0.619 3.132 1.335 3.132L2.833 3.132C2.8 2.973 2.8 2.813 2.8 2.653C2.8 1.183 4.005 0 5.502 0C7 0 8.205 1.183 8.205 2.653C8.205 2.813 8.205 2.973 8.172 3.132L9.67 3.132C10.419 3.132 11.005 3.74 11.005 4.443L11.005 5.913L11.298 5.913C12.795 5.913 14 7.096 14 8.566C14 10.037 12.795 11.219 11.298 11.219L11.005 11.219L11.005 12.69C11.07 13.393 10.451 14 9.702 14ZM0.977 11.219L0.977 12.658C0.977 12.849 1.14 13.009 1.335 13.009L9.702 13.009C9.898 13.009 10.06 12.849 10.06 12.658L10.06 10.58C10.06 10.42 10.126 10.26 10.256 10.196C10.386 10.1 10.549 10.1 10.712 10.132C10.907 10.196 11.102 10.26 11.298 10.26C12.242 10.26 13.023 9.493 13.023 8.566C13.023 7.639 12.242 6.872 11.298 6.872C11.102 6.872 10.907 6.904 10.712 7C10.549 7.064 10.386 7.032 10.256 6.936C10.126 6.84 10.06 6.712 10.06 6.553L10.06 4.443C10.06 4.251 9.898 4.091 9.702 4.091L7.521 4.091C7.358 4.091 7.195 3.995 7.098 3.868C7 3.74 7 3.548 7.065 3.42C7.195 3.164 7.228 2.941 7.228 2.685C7.228 1.758 6.447 0.991 5.502 0.991C4.558 0.991 3.777 1.758 3.777 2.685C3.777 2.941 3.842 3.196 3.94 3.42C4.005 3.58 4.005 3.74 3.907 3.868C3.81 3.995 3.647 4.091 3.484 4.091L1.335 4.091C1.14 4.091 0.977 4.251 0.977 4.443L0.977 5.881C2.507 5.817 3.777 7.032 3.777 8.534C3.777 10.005 2.572 11.187 1.074 11.187C1.042 11.219 1.009 11.219 0.977 11.219Z"
fill="currentColor"
fill-rule="nonzero"
/>
</svg>
</template>
<style scoped>
svg {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<svg
data-v-dd795da4=""
data-v-5179693f=""
class="loading-svg"
color="currentColor"
viewBox="0 0 1000 1000"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<title data-v-dd795da4="">Speech Recording</title>
<rect
data-v-dd795da4=""
fill="currentColor"
rx="70"
ry="70"
height="250"
width="140"
x="0"
y="375"
>
<animate
data-v-dd795da4=""
attributeName="height"
values="250; 500; 250"
keyTimes="0; 0.5; 1"
dur="0.8s"
begin="0s"
repeatCount="indefinite"
/>
<animate
data-v-dd795da4=""
attributeName="y"
values="375; 250; 375"
keyTimes="0; 0.5; 1"
dur="0.8s"
begin="0s"
repeatCount="indefinite"
/>
</rect>
<rect
data-v-dd795da4=""
fill="currentColor"
rx="70"
ry="70"
height="250"
width="140"
x="286.66666666666663"
y="375"
>
<animate
data-v-dd795da4=""
attributeName="height"
values="250; 500; 250"
keyTimes="0; 0.5; 1"
dur="0.8s"
begin="0.2s"
repeatCount="indefinite"
/>
<animate
data-v-dd795da4=""
attributeName="y"
values="375; 250; 375"
keyTimes="0; 0.5; 1"
dur="0.8s"
begin="0.2s"
repeatCount="indefinite"
/>
</rect>
<rect
data-v-dd795da4=""
fill="currentColor"
rx="70"
ry="70"
height="250"
width="140"
x="573.3333333333333"
y="375"
>
<animate
data-v-dd795da4=""
attributeName="height"
values="250; 500; 250"
keyTimes="0; 0.5; 1"
dur="0.8s"
begin="0.4s"
repeatCount="indefinite"
/>
<animate
data-v-dd795da4=""
attributeName="y"
values="375; 250; 375"
keyTimes="0; 0.5; 1"
dur="0.8s"
begin="0.4s"
repeatCount="indefinite"
/>
</rect>
<rect
data-v-dd795da4=""
fill="currentColor"
rx="70"
ry="70"
height="250"
width="140"
x="859.9999999999999"
y="375"
>
<animate
data-v-dd795da4=""
attributeName="height"
values="250; 500; 250"
keyTimes="0; 0.5; 1"
dur="0.8s"
begin="0.6000000000000001s"
repeatCount="indefinite"
/>
<animate
data-v-dd795da4=""
attributeName="y"
values="375; 250; 375"
keyTimes="0; 0.5; 1"
dur="0.8s"
begin="0.6000000000000001s"
repeatCount="indefinite"
/>
</rect>
</svg>
</template>

View File

@@ -0,0 +1,37 @@
<template>
<svg
width="30px"
height="30px"
viewBox="0 0 30 30"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g
id="页面-1"
stroke="none"
stroke-width="1"
fill="none"
fill-rule="evenodd"
>
<g id="运行Bot备份-2" transform="translate(-1185, -791)">
<g id="编组-11" transform="translate(1185, 791)">
<g id="矩形-2" fill="#0066FF">
<rect id="矩形" x="0" y="0" width="30" height="30" rx="15" />
</g>
<g
id="发送-实色"
transform="translate(9, 10)"
fill="#FFFFFF"
fill-rule="nonzero"
>
<path
d="M11.6691674,0.239363769 C11.3947722,0.00461392042 11.0102658,-0.0631322672 10.6652017,0.0613280807 L0.636969787,3.6945732 C0.263984798,3.83008097 0.0191472071,4.15148685 0.00107884118,4.53436129 C-0.0170055712,4.91722033 0.193315985,5.25912086 0.553174932,5.42769394 L2.11910533,6.16191613 C2.15033183,6.17608698 2.1782688,6.19658162 2.20291623,6.21863029 C2.36557966,6.62039919 3.00806572,8.19439408 3.29396632,8.76316664 C3.43856139,9.05307662 3.70475688,9.25631519 3.95123121,9.34455602 C3.93479959,9.34297122 3.91673122,9.3398324 3.89701007,9.3366782 C3.94466819,9.35400325 3.99396306,9.36818948 4.04489467,9.37763671 C4.37023758,9.44065929 4.7037963,9.34455602 4.93712855,9.1192534 L5.52045114,8.55992807 C5.62396715,8.46065521 5.78664663,8.43860655 5.91481008,8.50792214 L8.42721154,9.87552427 C8.57836963,9.9574413 8.74597539,10 8.91358115,10 C9.04503412,10 9.17813989,9.97476635 9.3030138,9.92279119 C9.58893045,9.80462387 9.79596247,9.57142805 9.87154152,9.28153345 L11.9698789,1.1705315 C12.0602529,0.830200382 11.9435948,0.472544209 11.6691835,0.239363769 L11.6691674,0.239363769 Z M2.82731788,6.02483896 L8.20373185,3.07537951 L5.16882405,6.05635025 C5.11952919,6.10518658 5.08337641,6.16033134 5.05872897,6.2249387 C5.05707618,6.22809291 5.05707618,6.22966232 5.05543944,6.23281652 C5.04886037,6.25172637 4.44910616,7.94859682 4.09418954,8.71589971 C4.02188398,8.67020219 3.93479959,8.59616298 3.88386798,8.4937513 C3.62096202,7.96435246 3.00804967,6.46599696 2.82731788,6.02483896 L2.82731788,6.02483896 Z"
id="形状"
/>
</g>
</g>
</g>
</g>
</svg>
</template>

View File

@@ -0,0 +1,38 @@
<template>
<svg
width="30px"
height="30px"
viewBox="0 0 30 30"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<g
id="页面-1"
stroke="none"
stroke-width="1"
fill="none"
fill-rule="evenodd"
>
<g id="设置bot" transform="translate(-1370, -830)">
<g id="编组-7" transform="translate(992, 64)">
<g id="编组-11" transform="translate(378, 766)">
<g id="矩形-2" fill="#DFDFDF">
<rect id="矩形" x="0" y="0" width="30" height="30" rx="15" />
</g>
<g
id="发送-实色"
transform="translate(9, 10)"
fill="#FFFFFF"
fill-rule="nonzero"
>
<path
d="M11.6691674,0.239363769 C11.3947722,0.00461392042 11.0102658,-0.0631322672 10.6652017,0.0613280807 L0.636969787,3.6945732 C0.263984798,3.83008097 0.0191472071,4.15148685 0.00107884118,4.53436129 C-0.0170055712,4.91722033 0.193315985,5.25912086 0.553174932,5.42769394 L2.11910533,6.16191613 C2.15033183,6.17608698 2.1782688,6.19658162 2.20291623,6.21863029 C2.36557966,6.62039919 3.00806572,8.19439408 3.29396632,8.76316664 C3.43856139,9.05307662 3.70475688,9.25631519 3.95123121,9.34455602 C3.93479959,9.34297122 3.91673122,9.3398324 3.89701007,9.3366782 C3.94466819,9.35400325 3.99396306,9.36818948 4.04489467,9.37763671 C4.37023758,9.44065929 4.7037963,9.34455602 4.93712855,9.1192534 L5.52045114,8.55992807 C5.62396715,8.46065521 5.78664663,8.43860655 5.91481008,8.50792214 L8.42721154,9.87552427 C8.57836963,9.9574413 8.74597539,10 8.91358115,10 C9.04503412,10 9.17813989,9.97476635 9.3030138,9.92279119 C9.58893045,9.80462387 9.79596247,9.57142805 9.87154152,9.28153345 L11.9698789,1.1705315 C12.0602529,0.830200382 11.9435948,0.472544209 11.6691835,0.239363769 L11.6691674,0.239363769 Z M2.82731788,6.02483896 L8.20373185,3.07537951 L5.16882405,6.05635025 C5.11952919,6.10518658 5.08337641,6.16033134 5.05872897,6.2249387 C5.05707618,6.22809291 5.05707618,6.22966232 5.05543944,6.23281652 C5.04886037,6.25172637 4.44910616,7.94859682 4.09418954,8.71589971 C4.02188398,8.67020219 3.93479959,8.59616298 3.88386798,8.4937513 C3.62096202,7.96435246 3.00804967,6.46599696 2.82731788,6.02483896 L2.82731788,6.02483896 Z"
id="形状"
/>
</g>
</g>
</g>
</g>
</g>
</svg>
</template>

View File

@@ -0,0 +1,47 @@
<template>
<svg
data-v-cabe7c8e=""
viewBox="0 0 1000 1000"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
class="loading-svg"
>
<title>Loading</title>
<rect
fill="currentColor"
height="250"
rx="24"
ry="24"
width="250"
x="375"
y="375"
/>
<circle
cx="500"
cy="500"
fill="none"
r="450"
stroke="currentColor"
stroke-width="100"
opacity="0.45"
/>
<circle
cx="500"
cy="500"
fill="none"
r="450"
stroke="currentColor"
stroke-width="100"
stroke-dasharray="600 9999999"
>
<animateTransform
attributeName="transform"
dur="1s"
from="0 500 500"
repeatCount="indefinite"
to="360 500 500"
type="rotate"
/>
</circle>
</svg>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { preferences } from '@easyflow/preferences';
import { ElEmpty } from 'element-plus';
import { JsonViewer } from 'vue3-json-viewer';
import 'vue3-json-viewer/dist/vue3-json-viewer.css';
defineProps({
value: {
required: true,
},
});
const themeMode = ref(preferences.theme.mode);
watch(
() => preferences.theme.mode,
(newVal) => {
themeMode.value = newVal;
},
);
</script>
<template>
<div class="res-container">
<JsonViewer v-if="value" :value="value" copyable :theme="themeMode" />
<ElEmpty
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
v-else
/>
</div>
</template>
<style scoped>
.res-container {
padding: 10px;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
}
</style>

View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useAccess } from '@easyflow/access';
import { MoreFilled } from '@element-plus/icons-vue';
import {
ElAvatar,
ElButton,
ElCard,
ElDivider,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElIcon,
ElText,
} from 'element-plus';
export interface ActionButton {
icon: any;
text: string;
className: string;
permission: string;
onClick: (row: any) => void;
}
export interface CardListProps {
iconField?: string;
titleField?: string;
descField?: string;
actions?: ActionButton[];
defaultIcon: any;
data: any[];
}
const props = withDefaults(defineProps<CardListProps>(), {
iconField: 'icon',
titleField: 'title',
descField: 'description',
actions: () => [],
});
const { hasAccessByCodes } = useAccess();
const filterActions = computed(() => {
return props.actions.filter((action) => {
return hasAccessByCodes([action.permission]);
});
});
const visibleActions = computed(() => {
return filterActions.value.length <= 3
? filterActions.value
: filterActions.value.slice(0, 3);
});
const hiddenActions = computed(() => {
return filterActions.value.length > 3 ? filterActions.value.slice(3) : [];
});
</script>
<template>
<div class="card-grid">
<ElCard
v-for="(item, index) in props.data"
:key="index"
shadow="hover"
footer-class="foot-c"
:style="{
'--el-box-shadow-light': '0px 2px 12px 0px rgb(100 121 153 10%)',
}"
>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
<ElAvatar
class="shrink-0"
:src="item[iconField] || defaultIcon"
:size="36"
/>
<ElText truncated size="large" class="font-medium">
{{ item[titleField] }}
</ElText>
</div>
<ElText line-clamp="2" class="item-desc w-full">
{{ item[titleField] }}
</ElText>
</div>
<template #footer>
<div :class="visibleActions.length > 2 ? 'footer-div' : ''">
<template v-for="(action, idx) in visibleActions" :key="idx">
<ElButton
:icon="typeof action.icon === 'string' ? undefined : action.icon"
size="small"
:style="{
'--el-button-text-color': 'hsl(220deg 9.68% 63.53%)',
'--el-button-font-weight': 400,
}"
link
@click="action.onClick(item)"
>
<template v-if="typeof action.icon === 'string'" #icon>
<IconifyIcon :icon="action.icon" />
</template>
{{ action.text }}
</ElButton>
<ElDivider
v-if="
filterActions.length <= 3
? idx < filterActions.length - 1
: true
"
direction="vertical"
/>
</template>
<ElDropdown v-if="hiddenActions.length > 0" trigger="click">
<ElButton
:style="{
'--el-button-text-color': 'hsl(220deg 9.68% 63.53%)',
'--el-button-font-weight': 400,
}"
:icon="MoreFilled"
link
/>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="(action, idx) in hiddenActions"
:key="idx"
@click="action.onClick(item)"
>
<template #default>
<div :class="`${action.className} handle-div`">
<ElIcon v-if="action.icon">
<component :is="action.icon" />
</ElIcon>
{{ action.text }}
</div>
</template>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
</ElCard>
</div>
</template>
<style scoped>
/* 响应式调整 */
@media (max-width: 1024px) {
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
}
@media (max-width: 768px) {
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
}
@media (max-width: 480px) {
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
:deep(.el-card__footer) {
border-top: none;
}
.footer-div {
display: flex;
justify-content: space-between;
padding: 8px 20px;
background-color: hsl(var(--background-deep));
border-radius: 8px;
}
.handle-div {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
min-width: max(100%, 600px); /* 确保至少显示2个卡片 */
}
.item-desc {
height: 40px;
font-size: clamp(8px, 1vw, 14px);
line-height: 20px;
color: #75808d;
}
.item-danger {
color: var(--el-color-danger);
}
</style>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue';
import { preferences } from '@easyflow/preferences';
import { ElEmpty, ElPagination } from 'element-plus';
import { api } from '#/api/request';
interface PageDataProps {
pageUrl: string;
pageSize?: number;
pageSizes?: number[];
extraQueryParams?: Record<string, any>;
}
const props = withDefaults(defineProps<PageDataProps>(), {
pageSize: 10,
pageSizes: () => [10, 20, 50, 100],
extraQueryParams: () => ({}),
});
// 响应式数据
const pageList = ref([]);
const loading = ref(false);
const queryParams = ref({});
const pageInfo = reactive({
pageNumber: 1,
pageSize: props.pageSize,
total: 0,
});
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
const doGet = async (params: any) => {
loading.value = true;
try {
// 这里替换为你的实际 API 调用
// 例如return await api.get(props.pageUrl, { params })
const response = await api.get(`${props.pageUrl}`, {
params,
});
const data = await response.data;
return { data };
} finally {
loading.value = false;
}
};
// 获取页面数据
const getPageList = async () => {
try {
const res = await doGet({
pageNumber: pageInfo.pageNumber,
pageSize: pageInfo.pageSize,
...props.extraQueryParams,
...queryParams.value,
});
pageList.value = res.data?.records || [];
pageInfo.total = res.data?.totalRow || 0;
} catch (error) {
console.error('get data error:', error);
pageList.value = [];
pageInfo.total = 0;
}
};
// 分页事件处理
const handleSizeChange = (newSize: number) => {
pageInfo.pageSize = newSize;
pageInfo.pageNumber = 1; // 重置到第一页
};
const handleCurrentChange = (newPage: number) => {
pageInfo.pageNumber = newPage;
};
// 暴露给父组件的方法 (替代 useImperativeHandle)
const setQuery = (newQueryParams: string) => {
pageInfo.pageNumber = 1;
pageInfo.pageSize = props.pageSize;
queryParams.value = newQueryParams;
getPageList();
};
// 暴露方法给父组件
defineExpose({
setQuery,
});
// 监听器
watch(
[() => pageInfo.pageNumber, () => pageInfo.pageSize],
() => {
getPageList();
},
{ deep: true },
);
// 生命周期
onMounted(() => {
getPageList();
});
</script>
<template>
<div class="page-data-container" v-loading="loading">
<template v-if="pageList.length > 0">
<div>
<slot :page-list="pageList"></slot>
</div>
<div v-if="pageInfo.total > pageInfo.pageSize" class="mx-auto mt-8 w-fit">
<ElPagination
v-model:current-page="pageInfo.pageNumber"
v-model:page-size="pageInfo.pageSize"
:total="pageInfo.total"
:page-sizes="pageSizes"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<ElEmpty
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
v-else
/>
</div>
</template>

View File

@@ -0,0 +1,236 @@
<script setup lang="ts" generic="T extends { icon?: any; [key: string]: any }">
import type { Component } from 'vue';
import { ref, watch } from 'vue';
import { preferences } from '@easyflow/preferences';
import { cn } from '@easyflow/utils';
import { MoreFilled } from '@element-plus/icons-vue';
import {
ElButton,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElIcon,
} from 'element-plus';
interface Props {
title?: string;
menus: T[];
labelKey: string;
valueKey: string;
iconSize?: number;
controlBtns?: {
icon?: any;
label: string;
onClick: (_: T) => void;
type?: any;
}[];
footerButton?: {
icon?: any;
label: string;
onClick: () => void;
};
defaultSelected?: string;
}
const props = withDefaults(defineProps<Props>(), {
title: '',
iconSize: 16,
controlBtns: () => [],
footerButton: undefined,
defaultSelected: '',
});
const emits = defineEmits<{
(e: 'change', item: T): void;
}>();
const panelWidth = ref(225);
const selected = ref<string>(props.defaultSelected ?? '');
const hoverId = ref<string>();
const handleChange = (item: T) => {
selected.value = item[props.valueKey];
emits('change', item);
};
// 监听 defaultSelected 的变化
watch(
() => props.defaultSelected,
(newVal) => {
if (newVal) {
selected.value = newVal;
const item = props.menus.find((menu) => menu[props.valueKey] === newVal);
if (item) {
emits('change', item);
}
}
},
{ immediate: true },
);
const handleMouseEvent = (id?: string) => {
if (id === undefined) {
setTimeout(() => {
hoverId.value = id;
}, 200);
} else {
hoverId.value = id;
}
};
const isComponent = (icon: any) => {
return typeof icon !== 'string';
};
const isSvgString = (icon: any) => {
if (typeof icon !== 'string') return false;
// 简单判断:是否包含 SVG 根标签
return icon.trim().startsWith('<svg') && icon.trim().endsWith('</svg>');
};
</script>
<template>
<div
class="flex h-full w-[225px] flex-col rounded-lg border border-[var(--el-border-color)] bg-[var(--el-bg-color)] p-2"
:style="{ width: `${panelWidth}px` }"
>
<div class="flex flex-1 flex-col gap-5 overflow-hidden">
<h3 v-if="title && title.length > 0" class="text-base font-medium">
{{ title }}
</h3>
<div class="flex-1 overflow-auto">
<div
v-for="item in menus"
:key="item[valueKey]"
class="group list-item"
:class="{
selected: selected === item[valueKey],
}"
@click="handleChange(item)"
>
<div class="flex items-center gap-1">
<div
v-if="item.icon"
class="ml-[-3px] flex items-center justify-center"
>
<div
v-if="isSvgString(item.icon)"
v-html="item.icon"
:style="{
width: `${iconSize}px`,
height: `${iconSize}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}"
class="svg-container"
></div>
<img
v-else-if="
typeof item.icon === 'string' && !isComponent(item.icon)
"
:src="item.icon"
:style="{
width: `${iconSize}px`,
height: `${iconSize}px`,
objectFit: 'contain',
}"
/>
<ElIcon v-else>
<component :is="item.icon as Component" v-bind="$attrs" />
</ElIcon>
</div>
<div>
{{ item[labelKey] }}
</div>
</div>
<ElDropdown
v-if="controlBtns.length > 0 && !['', '0'].includes(item[valueKey])"
@click.stop
>
<div
:class="
cn(
'group-hover:!inline-flex',
(!hoverId || item.id !== hoverId) && '!hidden',
)
"
>
<ElIcon>
<MoreFilled />
</ElIcon>
</div>
<template #dropdown>
<div
@mouseenter="handleMouseEvent(item.id)"
@mouseleave="handleMouseEvent()"
>
<ElDropdownMenu>
<ElDropdownItem
v-for="btn in controlBtns"
:key="btn.label"
@click="btn.onClick(item)"
>
<ElButton :type="btn.type" :icon="btn.icon" link>
{{ btn.label }}
</ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</div>
</template>
</ElDropdown>
</div>
<ElEmpty
v-if="menus.length <= 0"
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
/>
</div>
</div>
<ElButton
v-if="footerButton"
@click="footerButton.onClick"
:icon="footerButton.icon"
plain
>
{{ footerButton.label }}
</ElButton>
</div>
</template>
<style scoped>
.list-item {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
padding: 10px;
margin-bottom: 4px;
font-size: 14px;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.list-item:hover {
background-color: hsl(var(--accent));
}
.list-item.selected {
color: hsl(var(--primary));
background-color: hsl(var(--primary) / 15%);
}
.list-item.selected:where(.dark, .dark *) {
color: hsl(var(--accent-foreground));
background-color: hsl(var(--accent));
}
.svg-container :deep(svg) {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
object-fit: contain;
}
</style>

View File

@@ -0,0 +1,226 @@
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
/** 背景颜色 */
backgroundColor?: string;
/** 文字颜色 */
textColor?: string;
/** 标签文本内容 */
text: number | string;
/** 标签尺寸 */
size?: 'large' | 'medium' | 'small';
/** 是否可关闭 */
closable?: boolean;
/** 是否为圆形标签 */
round?: boolean;
/** 边框类型 */
border?: 'dashed' | 'none' | 'solid';
/** 边框颜色 */
borderColor?: string;
/** 主题类型 */
type?: 'danger' | 'info' | 'primary' | 'success' | 'warning';
}
const props = withDefaults(defineProps<Props>(), {
backgroundColor: 'rgb(236, 245, 255)',
textColor: '#409eff',
size: 'medium',
closable: false,
round: false,
border: 'solid',
borderColor: '',
type: 'primary',
});
const emit = defineEmits<{
close: [text: number | string];
}>();
// 根据类型自动设置颜色
const tagStyle = computed(() => {
const style: Record<string, string> = {};
switch (props.type) {
case 'danger': {
style.backgroundColor = 'rgb(254, 240, 240)';
style.color = '#f56c6c';
style.borderColor = props.borderColor || '#f56c6c';
break;
}
case 'info': {
style.backgroundColor = 'rgb(244, 244, 245)';
style.color = '#909399';
style.borderColor = props.borderColor || '#909399';
break;
}
case 'primary': {
style.backgroundColor = 'rgb(236, 245, 255)';
style.color = '#409eff';
style.borderColor = props.borderColor || '#409eff';
break;
}
case 'success': {
style.backgroundColor = 'rgb(240, 249, 235)';
style.color = '#67c23a';
style.borderColor = props.borderColor || '#67c23a';
break;
}
case 'warning': {
style.backgroundColor = 'rgb(253, 246, 236)';
style.color = '#e6a23c';
style.borderColor = props.borderColor || '#e6a23c';
break;
}
// No default
}
// 自定义颜色优先级高于类型预设
if (props.backgroundColor) {
style.backgroundColor = props.backgroundColor;
}
if (props.textColor) {
style.color = props.textColor;
}
if (props.borderColor) {
style.borderColor = props.borderColor;
}
return style;
});
const handleClose = () => {
emit('close', props.text);
};
// 尺寸映射
const sizeMap = {
small: {
fontSize: '12px',
padding: '0 8px',
height: '24px',
},
medium: {
fontSize: '14px',
padding: '0 12px',
height: '32px',
},
large: {
fontSize: '16px',
padding: '0 16px',
height: '40px',
},
};
</script>
<template>
<div
class="tag"
:class="[`tag--${size}`, { 'tag--round': round }, `tag--border-${border}`]"
:style="[
tagStyle,
{
fontSize: sizeMap[size].fontSize,
padding: sizeMap[size].padding,
height: sizeMap[size].height,
lineHeight: sizeMap[size].height,
},
]"
>
<span class="tag__content">
<slot>{{ text }}</slot>
</span>
<span v-if="closable" class="tag__close" @click.stop="handleClose">
<slot name="close-icon"> × </slot>
</span>
</div>
</template>
<style scoped>
.tag {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, sans-serif;
white-space: nowrap;
user-select: none;
border-radius: 4px;
transition: all 0.2s ease-in-out;
}
.tag--small {
font-size: 12px;
}
.tag--medium {
font-size: 14px;
}
.tag--large {
font-size: 16px;
}
.tag--round {
border-radius: 9999px;
}
.tag--border-solid {
border-style: solid;
}
.tag--border-dashed {
border-style: dashed;
}
.tag--border-none {
border: none;
}
.tag__content {
display: inline-flex;
align-items: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.tag__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: 4px;
font-size: 12px;
line-height: 1;
cursor: pointer;
border-radius: 50%;
transition: background-color 0.2s;
}
.tag__close:hover {
background-color: rgb(0 0 0 / 10%);
}
/* 为可关闭标签调整内边距 */
.tag:has(.tag__close) {
padding-right: 8px;
}
.tag:has(.tag__close).tag--small {
padding-right: 6px;
}
.tag:has(.tag__close).tag--large {
padding-right: 12px;
}
</style>

View File

@@ -0,0 +1,262 @@
<script setup lang="ts">
import type { TreeV2Instance } from 'element-plus';
import { nextTick, onMounted, ref, watch } from 'vue';
import { ElMessage, ElTreeV2 } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
// 定义组件属性
const props = defineProps({
// 获取树数据的URL
dataUrl: {
type: String,
required: true,
},
// 已选择的节点数组(支持双向绑定)
modelValue: {
type: Array<any>,
default: () => [],
},
// 节点键名
nodeKey: {
type: String,
default: 'id',
},
// 树形配置
defaultProps: {
type: Object,
default: () => ({
children: 'children',
label: 'label',
}),
},
// 是否默认展开所有节点
defaultExpandAll: {
type: Boolean,
default: false,
},
// 是否严格遵循父子不互相关联
checkStrictly: {
type: Boolean,
default: false,
},
// 树的高度
height: {
type: Number,
default: 200,
},
// 是否显示子节点数量
showCount: {
type: Boolean,
default: true,
},
// 是否显示已选择区域
showSelected: {
type: Boolean,
default: true,
},
// 加载提示文本
loadingText: {
type: String,
default: $t('message.loading'),
},
});
// 定义事件
const emit = defineEmits(['update:modelValue', 'change', 'check']);
// 响应式数据
const treeData = ref([]);
const loading = ref(false);
const treeRef = ref<TreeV2Instance>();
const nodeMap = ref(new Map()); // 用于存储节点键到节点数据的映射
watch(
[() => props.modelValue, () => treeData.value],
([newVal, treeDataVal]) => {
const value = newVal || [];
if (treeDataVal && treeDataVal.length > 0) {
nextTick(() => {
if (treeRef.value) {
treeRef.value.setCheckedKeys(value);
}
});
}
},
{ immediate: true, deep: true },
);
// 过滤节点方法
const filterNode = (value: any, data: any) => {
if (!value) return true;
return data[props.defaultProps.label]?.toString().includes(value);
};
// 处理节点选择变化
const handleCheck = (_: any, checkedInfo: any) => {
const checkedKeys = checkedInfo.checkedKeys;
emit('update:modelValue', checkedKeys);
emit('change', checkedKeys);
emit('check', {
checkedNodes: checkedInfo.checkedNodes,
checkedKeys,
halfCheckedNodes: checkedInfo.halfCheckedNodes,
halfCheckedKeys: checkedInfo.halfCheckedKeys,
});
};
// 构建节点映射
const buildNodeMap = (nodes: any) => {
nodes.forEach((node: any) => {
nodeMap.value.set(node[props.nodeKey], node);
if (node[props.defaultProps.children]) {
buildNodeMap(node[props.defaultProps.children]);
}
});
};
// 获取树数据
const fetchTreeData = async () => {
if (!props.dataUrl) return;
loading.value = true;
try {
const res = await api.get(props.dataUrl);
treeData.value = res.data;
// 构建节点映射
nodeMap.value.clear();
buildNodeMap(res.data);
// 数据加载完成后,如果有选中值则设置
if (props.modelValue && props.modelValue.length > 0) {
nextTick(() => {
if (treeRef.value) {
treeRef.value.setCheckedKeys(props.modelValue);
}
});
}
} catch (error) {
console.error('get data error:', error);
ElMessage.error($t('message.getDataError'));
} finally {
loading.value = false;
}
};
// 获取当前选中的节点
const getCheckedNodes = () => {
return treeRef.value?.getCheckedNodes() || [];
};
// 获取当前选中的叶子节点
const getCheckedLeafNodes = () => {
return treeRef.value?.getCheckedNodes(true) || [];
};
// 获取半选中的节点
const getHalfCheckedNodes = () => {
return treeRef.value?.getHalfCheckedNodes() || [];
};
// 清空选择
const clearChecked = () => {
treeRef.value?.setCheckedKeys([]);
};
// 设置选中节点
const setCheckedKeys = (keys: any) => {
treeRef.value?.setCheckedKeys(keys);
};
// 根据键值获取节点数据
const getNodeByKey = (key: any) => {
return nodeMap.value.get(key);
};
// 暴露方法给父组件
defineExpose({
getCheckedNodes,
getCheckedLeafNodes,
getHalfCheckedNodes,
clearChecked,
setCheckedKeys,
getNodeByKey,
refresh: fetchTreeData,
});
// 组件挂载时获取数据
onMounted(() => {
fetchTreeData();
});
</script>
<template>
<div class="tree-select">
<div class="tree-header"></div>
<div class="tree-wrapper">
<ElTreeV2
ref="treeRef"
:data="treeData"
:props="defaultProps"
:node-key="nodeKey"
:default-expand-all="defaultExpandAll"
:filter-node-method="filterNode"
:highlight-current="true"
show-checkbox
:check-strictly="checkStrictly"
:height="height"
@check="handleCheck"
v-loading="loading"
:element-loading-text="loadingText"
>
<template #default="{ node, data }">
<span class="tree-node">
<span class="node-label">{{ $t(node.label) }}</span>
<span
v-if="showCount && data[defaultProps.children]"
class="node-count"
>
({{ data[defaultProps.children].length }})
</span>
</span>
</template>
</ElTreeV2>
</div>
</div>
</template>
<style scoped>
.tree-select {
width: 100%;
background-color: #fff;
border: 1px solid #e4e7ed;
border-radius: 6px;
}
.tree-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: #f8f9fa;
border-bottom: 1px solid #e4e7ed;
}
.tree-wrapper {
padding: 8px;
}
.tree-node {
display: flex;
gap: 8px;
align-items: center;
}
.node-count {
font-size: 12px;
color: #909399;
}
</style>

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
import { defineExpose, defineProps, nextTick, ref, watch } from 'vue';
import { Attachments } from 'vue-element-plus-x';
import { ElMessage } from 'element-plus';
import { api } from '#/api/request';
const props = defineProps({
maxSize: {
type: Number,
default: 2,
},
action: {
type: String,
default: '/api/v1/commons/upload',
},
externalFiles: {
type: Array as () => File[],
default: () => [],
},
});
const emit = defineEmits(['deleteAll']);
type SelfFilesCardProps = {
fileSize: number;
id?: number;
name: string;
uid: number | string;
url: string; // 上传后的文件地址
} & FilesCardProps;
const files = ref<SelfFilesCardProps[]>([]);
/**
* 上传前校验
*/
function handleBeforeUpload(file: File) {
const maxSizeBytes = props.maxSize * 1024 * 1024;
if (file.size > maxSizeBytes) {
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB!`);
return false;
}
return true;
}
/**
* 拖拽上传处理
*/
async function handleUploadDrop(dropFiles: File[]) {
if (dropFiles?.length) {
if (dropFiles[0]?.type === '') {
ElMessage.error('禁止上传文件夹!');
return false;
}
for (const file of dropFiles) {
if (handleBeforeUpload(file)) {
await handleHttpRequest({ file });
}
}
}
}
/**
* 自定义上传请求
*/
async function handleHttpRequest(options: { file: File }) {
const { file } = options;
const formData = new FormData();
formData.append('file', file);
try {
const res = await api.upload(props.action, { file }, {});
const fileUrl = res.data.path;
const fileItem: SelfFilesCardProps = {
id: files.value.length,
uid: Date.now() + Math.random(),
name: file.name,
fileSize: file.size,
url: fileUrl,
showDelIcon: true,
imgVariant: 'square',
};
files.value.push(fileItem);
} catch (error) {
ElMessage.error(`${file.name} 上传失败`);
console.error('上传失败:', error);
}
}
/**
* 批量上传外部文件(父组件传递的文件数组)
*/
async function uploadExternalFiles(fileList: File[]) {
if (fileList.length === 0) return;
for (const file of fileList) {
if (handleBeforeUpload(file)) {
await handleHttpRequest({ file });
}
}
}
/**
* 组件内部完成删除逻辑
*/
function handleDeleteCard(item: FilesCardProps) {
const targetItem = item as SelfFilesCardProps;
files.value = files.value.filter((file) => file.id !== targetItem.id);
if (files.value.length === 0) {
emit('deleteAll');
}
}
/**
* 暴露给父组件的核心方法仅返回URL字符串数组
* @returns string[] 纯文件地址列表
*/
function getFileList(): string[] {
return files.value.map((file) => file.url);
}
watch(
() => props.externalFiles,
async (newFiles) => {
if (newFiles.length > 0) {
await nextTick();
await uploadExternalFiles(newFiles);
}
},
{ deep: true, immediate: true },
);
defineExpose({
getFileList,
clearFiles() {
files.value = [];
},
});
</script>
<template>
<div style="display: flex; flex-direction: column; gap: 12px">
<Attachments
:http-request="handleHttpRequest"
:items="files"
drag
:before-upload="handleBeforeUpload"
:hide-upload="false"
@upload-drop="handleUploadDrop"
@delete-card="handleDeleteCard"
/>
</div>
</template>
<style scoped lang="less"></style>

View File

@@ -0,0 +1,461 @@
<script setup lang="ts">
import type { UploadProps, UploadRequestHandler } from 'element-plus';
import { computed, ref, watch } from 'vue';
import { VueCropper } from 'vue-cropper';
import 'vue-cropper/dist/index.css';
import { useAccessStore } from '@easyflow/stores';
import { Delete, Plus, Refresh } from '@element-plus/icons-vue';
import {
ElButton,
ElDialog,
ElIcon,
ElImage,
ElMessage,
ElUpload,
} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
// 定义组件props
interface Props {
modelValue?: string; // 双向绑定的图片URL
crop?: boolean; // 是否启用裁剪
action?: string; // 上传地址
headers?: Record<string, string>; // 上传请求头
data?: Record<string, any>; // 上传额外数据
cropConfig?: Partial<CropConfig>; // 裁剪配置
limit?: number; // 文件大小限制(MB)
accept?: string; // 文件类型
}
interface CropConfig {
title: string;
outputSize: number;
outputType: string;
info: boolean;
full: boolean;
fixed: boolean;
fixedNumber: [number, number];
canMove: boolean;
canMoveBox: boolean;
fixedBox: boolean;
original: boolean;
autoCrop: boolean;
autoCropWidth: number;
autoCropHeight: number;
centerBox: boolean;
high: boolean;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
action: '/api/v1/commons/upload',
crop: false,
headers: () => ({}),
data: () => ({}),
cropConfig: () => ({}),
limit: 5,
accept: 'image/*',
});
const emit = defineEmits<{
'update:modelValue': [value: string];
uploadError: [error: Error];
uploadSuccess: [url: string];
}>();
const accessStore = useAccessStore();
const headers = computed(() => ({
'easyflow-token': accessStore.accessToken,
'Content-Type': 'multipart/form-data',
...props.headers,
}));
// 默认裁剪配置
const defaultCropConfig: CropConfig = {
title: $t('cropper.ImageCropping'),
outputSize: 1,
outputType: 'png',
info: true,
full: false,
fixed: false,
fixedNumber: [1, 1],
canMove: false,
canMoveBox: true,
fixedBox: false,
original: false,
autoCrop: true,
autoCropWidth: 200,
autoCropHeight: 200,
centerBox: true,
high: true,
};
// refs
const uploadRef = ref<InstanceType<typeof ElUpload>>();
const cropperRef = ref<InstanceType<typeof VueCropper>>();
const showCropDialog = ref(false);
const cropImageUrl = ref('');
const uploading = ref(false);
const currentFile = ref<File | null>(null);
// 合并裁剪配置
const mergedCropConfig = computed(() => ({
...defaultCropConfig,
...props.cropConfig,
}));
// 触发上传 - 修复:直接触发上传组件的点击事件
const triggerUpload = () => {
// 创建隐藏的input元素来触发文件选择
const input = document.createElement('input');
input.type = 'file';
input.accept = props.accept;
input.style.display = 'none';
input.addEventListener('change', (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
handleFileSelect(file);
}
input.remove();
});
document.body.append(input);
input.click();
};
// 处理文件选择
const handleFileSelect = (file: File) => {
// 验证文件
const isImage = file.type.startsWith('image/');
if (!isImage) {
ElMessage.error($t('cropper.message.onlyImage'));
return;
}
const isLtLimit = file.size / 1024 / 1024 < props.limit;
if (!isLtLimit) {
ElMessage.error($t('cropper.message.imgSize', { limit: props.limit }));
return;
}
currentFile.value = file;
// 如果需要裁剪,显示裁剪对话框
if (props.crop) {
cropImageUrl.value = URL.createObjectURL(file);
showCropDialog.value = true;
} else {
// 直接上传
uploadFile(file);
}
};
// 上传前验证
const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
const isImage = rawFile.type.startsWith('image/');
if (!isImage) {
ElMessage.error($t('cropper.message.onlyImage'));
return false;
}
const isLtLimit = rawFile.size / 1024 / 1024 < props.limit;
if (!isLtLimit) {
ElMessage.error($t('cropper.message.imgSize', { limit: props.limit }));
return false;
}
// 如果需要裁剪,显示裁剪对话框
if (props.crop) {
currentFile.value = rawFile;
cropImageUrl.value = URL.createObjectURL(rawFile);
showCropDialog.value = true;
return false; // 阻止自动上传
}
return true;
};
// 统一上传方法
const uploadFile = async (file: File) => {
try {
uploading.value = true;
const formData = new FormData();
formData.append('file', file);
Object.entries(props.data).forEach(([key, value]) => {
formData.append(key, value);
});
const response = await api.post(props.action, formData, {
headers: headers.value,
});
if (response.errorCode !== 0) {
throw new Error(
`${$t('cropper.message.uploadFailed')}: ${response.message}`,
);
}
const imageUrl = response.data.path;
if (!imageUrl) {
throw new Error($t('cropper.message.notUrl'));
}
emit('update:modelValue', imageUrl);
emit('uploadSuccess', imageUrl);
ElMessage.success($t('cropper.message.uploadSuccessful'));
} catch (error) {
const err =
error instanceof Error
? error
: new Error($t('cropper.message.uploadFailed'));
emit('uploadError', err);
ElMessage.error(err.message);
} finally {
uploading.value = false;
currentFile.value = null;
}
};
// 处理上传
const handleUpload: UploadRequestHandler = async (options) => {
const { file, onSuccess } = options;
await uploadFile(file);
onSuccess({}); // 调用成功回调
};
// 处理裁剪 - 修复:使用正确的裁剪逻辑
const handleCrop = () => {
if (!cropperRef.value) {
ElMessage.error($t('cropper.message.notInitialized'));
return;
}
cropperRef.value.getCropBlob(async (blob: Blob | null) => {
if (!blob) {
ElMessage.error($t('cropper.message.cropFailed'));
return;
}
try {
uploading.value = true;
// 创建文件对象,保留原始文件名但使用裁剪后的内容
const originalName = currentFile.value?.name || 'cropped-image';
const fileExtension = originalName.split('.').pop() || 'png';
const fileName = `cropped-${Date.now()}.${fileExtension}`;
const file = new File([blob], fileName, { type: blob.type });
await uploadFile(file);
showCropDialog.value = false;
} catch (error) {
const err =
error instanceof Error
? error
: new Error($t('cropper.message.uploadFailed'));
emit('uploadError', err);
ElMessage.error(err.message);
} finally {
uploading.value = false;
}
});
};
// 删除图片
const handleRemove = () => {
emit('update:modelValue', '');
ElMessage.success($t('message.deleteOkMessage'));
};
// 清理URL对象
watch(showCropDialog, (newVal) => {
if (!newVal && cropImageUrl.value) {
URL.revokeObjectURL(cropImageUrl.value);
cropImageUrl.value = '';
}
});
</script>
<template>
<div class="image-upload-container">
<!-- 上传按钮 -->
<div v-if="!modelValue" class="upload-area">
<ElUpload
ref="uploadRef"
class="avatar-uploader"
action="#"
:show-file-list="false"
:before-upload="beforeUpload"
:http-request="handleUpload"
:accept="accept"
>
<ElIcon class="avatar-uploader-icon"><Plus /></ElIcon>
</ElUpload>
</div>
<!-- 图片预览 -->
<div v-else class="preview-area">
<div class="preview-container">
<ElImage
:src="modelValue"
:preview-src-list="[modelValue]"
fit="cover"
class="preview-image"
:zoom-rate="1.2"
:max-scale="7"
:min-scale="0.2"
hide-on-click-modal
/>
<div class="preview-actions">
<ElButton @click="triggerUpload">
<ElIcon><Refresh /></ElIcon>
</ElButton>
<ElButton @click="handleRemove">
<ElIcon><Delete /></ElIcon>
</ElButton>
</div>
</div>
</div>
<!-- 裁剪对话框 -->
<ElDialog
v-model="showCropDialog"
:title="mergedCropConfig.title"
width="800px"
:close-on-click-modal="false"
destroy-on-close
>
<div class="cropper-container">
<VueCropper
ref="cropperRef"
:img="cropImageUrl"
:output-size="mergedCropConfig.outputSize"
:output-type="mergedCropConfig.outputType"
:info="mergedCropConfig.info"
:full="mergedCropConfig.full"
:fixed="mergedCropConfig.fixed"
:fixed-number="mergedCropConfig.fixedNumber"
:can-move="mergedCropConfig.canMove"
:can-move-box="mergedCropConfig.canMoveBox"
:fixed-box="mergedCropConfig.fixedBox"
:original="mergedCropConfig.original"
:auto-crop="mergedCropConfig.autoCrop"
:auto-crop-width="mergedCropConfig.autoCropWidth"
:auto-crop-height="mergedCropConfig.autoCropHeight"
:center-box="mergedCropConfig.centerBox"
:high="mergedCropConfig.high"
mode="cover"
/>
</div>
<template #footer>
<span class="dialog-footer">
<ElButton @click="showCropDialog = false" :disabled="uploading">
{{ $t('button.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleCrop" :loading="uploading">
{{
uploading ? $t('cropper.Uploading') : $t('cropper.ConfirmCrop')
}}
</ElButton>
</span>
</template>
</ElDialog>
</div>
</template>
<style scoped>
/* 样式保持不变 */
.image-upload-container {
display: inline-block;
}
.upload-area {
display: flex;
align-items: center;
justify-content: center;
}
.avatar-uploader {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
overflow: hidden;
cursor: pointer;
border: 1px dashed #d9d9d9;
border-radius: 6px;
transition: border-color 0.3s;
}
.avatar-uploader:hover {
border-color: #409eff;
}
.avatar-uploader-icon {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 28px;
color: #8c939d;
}
.preview-area {
display: flex;
flex-direction: column;
align-items: center;
}
.preview-container {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.preview-image {
width: 100px;
height: 100px;
overflow: hidden;
border-radius: 6px;
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
.preview-actions {
display: flex;
gap: 5px;
}
.cropper-container {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
background: #f5f7fa;
}
.edit-actions {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px 0;
}
.edit-actions .el-button {
justify-content: flex-start;
}
</style>

View File

@@ -0,0 +1,603 @@
<script setup lang="ts">
import type { UploadProps, UploadRequestHandler } from 'element-plus';
import { computed, ref, watch } from 'vue';
import { VueCropper } from 'vue-cropper';
import 'vue-cropper/dist/index.css';
import { $t } from '@easyflow/locales';
import { useAccessStore } from '@easyflow/stores';
import { Delete, Edit, Plus } from '@element-plus/icons-vue';
import {
ElButton,
ElDialog,
ElIcon,
ElImage,
ElMessage,
ElTag,
ElUpload,
} from 'element-plus';
import { api } from '#/api/request';
// 定义组件props
interface Props {
modelValue?: string[]; // 双向绑定的图片URL数组
crop?: boolean; // 是否启用裁剪
action?: string; // 上传地址
headers?: Record<string, string>; // 上传请求头
data?: Record<string, any>; // 上传额外数据
cropConfig?: Partial<CropConfig>; // 裁剪配置
limit?: number; // 文件大小限制(MB)
maxCount?: number; // 最大文件数量
}
interface CropConfig {
title: string;
outputSize: number;
outputType: string;
info: boolean;
full: boolean;
fixed: boolean;
fixedNumber: [number, number];
canMove: boolean;
canMoveBox: boolean;
fixedBox: boolean;
original: boolean;
autoCrop: boolean;
autoCropWidth: number;
autoCropHeight: number;
centerBox: boolean;
high: boolean;
}
interface FileItem {
id: string; // 唯一标识
url: string; // 图片URL
name?: string; // 文件名
uploading?: boolean; // 上传状态
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
action: '/api/v1/commons/upload',
crop: false,
headers: () => ({}),
data: () => ({}),
cropConfig: () => ({}),
limit: 5,
maxCount: 5,
});
const emit = defineEmits<{
remove: [url: string, fileList: string[]];
'update:modelValue': [value: string[]];
uploadError: [error: Error];
uploadSuccess: [url: string, fileList: string[]];
}>();
const accessStore = useAccessStore();
const headers = computed(() => ({
'easyflow-token': accessStore.accessToken,
'Content-Type': 'multipart/form-data',
...props.headers,
}));
// 默认裁剪配置
const defaultCropConfig: CropConfig = {
title: $t('cropper.ImageCropping'),
outputSize: 1,
outputType: 'png',
info: true,
full: false,
fixed: false,
fixedNumber: [1, 1],
canMove: false,
canMoveBox: true,
fixedBox: false,
original: false,
autoCrop: true,
autoCropWidth: 200,
autoCropHeight: 200,
centerBox: true,
high: true,
};
// refs
const uploadRef = ref<InstanceType<typeof ElUpload>>();
const cropperRef = ref<InstanceType<typeof VueCropper>>();
const showCropDialog = ref(false);
const cropImageUrl = ref('');
const uploading = ref(false);
const currentFile = ref<File | null>(null);
const currentCropIndex = ref<number>(-1); // 当前裁剪的文件索引,-1表示新增文件
// 文件列表
const fileList = ref<FileItem[]>([]);
// 合并裁剪配置
const mergedCropConfig = computed(() => ({
...defaultCropConfig,
...props.cropConfig,
}));
// 从URL中提取文件名
const getFileNameFromUrl = (url: string): string => {
try {
return url.split('/').pop() || 'image';
} catch {
return 'image';
}
};
// 生成唯一ID
const generateId = () => {
return Date.now().toString(36) + Math.random().toString(36).slice(2);
};
// 从modelValue初始化文件列表
watch(
() => props.modelValue,
(urls) => {
if (
JSON.stringify(urls) !==
JSON.stringify(fileList.value.map((item) => item.url))
) {
fileList.value = urls.map((url) => ({
id: generateId(),
url,
name: getFileNameFromUrl(url),
}));
}
},
{ immediate: true, deep: true },
);
// 处理单个文件重新上传
const triggerReupload = (index: number) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.style.display = 'none';
input.addEventListener('change', (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
currentCropIndex.value = index; // 标记为重新上传
handleFileSelect(file, index);
}
input.remove();
});
document.body.append(input);
input.click();
};
// 处理单个文件选择
const handleFileSelect = (file: File, index: number) => {
// 验证文件
const isImage = file.type.startsWith('image/');
if (!isImage) {
ElMessage.error($t('cropper.message.onlyImage'));
return;
}
const isLtLimit = file.size / 1024 / 1024 < props.limit;
if (!isLtLimit) {
ElMessage.error($t('cropper.message.imgSize', { limit: props.limit }));
return;
}
currentFile.value = file;
currentCropIndex.value = index;
// 如果需要裁剪,显示裁剪对话框
if (props.crop) {
cropImageUrl.value = URL.createObjectURL(file);
showCropDialog.value = true;
} else {
// 直接上传
uploadFile(file, index);
}
};
// 上传前验证
const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
const isImage = rawFile.type.startsWith('image/');
if (!isImage) {
ElMessage.error($t('cropper.message.onlyImage'));
return false;
}
const isLtLimit = rawFile.size / 1024 / 1024 < props.limit;
if (!isLtLimit) {
ElMessage.error($t('cropper.message.imgSize', { limit: props.limit }));
return false;
}
if (fileList.value.length >= props.maxCount) {
ElMessage.error($t('cropper.message.fileCount', { count: props.maxCount }));
return false;
}
// 如果需要裁剪,显示裁剪对话框
if (props.crop) {
currentFile.value = rawFile;
currentCropIndex.value = -1; // 新增文件
cropImageUrl.value = URL.createObjectURL(rawFile);
showCropDialog.value = true;
return false; // 阻止自动上传
}
return true;
};
// 统一上传方法
const uploadFile = async (file: File, index: number) => {
try {
// 如果是重新上传,标记为上传中状态
if (index >= 0 && index < fileList.value.length && fileList.value[index]) {
fileList.value[index].uploading = true;
} else {
uploading.value = true;
}
const formData = new FormData();
formData.append('file', file);
Object.entries(props.data).forEach(([key, value]) => {
formData.append(key, value);
});
const response = await api.post(props.action, formData, {
headers: headers.value,
});
if (response.errorCode !== 0) {
throw new Error(
`${$t('cropper.message.uploadFailed')}: ${response.message}`,
);
}
const imageUrl = response.data.path;
if (!imageUrl) {
throw new Error($t('cropper.message.notUrl'));
}
// 更新文件列表
if (index >= 0 && index < fileList.value.length && fileList.value[index]) {
// 重新上传,替换原有文件
fileList.value[index].url = imageUrl;
fileList.value[index].name = file.name;
fileList.value[index].uploading = false;
} else {
// 新增文件
fileList.value.push({
id: generateId(),
url: imageUrl,
name: file.name,
uploading: false,
});
}
// 更新modelValue
const urls = fileList.value.map((item) => item.url);
emit('update:modelValue', urls);
emit('uploadSuccess', imageUrl, urls);
ElMessage.success(
index >= 0
? $t('cropper.message.reuploadSuccessful')
: $t('cropper.message.uploadSuccessful'),
);
} catch (error) {
const err =
error instanceof Error
? error
: new Error($t('cropper.message.uploadFailed'));
// 重置上传状态
if (index >= 0 && index < fileList.value.length && fileList.value[index]) {
fileList.value[index].uploading = false;
}
emit('uploadError', err);
ElMessage.error(err.message);
} finally {
uploading.value = false;
currentFile.value = null;
currentCropIndex.value = -1;
}
};
// 处理上传
const handleUpload: UploadRequestHandler = async (options) => {
const { file, onSuccess } = options;
await uploadFile(file, -1);
onSuccess({});
};
// 处理裁剪
const handleCrop = () => {
if (!cropperRef.value) {
ElMessage.error($t('cropper.message.notInitialized'));
return;
}
cropperRef.value.getCropBlob(async (blob: Blob | null) => {
if (!blob) {
ElMessage.error($t('cropper.message.cropFailed'));
return;
}
try {
uploading.value = true;
// 创建文件对象
const originalName = currentFile.value?.name || 'cropped-image';
const fileExtension = originalName.split('.').pop() || 'png';
const fileName = `cropped-${Date.now()}.${fileExtension}`;
const file = new File([blob], fileName, { type: blob.type });
await uploadFile(file, currentCropIndex.value);
showCropDialog.value = false;
} catch (error) {
const err =
error instanceof Error
? error
: new Error($t('cropper.message.uploadFailed'));
emit('uploadError', err);
ElMessage.error(err.message);
} finally {
uploading.value = false;
}
});
};
// 删除图片
const handleRemove = (index: number) => {
const removedUrl = fileList.value[index]?.url;
fileList.value.splice(index, 1);
const urls = fileList.value.map((item) => item.url);
emit('update:modelValue', urls);
emit('remove', removedUrl || '', urls);
ElMessage.success($t('message.deleteOkMessage'));
};
// 清理URL对象
watch(showCropDialog, (newVal) => {
if (!newVal && cropImageUrl.value) {
URL.revokeObjectURL(cropImageUrl.value);
cropImageUrl.value = '';
}
});
</script>
<template>
<div class="multi-image-upload-container">
<!-- 文件列表展示 -->
<div class="file-list">
<div v-for="(file, index) in fileList" :key="file.id" class="file-item">
<div class="preview-container">
<ElImage
:src="file.url"
:preview-src-list="fileList.map((f) => f.url)"
fit="cover"
class="preview-image"
:zoom-rate="1.2"
:max-scale="7"
:min-scale="0.2"
hide-on-click-modal
/>
<div class="preview-actions">
<ElButton
type="primary"
text
:loading="file.uploading"
@click="triggerReupload(index)"
>
<ElIcon><Edit /></ElIcon>
{{
file.uploading
? $t('cropper.Uploading')
: $t('cropper.Re-upload')
}}
</ElButton>
<ElButton type="danger" text @click="handleRemove(index)">
<ElIcon><Delete /></ElIcon>
{{ $t('button.delete') }}
</ElButton>
</div>
<ElTag v-if="file.name" class="file-name" size="small">
{{ file.name }}
</ElTag>
</div>
</div>
<!-- 上传按钮 -->
<div v-if="fileList.length < maxCount" class="upload-area file-item">
<ElUpload
ref="uploadRef"
class="avatar-uploader"
action="#"
:show-file-list="false"
:before-upload="beforeUpload"
:http-request="handleUpload"
accept="image/*"
:multiple="true"
>
<ElIcon class="avatar-uploader-icon"><Plus /></ElIcon>
<div class="upload-text">{{ $t('cropper.ClickToUpload') }}</div>
<div class="upload-hint">
{{ $t('cropper.message.fileCount', { count: maxCount }) }}
</div>
</ElUpload>
</div>
</div>
<!-- 裁剪对话框 -->
<ElDialog
v-model="showCropDialog"
:title="mergedCropConfig.title"
width="800px"
:close-on-click-modal="false"
destroy-on-close
>
<div class="cropper-container">
<VueCropper
ref="cropperRef"
:img="cropImageUrl"
:output-size="mergedCropConfig.outputSize"
:output-type="mergedCropConfig.outputType"
:info="mergedCropConfig.info"
:full="mergedCropConfig.full"
:fixed="mergedCropConfig.fixed"
:fixed-number="mergedCropConfig.fixedNumber"
:can-move="mergedCropConfig.canMove"
:can-move-box="mergedCropConfig.canMoveBox"
:fixed-box="mergedCropConfig.fixedBox"
:original="mergedCropConfig.original"
:auto-crop="mergedCropConfig.autoCrop"
:auto-crop-width="mergedCropConfig.autoCropWidth"
:auto-crop-height="mergedCropConfig.autoCropHeight"
:center-box="mergedCropConfig.centerBox"
:high="mergedCropConfig.high"
mode="cover"
/>
</div>
<template #footer>
<span class="dialog-footer">
<ElButton @click="showCropDialog = false" :disabled="uploading">
{{ $t('button.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleCrop" :loading="uploading">
{{
uploading ? $t('cropper.Uploading') : $t('cropper.ClickToUpload')
}}
</ElButton>
</span>
</template>
</ElDialog>
</div>
</template>
<style scoped>
.multi-image-upload-container {
width: 100%;
}
.file-list {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
}
.file-item {
display: flex;
flex-direction: column;
align-items: center;
}
.preview-container {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
padding: 12px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
transition: all 0.3s;
}
.preview-container:hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgb(64 158 255 / 10%);
}
.preview-image {
width: 100px;
height: 100px;
overflow: hidden;
border-radius: 6px;
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.file-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-area {
display: flex;
align-items: center;
justify-content: center;
}
.avatar-uploader {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100px;
height: 140px;
padding: 8px;
overflow: hidden;
cursor: pointer;
border: 1px dashed #d9d9d9;
border-radius: 6px;
transition: border-color 0.3s;
}
.avatar-uploader:hover {
border-color: #409eff;
}
.avatar-uploader-icon {
margin-bottom: 8px;
font-size: 28px;
color: #8c939d;
}
.upload-text {
margin-bottom: 4px;
font-size: 12px;
color: #606266;
text-align: center;
}
.upload-hint {
font-size: 10px;
color: #909399;
text-align: center;
}
.cropper-container {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
background: #f5f7fa;
}
.dialog-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import type { UploadProps } from 'element-plus';
import { ref } from 'vue';
import { useAppConfig } from '@easyflow/hooks';
import { useAccessStore } from '@easyflow/stores';
import { UploadFilled } from '@element-plus/icons-vue';
import { ElIcon, ElUpload } from 'element-plus';
const props = defineProps({
action: {
type: String,
default: '/api/v1/commons/upload',
},
visible: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['success', 'onChange']);
const accessStore = useAccessStore();
const headers = ref({
'easyflow-token': accessStore.accessToken,
});
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
// 核心获取ElUpload组件实例
const uploadRef = ref<InstanceType<typeof ElUpload>>();
// 上传成功回调
const handleSuccess: UploadProps['onSuccess'] = (response) => {
emit('success', response.data.path);
};
// 文件状态变化回调
const handleChange: UploadProps['onChange'] = (file, fileList) => {
emit('onChange', file, fileList);
};
// 暴露给父组件的方法:手动触发文件选择
const triggerFileSelect = () => {
if (uploadRef.value) {
// 调用ElUpload内部的上传按钮点击事件
const uploadInput = uploadRef.value.$el.querySelector('input[type="file"]');
if (uploadInput) {
uploadInput.click(); // 触发原生文件选择框
}
}
};
// 对外暴露方法父组件可通过ref调用
defineExpose({
triggerFileSelect,
});
</script>
<template>
<!-- 给ElUpload添加ref引用 -->
<ElUpload
ref="uploadRef"
class="upload-demo"
drag
:headers="headers"
:action="`${apiURL}${props.action}`"
:on-success="handleSuccess"
:on-change="handleChange"
multiple
:style="{ display: props.visible ? 'block' : 'none' }"
>
<ElIcon size="48" color="hsl(var(--primary))">
<UploadFilled />
</ElIcon>
<div class="flex flex-col gap-1">
<span class="text-base">{{ $t('message.upload.title') }}</span>
<span class="text-sm text-[#75808d]">{{
$t('message.upload.description')
}}</span>
</div>
</ElUpload>
</template>

View File

@@ -0,0 +1,72 @@
<script lang="ts" setup>
import type { UploadProps, UploadUserFile } from 'element-plus';
import { ref } from 'vue';
import { useAppConfig } from '@easyflow/hooks';
import { useAccessStore } from '@easyflow/stores';
import { ElButton, ElUpload } from 'element-plus';
import { $t } from '#/locales';
const props = defineProps({
action: {
type: String,
default: '/api/v1/commons/upload',
},
tips: {
type: String,
default: '',
},
limit: {
type: Number,
default: 1,
},
multiple: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'success', // 文件上传成功
'handleDelete',
'handlePreview',
'beforeUpload',
]);
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const accessStore = useAccessStore();
const headers = ref({
'easyflow-token': accessStore.accessToken,
});
const fileList = ref<UploadUserFile[]>([]);
const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
emit('beforeUpload', rawFile);
};
const handleRemove: UploadProps['onRemove'] = (file, uploadFiles) => {
emit('handleDelete', file, uploadFiles);
};
const handleSuccess: UploadProps['onSuccess'] = (response) => {
emit('success', response.data.path);
};
</script>
<template>
<ElUpload
v-model:file-list="fileList"
class="upload-demo"
:headers="headers"
:action="`${apiURL}${props.action}`"
:multiple="props.multiple"
:before-upload="beforeUpload"
:on-remove="handleRemove"
:limit="props.limit"
:on-success="handleSuccess"
>
<ElButton type="primary">{{ $t('button.upload') }}</ElButton>
</ElUpload>
</template>

View File

@@ -0,0 +1,125 @@
<script lang="ts" setup>
import type { UploadProps } from 'element-plus';
import { ref, watch } from 'vue';
import { useAppConfig } from '@easyflow/hooks';
import { useAccessStore } from '@easyflow/stores';
import { Plus } from '@element-plus/icons-vue';
import { ElIcon, ElImage, ElMessage, ElUpload } from 'element-plus';
import { $t } from '#/locales';
const props = defineProps({
action: {
type: String,
default: '/api/v1/commons/upload',
},
fileSize: {
type: Number,
default: 2,
},
allowedImageTypes: {
type: Array<string>,
default: () => ['image/gif', 'image/jpeg', 'image/png', 'image/webp'],
},
modelValue: { type: String, default: '' },
});
const emit = defineEmits(['success', 'update:modelValue']);
const accessStore = useAccessStore();
const headers = ref({
'easyflow-token': accessStore.accessToken,
});
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const localImageUrl = ref(props.modelValue);
const handleAvatarSuccess: UploadProps['onSuccess'] = (
_response,
uploadFile,
) => {
localImageUrl.value = URL.createObjectURL(uploadFile.raw!);
emit('success', _response.data.path);
emit('update:modelValue', _response.data.path);
};
watch(
() => props.modelValue,
(newVal) => {
localImageUrl.value = newVal;
},
{ immediate: true },
);
const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
if (!props.allowedImageTypes.includes(rawFile.type)) {
const formatTypes = props.allowedImageTypes
.map((type: string) => {
const parts = type.split('/');
return parts[1] ? parts[1].toUpperCase() : '';
})
.filter(Boolean);
ElMessage.error(
$t('cropper.message.avatarFormat', { format: formatTypes.join(', ') }),
);
return false;
} else if (rawFile.size / 1024 / 1024 > props.fileSize) {
ElMessage.error(
$t('cropper.message.avatarSize', { limit: props.fileSize }),
);
return false;
}
return true;
};
</script>
<template>
<ElUpload
class="avatar-uploader"
:action="`${apiURL}${props.action}`"
:headers="headers"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:before-upload="beforeAvatarUpload"
>
<ElImage
v-if="localImageUrl"
:src="localImageUrl"
class="avatar"
fit="cover"
/>
<ElIcon v-else class="avatar-uploader-icon"><Plus /></ElIcon>
</ElUpload>
</template>
<style scoped>
.avatar-uploader .avatar {
display: block;
width: 100px;
height: 100px;
}
</style>
<style>
.avatar-uploader .el-upload {
position: relative;
overflow: hidden;
cursor: pointer;
border: 1px solid #e6e9ee;
border-radius: 50%;
transition: var(--el-transition-duration-fast);
}
.avatar-uploader .el-upload:hover {
border-color: var(--el-color-primary);
}
.el-icon.avatar-uploader-icon {
width: 100px;
height: 100px;
font-size: 28px;
color: var(--el-text-color-secondary);
text-align: center;
}
</style>

View File

@@ -0,0 +1,25 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { AuthPageLayout } from '@easyflow/layouts';
import { preferences } from '@easyflow/preferences';
const appName = computed(() => preferences.app.name);
const logo = computed(() => preferences.logo.source);
const logoDark = computed(() => preferences.logo.sourceDark);
const pageDescription = computed(() => preferences.auth.pageDescription);
const pageTitle = computed(() => preferences.auth.pageTitle);
</script>
<template>
<AuthPageLayout
:app-name="appName"
:logo="logo"
:logo-dark="logoDark"
:page-description="pageDescription || $t('authentication.pageDesc')"
:page-title="pageTitle || $t('authentication.pageTitle')"
>
<!-- 自定义工具栏 -->
<!-- <template #toolbar></template> -->
</AuthPageLayout>
</template>

View File

@@ -0,0 +1,197 @@
<script lang="ts" setup>
// import type { NotificationItem } from '@easyflow/layouts';
import { computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { AuthenticationLoginExpiredModal } from '@easyflow/common-ui';
import { useWatermark } from '@easyflow/hooks';
import { BasicLayout, LockScreen, UserDropdown } from '@easyflow/layouts';
import { preferences } from '@easyflow/preferences';
import { useAccessStore, useUserStore } from '@easyflow/stores';
import { $t } from '#/locales';
import { useAuthStore } from '#/store';
import LoginForm from '#/views/_core/authentication/login.vue';
// const notifications = ref<NotificationItem[]>([
// {
// id: 1,
// avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
// date: '3小时前',
// isRead: true,
// message: '描述信息描述信息描述信息',
// title: '收到了 14 份新周报',
// },
// {
// id: 2,
// avatar: 'https://avatar.vercel.sh/1',
// date: '刚刚',
// isRead: false,
// message: '描述信息描述信息描述信息',
// title: '朱偏右 回复了你',
// },
// {
// id: 3,
// avatar: 'https://avatar.vercel.sh/1',
// date: '2024-01-01',
// isRead: false,
// message: '描述信息描述信息描述信息',
// title: '曲丽丽 评论了你',
// },
// {
// id: 4,
// avatar: 'https://avatar.vercel.sh/satori',
// date: '1天前',
// isRead: false,
// message: '描述信息描述信息描述信息',
// title: '代办提醒',
// },
// {
// id: 5,
// avatar: 'https://avatar.vercel.sh/satori',
// date: '1天前',
// isRead: false,
// message: '描述信息描述信息描述信息',
// title: '跳转Workspace示例',
// link: '/workspace',
// },
// {
// id: 6,
// avatar: 'https://avatar.vercel.sh/satori',
// date: '1天前',
// isRead: false,
// message: '描述信息描述信息描述信息',
// title: '跳转外部链接示例',
// link: 'https://doc.easyflow.tech',
// },
// ]);
const router = useRouter();
const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark();
// const showDot = computed(() =>
// notifications.value.some((item) => !item.isRead),
// );
const menus = computed(() => [
{
handler: () => {
router.push({ name: 'Profile' });
},
icon: 'lucide:user',
text: $t('page.auth.profile'),
},
{
handler: () => {
router.push({ name: 'Profile', query: { tab: 'password' } });
},
icon: 'lucide:lock',
text: $t('settingsConfig.updatePwd'),
},
// 品牌外链入口(文档)已隐藏
// {
// handler: () => {
// openWindow(APP_GITHUB_URL, {
// target: '_blank',
// });
// },
// icon: SvgGithubIcon,
// text: 'GitHub',
// },
// {
// handler: () => {
// openWindow(`${APP_GITHUB_URL}/issues`, {
// target: '_blank',
// });
// },
// icon: CircleHelp,
// text: $t('ui.widgets.qa'),
// },
]);
const avatar = computed(() => {
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
});
async function handleLogout() {
await authStore.logout(false);
}
// function handleNoticeClear() {
// notifications.value = [];
// }
//
// function markRead(id: number | string) {
// const item = notifications.value.find((item) => item.id === id);
// if (item) {
// item.isRead = true;
// }
// }
// function remove(id: number | string) {
// notifications.value = notifications.value.filter((item) => item.id !== id);
// }
//
// function handleMakeAll() {
// notifications.value.forEach((item) => (item.isRead = true));
// }
watch(
() => ({
enable: preferences.app.watermark,
content: preferences.app.watermarkContent,
}),
async ({ enable, content }) => {
if (enable) {
await updateWatermark({
content:
content ||
`${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
});
} else {
destroyWatermark();
}
},
{
immediate: true,
},
);
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<template #user-dropdown>
<UserDropdown
:avatar
:menus
:text="userStore.userInfo?.nickname"
:description="userStore.userInfo?.loginName"
tag-text="Pro"
@logout="handleLogout"
/>
</template>
<!--<template #notification>
<Notification
:dot="showDot"
:notifications="notifications"
@clear="handleNoticeClear"
@read="(item) => item.id && markRead(item.id)"
@remove="(item) => item.id && remove(item.id)"
@make-all="handleMakeAll"
/>
</template>-->
<template #extra>
<AuthenticationLoginExpiredModal
v-model:open="accessStore.loginExpired"
:avatar
>
<LoginForm />
</AuthenticationLoginExpiredModal>
</template>
<template #lock-screen>
<LockScreen :avatar @to-login="handleLogout" />
</template>
</BasicLayout>
</template>

View File

@@ -0,0 +1,6 @@
const BasicLayout = () => import('./basic.vue');
const AuthPageLayout = () => import('./auth.vue');
const IFrameView = () => import('@easyflow/layouts').then((m) => m.IFrameView);
export { AuthPageLayout, BasicLayout, IFrameView };

View File

@@ -0,0 +1,3 @@
# locale
每个app使用的国际化可能不同这里用于扩展国际化的功能例如扩展 dayjs、antd组件库的多语言切换以及app本身的国际化文件。

Some files were not shown because too many files have changed in this diff Show More