初始化

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,4 @@
> 1%
last 2 versions
not dead
not ie 11

View File

@@ -0,0 +1,5 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

View File

@@ -0,0 +1,18 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": [
"@changesets/changelog-github",
{ "repo": "easyflow/easyflow" }
],
"commit": false,
"fixed": [["@easyflow-core/*", "@easyflow/*"]],
"snapshot": {
"prereleaseTemplate": "{tag}-{datetime}"
},
"privatePackages": { "version": true, "tag": true },
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

View File

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

View File

@@ -0,0 +1,7 @@
node_modules
.git
.gitignore
*.md
dist
.turbo
dist.zip

View File

@@ -0,0 +1,18 @@
root = true
[*]
charset=utf-8
end_of_line=lf
insert_final_newline=true
indent_style=space
indent_size=2
max_line_length = 100
trim_trailing_whitespace = true
quote_type = single
[*.{yml,yaml,json}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false

11
easyflow-ui-usercenter/.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
# https://docs.github.com/cn/get-started/getting-started-with-git/configuring-git-to-handle-line-endings
# Automatically normalize line endings (to LF) for all text-based files.
* text=auto eol=lf
# Declare files that will always have CRLF line endings on checkout.
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
# Denote all files that are truly binary and should not be modified.
*.{ico,png,jpg,jpeg,gif,webp,svg,woff,woff2} binary

View File

@@ -0,0 +1,2 @@
[core]
ignorecase = false

52
easyflow-ui-usercenter/.gitignore vendored Normal file
View File

@@ -0,0 +1,52 @@
node_modules
.DS_Store
dist
dist-ssr
dist.zip
dist.tar
dist.war
.nitro
.output
*-dist.zip
*-dist.tar
*-dist.war
coverage
*.local
**/.vitepress/cache
.cache
.turbo
.temp
dev-dist
.stylelintcache
yarn.lock
package-lock.json
.VSCodeCounter
**/backend-mock/data
# local env files
.env.local
.env.*.local
.eslintcache
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
vite.config.mts.*
vite.config.mjs.*
vite.config.js.*
vite.config.ts.*
# Editor directories and files
.idea
# .vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.history
.cursor

View File

@@ -0,0 +1,6 @@
ports:
- port: 5555
onOpen: open-preview
tasks:
- init: npm i -g corepack && pnpm install
command: pnpm run dev:play

View File

@@ -0,0 +1 @@
22.21.1

View File

@@ -0,0 +1,13 @@
registry=https://registry.npmmirror.com
public-hoist-pattern[]=lefthook
public-hoist-pattern[]=eslint
public-hoist-pattern[]=prettier
public-hoist-pattern[]=prettier-plugin-tailwindcss
public-hoist-pattern[]=stylelint
public-hoist-pattern[]=*postcss*
public-hoist-pattern[]=@commitlint/*
public-hoist-pattern[]=czg
strict-peer-dependencies=false
auto-install-peers=true
dedupe-peer-dependents=true

View File

@@ -0,0 +1,18 @@
dist
dev-dist
.local
.output.js
node_modules
.nvmrc
coverage
CODEOWNERS
.nitro
.output
**/*.svg
**/*.sh
public
.npmrc
*-lock.yaml

View File

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

View File

@@ -0,0 +1,4 @@
dist
public
__tests__
coverage

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=5091
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 Usercenter 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,46 @@
{
"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/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",
"@vueuse/core": "catalog:",
"dayjs": "catalog:",
"element-plus": "catalog:",
"fetch-event-stream": "^0.1.6",
"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": {
"unplugin-element-plus": "catalog:"
}
}

View File

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

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,155 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { IconifyIcon } from '@easyflow/icons';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElSelect,
} from 'element-plus';
import { tryit } from 'radash';
import Upload from '#/components/upload/Upload.vue';
import { api } from './api/request';
const options = [
{
value: 1,
label: '功能故障',
},
{
value: 2,
label: '优化建议',
},
{
value: 3,
label: '账号问题',
},
{
value: 4,
label: '其它',
},
];
const route = useRoute();
const showDialog = ref(false);
const formRef = ref<FormInstance>();
const formData = reactive({
feedbackType: '',
feedbackContent: '',
contactInfo: '',
attachmentUrl: '',
});
const loading = ref(false);
const uploadRef = ref();
function uploadSuccess(res: any) {
formData.attachmentUrl = res;
}
function onRemove() {
formData.attachmentUrl = '';
}
const handleSubmit = async () => {
const isValid = await formRef.value?.validate();
if (isValid) {
loading.value = true;
const [, res] = await tryit(api.post)(
'/userCenter/sysUserFeedback/save',
formData,
);
if (res && res.errorCode === 0) {
ElMessage.success('反馈成功!');
showDialog.value = false;
formRef.value?.resetFields();
uploadRef.value?.clear();
}
loading.value = false;
}
};
</script>
<template>
<Teleport
v-if="!route.path.includes('auth') && !route.path.includes('share')"
to="#app"
>
<div
class="fixed bottom-1 right-2 cursor-pointer text-6xl active:opacity-70"
@click="showDialog = !showDialog"
>
<IconifyIcon icon="svg:issue" />
</div>
</Teleport>
<ElDialog
draggable
v-model="showDialog"
title="问题反馈"
style="max-width: 560px"
>
<ElForm
ref="formRef"
:model="formData"
label-width="80px"
label-position="left"
require-asterisk-position="right"
>
<ElFormItem
prop="feedbackType"
label="问题类型"
:rules="[{ required: true, message: '此为必填项' }]"
>
<ElSelect
:options="options"
v-model="formData.feedbackType"
placeholder="请选择问题类型"
/>
</ElFormItem>
<ElFormItem
prop="feedbackContent"
label="问题描述"
:rules="[{ required: true, message: '此为必填项' }]"
>
<ElInput
type="textarea"
:rows="5"
v-model="formData.feedbackContent"
placeholder="请简要描述下您所遇到的问题"
/>
</ElFormItem>
<ElFormItem prop="contactInfo" label="联系方式">
<ElInput
v-model="formData.contactInfo"
placeholder="请留下手机号/邮箱"
/>
</ElFormItem>
<ElFormItem prop="attachmentUrl" label="上传附件">
<Upload
ref="uploadRef"
@success="uploadSuccess"
@handle-delete="onRemove"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="showDialog = false">取消</ElButton>
<ElButton type="primary" :loading="loading" @click="handleSubmit">
立即反馈
</ElButton>
</div>
</template>
</ElDialog>
</template>

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,19 @@
import type { BotInfo } from '@easyflow/types';
import { requestClient } from '#/api/request';
/**
* 获取所有智能体
*/
export async function getBotList() {
return requestClient.get<BotInfo[]>('/api/v1/bot/list');
}
/**
* 获取智能体信息
*/
export async function getBotDetail(id: string) {
return requestClient.get<BotInfo>('/api/v1/bot/detail', {
params: { id },
});
}

View File

@@ -0,0 +1,55 @@
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>(
'/userCenter/auth/login',
data,
);
}
/**
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/userCenter/auth/logout', {
withCredentials: true,
});
}
/**
* 获取用户权限码
*/
export async function getAccessCodesApi() {
return requestClient.get<string[]>('/userCenter/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 './bot';
export * from './core';

View File

@@ -0,0 +1,225 @@
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: any) {
if (innerError.name !== 'AbortError') {
options?.onError?.(innerError);
}
}
// 只有在还是同一个请求的情况下才调用 onFinished
if (this.currentRequestId === currentRequestId) {
options?.onFinished?.();
}
} catch (error: any) {
if (this.currentRequestId !== currentRequestId) {
return;
}
if (error.name !== 'AbortError') {
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,20 @@
<script lang="ts" setup>
import { useElementPlusDesignTokens } from '@easyflow/hooks';
import { ElConfigProvider } from 'element-plus';
import { elementLocale } from '#/locales';
import IssueFeedbackModal from './IssueFeedbackModal.vue';
defineOptions({ name: 'App' });
useElementPlusDesignTokens();
</script>
<template>
<ElConfigProvider :locale="elementLocale">
<RouterView />
<IssueFeedbackModal />
</ElConfigProvider>
</template>

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: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,18 @@
<?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>编组 31备份</title>
<defs>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" 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="编组-31备份">
<g id="编组-26备份-4" fill="#EAF1FF">
<circle id="椭圆形" cx="18" cy="18" r="18"></circle>
</g>
<path d="M22.0000013,9.27000831 C23.5806928,9.27000831 25.012114,9.91249995 26.0489165,10.948266 C27.0873136,11.985625 27.7300013,13.4175549 27.7300013,14.9940099 L27.7300013,26.7299964 L14.0000013,26.7299964 C12.4193079,26.7299964 10.9878865,26.0875068 9.95108437,25.0517426 C8.91268789,24.0143857 8.27000134,22.5824579 8.27000134,21.0060032 L8.27000128,14.9938295 C8.26898585,13.4744752 8.8724896,12.0171412 9.94737373,10.9433328 C11.0222583,9.869524 12.4801971,9.26747909 14.0000013,9.27000831 L22.0000013,9.27000831 Z M21.3544257,17.0557682 C21.0771157,16.9710935 20.7917374,17.0068369 20.5545179,17.1330764 C20.3172983,17.259316 20.1282376,17.4760517 20.0435628,17.7533615 C19.90909,18.1937594 19.6918883,18.5208585 19.3594341,18.733135 C19.015785,18.9525596 18.5583757,19.0499946 17.9717181,19.0499946 C17.209604,19.0499946 16.6651374,18.8127795 16.2758196,18.4020297 C16.1425405,18.2614135 16.0388319,18.1091274 15.8891109,17.8086876 C15.798089,17.5424704 15.609964,17.3357472 15.3771573,17.2156088 C15.1383604,17.0923791 14.8525529,17.0602474 14.5763359,17.1484221 C14.267636,17.246966 14.0320655,17.4688639 13.9496414,17.8365139 C13.8904645,18.1004709 13.9152461,18.4558157 14.1030753,18.9443889 C14.2663453,19.2531702 14.4798805,19.5599106 14.7516641,19.8466561 C15.5200513,20.6573432 16.5930767,21.1499929 17.9717181,21.1499929 C19.0191517,21.1499929 19.8967293,20.90693 20.5826261,20.44174 C21.2723005,19.9739879 21.7728508,19.2809071 22.0520195,18.3666306 C22.1366943,18.0893207 22.1009509,17.8039424 21.9747112,17.566723 C21.8484716,17.3295036 21.6317357,17.140443 21.3544257,17.0557682 Z M21.0199956,13.2899949 C20.630635,13.2899949 20.2781351,13.4478143 20.0229754,13.7029739 C19.7678156,13.9581335 19.609996,14.3106332 19.609996,14.6999936 C19.609996,15.089354 19.7678156,15.4418537 20.0229754,15.6970133 C20.2781351,15.9521728 20.630635,16.1099923 21.0199956,16.1099923 C21.4093562,16.1099923 21.7618561,15.9521728 22.0170158,15.6970133 C22.2721756,15.4418537 22.4299952,15.089354 22.4299952,14.6999936 C22.4299952,14.3106332 22.2721756,13.9581335 22.0170158,13.7029739 C21.7618561,13.4478143 21.4093562,13.2899949 21.0199956,13.2899949 Z M15.1399976,13.2899949 C14.750637,13.2899949 14.3981371,13.4478143 14.1429774,13.7029739 C13.8878176,13.9581335 13.729998,14.3106332 13.729998,14.6999936 C13.729998,15.089354 13.8878176,15.4418537 14.1429774,15.6970133 C14.3981371,15.9521728 14.750637,16.1099923 15.1399976,16.1099923 C15.5293582,16.1099923 15.8818581,15.9521728 16.1370178,15.6970133 C16.3921776,15.4418537 16.5499972,15.089354 16.5499972,14.6999936 C16.5499972,14.3106332 16.3921776,13.9581335 16.1370178,13.7029739 C15.8818581,13.4478143 15.5293582,13.2899949 15.1399976,13.2899949 Z" id="形状结合" stroke="url(#linearGradient-1)" stroke-width="0.54" fill="url(#linearGradient-1)"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,85 @@
import { createApp, watchEffect } from 'vue';
import { BubbleList, 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('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,23 @@
<script setup lang="ts">
import { cn } from '@easyflow/utils';
interface CardProps {
class?: string;
}
defineOptions({ name: 'Card' });
const props = defineProps<CardProps>();
</script>
<template>
<div
:class="
cn(
'flex w-full max-w-60 cursor-pointer items-start gap-2.5 rounded-lg pb-2.5 pl-2.5 pr-5 pt-3.5',
props.class,
)
"
>
<slot></slot>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { cn } from '@easyflow/utils';
import { Avatar } from '@element-plus/icons-vue';
import { ElAvatar } from 'element-plus';
interface CardAvatarProps {
class?: string;
size?: number;
src?: string;
defaultAvatar?: string;
}
defineOptions({ name: 'CardAvatar' });
const props = defineProps<CardAvatarProps>();
</script>
<template>
<template v-if="props.defaultAvatar">
<ElAvatar
:class="cn('shrink-0', props.class)"
:size="props.size ?? 36"
:src="props.src ?? props.defaultAvatar"
/>
</template>
<template v-else>
<ElAvatar
:class="cn('shrink-0', props.class)"
:size="props.size ?? 36"
:icon="Avatar"
/>
</template>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '@easyflow/utils';
interface CardContentProps {
class?: string;
}
defineOptions({ name: 'CardContent' });
const props = defineProps<CardContentProps>();
</script>
<template>
<div
:class="
cn('flex w-full flex-col gap-1 overflow-hidden text-nowrap', props.class)
"
>
<slot></slot>
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { cn } from '@easyflow/utils';
interface CardDescriptionProps {
class?: string;
}
defineOptions({ name: 'CardDescription' });
const props = defineProps<CardDescriptionProps>();
</script>
<template>
<span
:class="
cn(
'text-foreground/50 overflow-hidden text-ellipsis text-xs',
props.class,
)
"
>
<slot></slot>
</span>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '@easyflow/utils';
interface CardTitleProps {
class?: string;
}
defineOptions({ name: 'CardTitle' });
const props = defineProps<CardTitleProps>();
</script>
<template>
<span
:class="
cn('overflow-hidden text-ellipsis text-base font-semibold', props.class)
"
>
<slot></slot>
</span>
</template>

View File

@@ -0,0 +1,5 @@
export { default as Card } from './Card.vue';
export { default as CardAvatar } from './CardAvatar.vue';
export { default as CardContent } from './CardContent.vue';
export { default as CardDescription } from './CardDescription.vue';
export { default as CardTitle } from './CardTitle.vue';

View File

@@ -0,0 +1,219 @@
<script setup>
import { computed } from 'vue';
import { ArrowDown } from '@element-plus/icons-vue';
import {
ElAvatar,
ElButton,
ElCard,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElIcon,
} from 'element-plus';
const props = defineProps({
titleKey: {
type: String,
default: 'title',
},
avatarKey: {
type: String,
default: 'avatar',
},
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 });
};
</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]" />
<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"
:type="action.type || 'primary'"
:icon="action.icon"
size="small"
@click="handleActionClick(action, item)"
>
{{ action.label }}
</ElButton>
<!-- 更多操作下拉菜单 -->
<ElDropdown
v-if="dropdownActions.length > 0"
@command="(command) => handleActionClick(command, item)"
>
<ElButton size="small" style="margin-left: 8px">
更多
<ElIcon class="el-icon--right">
<ArrowDown />
</ElIcon>
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="action in dropdownActions"
:key="action.name"
:command="action"
:icon="action.icon"
>
{{ action.label }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
</ElCard>
</div>
<!-- 空状态保留 -->
<div v-if="data.length === 0" class="empty-state">
<ElEmpty description="暂无数据" />
</div>
</div>
</template>
<style scoped>
.card-list-container {
width: 100%;
height: 100%;
}
.card-list {
display: flex;
min-width: 300px;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
}
.card-item {
transition: all 0.3s ease;
border-radius: 8px;
width: 330px;
flex-shrink: 0;
}
.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;
}
.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;
flex-wrap: wrap;
align-items: center;
}
.empty-state {
padding: 40px 0;
text-align: center;
}
/* 响应式设计(移除分页相关样式,保留卡片适配) */
@media (max-width: 768px) {
.card-list {
justify-content: center; /* 小屏幕下卡片居中 */
}
.card-item {
width: 100%;
max-width: 330px;
}
.card-header {
flex-direction: column;
align-items: center;
text-align: center;
}
}
</style>

View File

@@ -0,0 +1,345 @@
<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,
},
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', // 面板收缩状态改变事件
]);
// -------------------------- 核心工具函数 --------------------------
/**
* 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) => {
selectedCategory.value = category[props.titleKey];
emit('click', category);
};
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[titleKey] }"
@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; /* 相对定位,用于按钮绝对定位 */
border: 1px solid #e5e7eb;
border-radius: 4px;
overflow: hidden;
height: 100%;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* 平滑宽度过渡 */
box-sizing: border-box;
background-color: var(--el-bg-color);
}
/* 右上角收缩/展开按钮 */
.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;
}
.category-item {
}
.category-item:last-child {
border-bottom: none;
}
.category-item-content {
display: flex;
align-items: center;
padding: 12px 16px;
font-size: 14px;
font-weight: 500;
color: #333;
cursor: pointer;
transition: background-color 0.2s;
gap: 12px;
}
.category-item-content:hover {
background-color: #f9fafb;
}
.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;
padding: 12px 16px;
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;
color: var(--el-text-color-primary);
}
/* 收缩状态样式 */
.hidden {
display: none;
}
.collapsed .category-item-content {
justify-content: center;
padding: 12px 0;
}
/* 收缩状态下文字强制隐藏(避免无图标时文字溢出) */
.collapsed .category-name {
display: none;
}
/* 新增:选中态样式 */
.category-item-content.selected {
background-color: var(--el-color-primary-light-9);
color: var(--el-text-color-primary);
font-weight: 600;
}
.category-item-content.selected:hover {
background-color: var(--el-color-primary-light-9);
}
</style>

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import { IconifyIcon } from '@easyflow/icons';
import { useUserStore } from '@easyflow/stores';
import { CircleCheck } from '@element-plus/icons-vue';
import { ElAvatar, ElCollapse, ElCollapseItem, ElIcon } from 'element-plus';
import defaultAssistantAvatar from '#/assets/defaultAssistantAvatar.svg';
import defaultUserAvatar from '#/assets/defaultUserAvatar.png';
import ShowJson from '#/components/json/ShowJson.vue';
interface Props {
bot: any;
messages: any[];
}
const props = defineProps<Props>();
const store = useUserStore();
function getAssistantAvatar() {
return props.bot.icon || defaultAssistantAvatar;
}
function getUserAvatar() {
return store.userInfo?.avatar || defaultUserAvatar;
}
</script>
<template>
<ElBubbleList :list="messages" max-height="calc(100vh - 345px)">
<!-- 自定义头像 -->
<template #avatar="{ item }">
<ElAvatar
:src="
item.role === 'assistant' ? getAssistantAvatar() : getUserAvatar()
"
:size="40"
/>
</template>
<!-- 自定义头部 -->
<template #header="{ item }">
<div class="flex flex-col">
<span class="text-foreground/50 text-xs">
{{ item.created }}
</span>
<template v-if="item.chains">
<template
v-for="(chain, index) in item.chains"
:key="chain.id || index"
>
<ElThinking
v-if="!('id' in 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>工具调用中...</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>调用成功</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"
/> -->
<!-- <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 #content="{ item }">
<ElXMarkdown :markdown="item.content" />
</template>
<!-- 自定义底部 -->
<!--<template #footer="{ item }">
<div class="flex items-center">
<template v-if="item.role === 'assistant'">
<ElButton :icon="RefreshRight" link />
<ElButton :icon="CopyDocument" link />
</template>
<template v-else>
<ElButton :icon="CopyDocument" link />
<ElButton :icon="EditPen" link />
</template>
</div>
</template>-->
</ElBubbleList>
</template>
<style lang="css" scoped>
:deep(.el-bubble-header) {
width: 100%;
}
:deep(.el-bubble-end .el-bubble-header) {
width: fit-content;
}
:deep(.el-bubble-content-wrapper .el-bubble-content) {
--bubble-content-max-width: 100%;
}
:deep(.el-thinking) {
margin: 0;
}
:deep(.el-thinking .content-wrapper) {
--el-thinking-content-wrapper-width: 100%;
margin-bottom: 8px;
}
:deep(.el-collapse) {
overflow: hidden;
border: 1px solid var(--el-collapse-border-color);
border-radius: 8px;
}
:deep(.el-collapse-item__content) {
padding-bottom: 0;
}
</style>

View File

@@ -0,0 +1,315 @@
<script setup lang="ts">
import { nextTick, provide, ref, watch } from 'vue';
import { IconifyIcon } from '@easyflow/icons';
import { cn } from '@easyflow/utils';
import { Delete, Edit, MoreFilled } from '@element-plus/icons-vue';
import {
ElAside,
ElButton,
ElContainer,
ElDialog,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElForm,
ElFormItem,
ElHeader,
ElInput,
ElMain,
ElMessage,
ElMessageBox,
} from 'element-plus';
import { api } from '#/api/request';
import ChatIcon from '#/components/icons/ChatIcon.vue';
import { $t } from '#/locales';
interface Props {
bot: any;
isFold: boolean;
onMessageList?: (list: any[]) => void;
toggleFold: () => void;
}
const props = defineProps<Props>();
const sessionList = ref<any>([]);
const currentSession = ref<any>({});
const hoverId = ref<string>();
const dialogVisible = ref(false);
watch(
() => props.bot.id,
() => {
getSessionList(true);
},
);
defineExpose({
getSessionList,
});
function getSessionList(resetSession = false) {
api
.get('/userCenter/botConversation/list', {
params: {
botId: props.bot.id,
},
})
.then((res) => {
if (res.errorCode === 0) {
sessionList.value = res.data;
if (resetSession) {
currentSession.value = {};
}
}
});
}
provide('getSessionList', getSessionList);
function addSession() {
const newSession = sessionList.value.find(
(session: any) => session.title === '新对话' && !session.created,
);
if (newSession) {
return;
}
api.get('/userCenter/bot/generateConversationId').then((res) => {
const data = {
botId: props.bot.id,
title: '新对话',
id: res.data,
};
sessionList.value.unshift(data);
nextTick(() => {
clickSession(data);
});
});
}
function clickSession(session: any) {
currentSession.value = session;
getMessageList();
}
function getMessageList() {
api
.get('/userCenter/botMessage/getMessages', {
params: {
botId: props.bot.id,
conversationId: currentSession.value.id,
},
})
.then((res) => {
if (res.errorCode === 0) {
props.onMessageList?.(res.data);
}
});
}
function formatCreatedTime(time: string) {
if (time) {
const createTime = Math.floor(new Date(time).getTime() / 1000);
const today = Math.floor(Date.now() / 1000 / 86_400) * 86_400;
return time.split(' ')[createTime < today ? 0 : 1];
}
return '';
}
const handleMouseEvent = (id?: string) => {
if (id === undefined) {
setTimeout(() => {
hoverId.value = id;
}, 200);
} else {
hoverId.value = id;
}
};
const updateLoading = ref(false);
function updateTitle() {
updateLoading.value = true;
api
.get('/userCenter/botConversation/updateConversation', {
params: {
botId: props.bot.id,
conversationId: currentSession.value.id,
title: currentSession.value.title,
},
})
.then((res) => {
updateLoading.value = false;
if (res.errorCode === 0) {
dialogVisible.value = false;
ElMessage.success('成功');
getSessionList();
}
});
}
function remove(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.get('/userCenter/botConversation/deleteConversation', {
params: {
botId: props.bot.id,
conversationId: row.id,
},
})
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
props.onMessageList?.([]);
currentSession.value = {};
ElMessage.success(res.message);
done();
getSessionList();
}
})
.catch(() => {
instance.confirmButtonLoading = false;
});
} else {
done();
}
},
}).catch(() => {});
}
</script>
<template>
<ElContainer class="border-border bg-background h-full rounded-lg border">
<ElAside width="287px" class="border-border border-r p-6">
<!-- <Card class="max-w-max p-0">
<CardAvatar :src="bot.icon" :default-avatar="defaultAssistantAvatar" />
<CardContent>
<CardTitle>{{ bot.title }}</CardTitle>
<CardDescription>{{ bot.description }}</CardDescription>
</CardContent>
</Card> -->
<span>会话</span>
<ElButton
class="mt-6 !h-10 w-full !text-sm"
type="primary"
:icon="ChatIcon"
plain
@click="addSession"
>
新建会话
</ElButton>
<div class="mt-8">
<div
v-for="conversation in sessionList"
:key="conversation.id"
:class="
cn(
'group flex h-10 cursor-pointer items-center justify-between gap-1 rounded-lg px-5 text-sm',
currentSession.id === conversation.id
? 'bg-[hsl(var(--primary)/15%)] dark:bg-[hsl(var(--accent))]'
: 'hover:bg-[hsl(var(--accent))]',
)
"
@click="clickSession(conversation)"
>
<span
:class="
cn(
'text-foreground overflow-hidden text-ellipsis text-nowrap',
currentSession.id === conversation.id && 'text-primary',
)
"
:title="conversation.title || '未命名'"
>
{{ conversation.title || '未命名' }}
</span>
<span
:class="
cn(
'text-foreground/50 text-nowrap text-xs group-hover:hidden',
hoverId === conversation.id && 'hidden',
)
"
>
{{ formatCreatedTime(conversation.created) }}
</span>
<ElDropdown
:class="
cn(
'group-hover:!inline-flex',
(!hoverId || conversation.id !== hoverId) && '!hidden',
)
"
@click.stop
trigger="click"
>
<ElButton link :icon="MoreFilled" @click.stop />
<template #dropdown>
<ElDropdownMenu
@mouseenter="handleMouseEvent(conversation.id)"
@mouseleave="handleMouseEvent()"
>
<ElDropdownItem @click="dialogVisible = true">
<ElButton link :icon="Edit">编辑</ElButton>
</ElDropdownItem>
<ElDropdownItem>
<ElButton
@click="remove(conversation)"
link
type="danger"
:icon="Delete"
>
删除
</ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
</ElAside>
<ElContainer>
<ElHeader class="border-border border-b" height="53">
<div class="flex items-center justify-between">
<span class="text-base/[53px] font-medium">
{{ currentSession.title || '未命名' }}
</span>
<IconifyIcon
v-if="isFold"
icon="svg:assistant-fold"
class="rotate-180 cursor-pointer"
@click="toggleFold"
/>
</div>
</ElHeader>
<ElMain>
<slot :conversation-id="currentSession.id"></slot>
</ElMain>
</ElContainer>
<ElDialog title="编辑" v-model="dialogVisible">
<div class="p-5">
<ElForm>
<ElFormItem>
<ElInput
v-model="currentSession.title"
placeholder="请输入会话名称"
/>
</ElFormItem>
</ElForm>
</div>
<template #footer>
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="updateTitle" :loading="updateLoading">
确认
</ElButton>
</template>
</ElDialog>
</ElContainer>
</template>
<style lang="css" scoped>
.el-button :deep(.el-icon) {
font-size: 20px;
}
</style>

View File

@@ -0,0 +1,3 @@
export { default as ChatBubbleList } from './bubbleList.vue';
export { default as ChatContainer } from './container.vue';
export { default as ChatSender } from './sender.vue';

View File

@@ -0,0 +1,265 @@
<script setup lang="ts">
import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
import { inject, ref } from 'vue';
import { cloneDeep, uuid } from '@easyflow/utils';
import { Paperclip, Promotion } from '@element-plus/icons-vue';
import { ElButton, ElIcon } from 'element-plus';
import { sseClient } from '#/api/request';
import SendingIcon from '#/components/icons/SendingIcon.vue';
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
// import PaperclipIcon from '#/components/icons/PaperclipIcon.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 = BubbleProps & {
chains?: (Think | Tool)[];
key: string;
role: 'assistant' | 'user';
};
interface Props {
conversationId: string | undefined;
bot: any;
addMessage: (message: MessageItem) => void;
updateLastMessage: (item: any) => void;
stopThinking: () => void;
}
const props = defineProps<Props>();
const senderValue = ref('');
const btnLoading = ref(false);
const getSessionList = inject<any>('getSessionList');
const clearSenderFiles = () => {
files.value = [];
attachmentsRef.value?.clearFiles();
openCloseHeader();
};
function sendMessage() {
if (getDisabled()) {
return;
}
const data = {
conversationId: props.conversationId,
prompt: senderValue.value,
botId: props.bot.id,
attachments: attachmentsRef.value?.getFileList(),
};
clearSenderFiles();
btnLoading.value = true;
props.addMessage({
key: uuid(),
role: 'user',
placement: 'end',
content: senderValue.value,
typing: true,
});
props.addMessage({
key: uuid(),
role: 'assistant',
placement: 'start',
content: '',
loading: true,
typing: true,
});
senderValue.value = '';
let content = '';
sseClient.post('/userCenter/bot/chat', data, {
onMessage(res) {
if (!res.data) {
return;
}
const sseData = JSON.parse(res.data);
const delta = sseData.payload?.delta;
if (res.event === 'done') {
btnLoading.value = false;
getSessionList();
}
// 处理系统错误
if (
sseData?.domain === 'SYSTEM' &&
sseData.payload?.code === 'SYSTEM_ERROR'
) {
const errorMessage = sseData.payload.message;
props.updateLastMessage({
content: errorMessage,
loading: false,
typing: false,
});
return;
}
if (sseData?.domain === 'TOOL') {
props.updateLastMessage((message: MessageItem) => {
const chains = cloneDeep(message.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,
};
}
return { chains };
});
props.stopThinking();
return;
}
if (sseData.type === 'THINKING') {
props.updateLastMessage((message: MessageItem) => {
const chains = cloneDeep(message.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,
};
}
return { chains };
});
} else if (sseData.type === 'MESSAGE') {
props.updateLastMessage({
thinkingStatus: 'end',
loading: false,
content: (content += delta),
});
props.stopThinking();
}
},
onError(err) {
console.error(err);
btnLoading.value = false;
},
onFinished() {
senderValue.value = '';
btnLoading.value = false;
props.updateLastMessage({ loading: false });
props.stopThinking();
},
});
}
const isTool = (item: Think | Tool) => {
return 'id' in item;
};
const isThink = (item: Think | Tool): item is Think => {
return !('id' in item);
};
function getDisabled() {
return !senderValue.value || !props.conversationId;
}
const stopSse = () => {
sseClient.abort();
btnLoading.value = false;
};
const showHeaderFlog = ref(false);
const attachmentsRef = ref();
const senderRef = ref();
const files = ref<any[]>([]);
function handlePasteFile(_: any, fileList: FileList) {
showHeaderFlog.value = true;
senderRef.value?.openHeader();
files.value = [...fileList];
}
function openCloseHeader() {
if (showHeaderFlog.value) {
senderRef.value?.closeHeader();
files.value = [];
} else {
senderRef.value?.openHeader();
}
showHeaderFlog.value = !showHeaderFlog.value;
}
</script>
<template>
<ElSender
ref="senderRef"
v-model="senderValue"
variant="updown"
:auto-size="{ minRows: 2, maxRows: 5 }"
clearable
allow-speech
placeholder="发送消息"
@keyup.enter="sendMessage"
@paste-file="handlePasteFile"
>
<!-- 自定义 prefix 前缀 -->
<!-- <template #prefix>
</template> -->
<!-- 自定义头部内容 -->
<template #header>
<ChatFileUploader
ref="attachmentsRef"
:external-files="files"
@delete-all="openCloseHeader"
:max-size="10"
/>
</template>
<template #action-list>
<div class="flex items-center gap-2">
<ElButton circle @click="openCloseHeader">
<ElIcon><Paperclip /></ElIcon>
</ElButton>
<!-- <ElButton :icon="PaperclipIcon" link /> -->
<ElButton v-if="btnLoading" circle @click="stopSse">
<ElIcon size="30" color="#409eff"><SendingIcon /></ElIcon>
</ElButton>
<ElButton
v-else
type="primary"
:icon="Promotion"
:disabled="getDisabled()"
@click="sendMessage"
round
/>
</div>
</template>
</ElSender>
</template>

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 {
color: #909399;
font-size: 12px;
margin-right: 4px;
}
</style>

View File

@@ -0,0 +1,209 @@
<script setup>
import { computed, ref } from 'vue';
import { ArrowDown } from '@element-plus/icons-vue';
import {
ElButton,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElIcon,
ElInput,
} from 'element-plus';
// 定义组件属性
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: '请输入搜索内容',
},
});
const emit = defineEmits(['search', 'button-click', 'buttonClick']);
// 搜索值
const searchValue = ref('');
// 计算显示的按钮
const visibleButtons = computed(() => {
return props.buttons.slice(0, props.maxVisibleButtons);
});
// 计算下拉菜单中的按钮
const dropdownButtons = computed(() => {
return props.buttons.slice(props.maxVisibleButtons);
});
// 处理搜索
const handleSearch = () => {
emit('search', searchValue.value);
};
const handleReset = () => {
searchValue.value = '';
};
// 处理按钮点击
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" @click="handleSearch">
{{ $t('button.query') }}
</ElButton>
</div>
<div>
<ElButton @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"
@click="handleButtonClick(button)"
>
{{ button.text }}
</ElButton>
</template>
<!-- 下拉菜单隐藏的按钮 -->
<ElDropdown
v-if="dropdownButtons.length > 0"
@command="handleDropdownClick"
>
<ElButton>
更多<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>
.custom-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e4e7ed;
}
.header-left {
display: flex;
align-items: center;
}
.search-container {
display: flex;
gap: 12px;
}
.search-input {
border-radius: 4px;
width: 300px;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.custom-header {
flex-direction: column;
gap: 16px;
padding: 12px 16px;
}
.header-left,
.header-right {
width: 100%;
justify-content: center;
}
.search-container {
width: 100%;
}
.header-right {
flex-wrap: wrap;
}
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<svg
width="1em"
height="1em"
viewBox="0 0 20 20"
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(-665, -128)"
fill="currentColor"
fill-rule="nonzero"
>
<g id="编组-2" transform="translate(587, 118)">
<g id="聊天" transform="translate(78, 10)">
<rect id="矩形" opacity="0" x="0" y="0" width="20" height="20" />
<path
d="M9.58369141,2.95388672 C14.0753725,2.95388672 17.7682147,5.80492802 17.9985115,9.43806706 L18.0004805,9.48688672 L18.0451616,9.54683168 C18.5191813,10.2146674 18.7903205,10.9649721 18.835701,11.7441169 L18.8425195,11.9786719 C18.8425195,13.3549242 18.1384458,14.6409769 16.9220514,15.5524004 L16.7804805,15.6528867 L16.8050955,15.6856721 L16.8382762,15.7265555 L16.8690426,15.7667797 C17.4098878,16.5545617 16.8687213,17.2724569 15.9571984,16.9732091 C15.8642431,16.9415552 15.8642431,16.9415552 15.7710491,16.9104204 C15.6220312,16.860775 15.4777901,16.8139646 15.3433483,16.7717381 C15.2928614,16.7559124 15.2928614,16.7559124 15.2437809,16.7407435 C15.0678614,16.6865476 14.9227047,16.6449372 14.8148836,16.6178971 C14.7968483,16.6133761 14.7799872,16.6093002 14.7645016,16.6057079 L14.7234805,16.5958867 L14.5046465,16.6525745 C14.084516,16.7488676 13.651768,16.8072691 13.212396,16.8267779 L12.8817187,16.8341016 C12.2807983,16.8341016 11.6894804,16.7618391 11.1229611,16.6200235 L10.8964805,16.5568867 L10.6544807,16.5865773 C10.4186334,16.6108231 10.1811627,16.6270228 9.94250755,16.6351286 L9.58369141,16.6412109 C8.77558458,16.6412109 7.97964018,16.5489412 7.21402919,16.3674942 L6.91548047,16.2898867 L6.84755984,16.3052468 C6.82232182,16.3110111 6.79476708,16.3174849 6.76473886,16.3248639 L6.66742199,16.3496762 C6.4750594,16.4002979 6.2183789,16.4758066 5.90552572,16.5735806 C5.83361971,16.5961023 5.83361971,16.5961023 5.75995684,16.6194104 C5.50976359,16.6988698 5.26015731,16.7801657 5.01112132,16.8633037 L4.81936416,16.9276073 C4.80582608,16.9321754 4.79028932,16.9374202 4.77164522,16.943714 L4.74701067,16.9520621 L4.67837195,16.9753855 C3.7618984,17.2799458 3.23238997,16.5507627 3.80022944,15.7477041 C3.82916944,15.7104932 3.84827678,15.6852432 3.88168805,15.6396486 C3.9483591,15.5485583 4.01134766,15.4568837 4.06769221,15.3684183 C4.11335085,15.2967489 4.15321837,15.2293824 4.18650908,15.1675409 C4.19217329,15.1570245 4.19990192,15.1419287 4.2087555,15.1243507 L4.22848047,15.0848867 L4.21690393,15.0771266 C2.36245034,13.8340367 1.24898298,12.0130046 1.16287272,10.0443576 L1.15748047,9.79753906 C1.15748047,6.00241783 4.93601286,2.95388672 9.58369141,2.95388672 Z M12.8817188,8.15710937 C10.1538598,8.15710937 7.95355469,9.88463621 7.95355469,11.9786719 C7.95355469,13.5435846 9.19776958,14.9615506 11.086977,15.53757 C11.561609,15.6822897 12.06148,15.7682152 12.5732995,15.7928376 L12.8817187,15.8002344 C13.4517579,15.8002344 14.0105873,15.7248455 14.5405134,15.5772212 C14.7383518,15.5221203 14.904422,15.5545219 15.5473738,15.7525791 C15.5993294,15.7686199 15.5993294,15.7686199 15.652589,15.7853188 L15.6914805,15.7978867 L15.6668119,15.719849 C15.5932609,15.4423614 15.6517585,15.2003859 15.8659174,15.0256726 L15.9535697,14.96337 C17.1298176,14.2328612 17.8099023,13.1393491 17.8099023,11.9786719 C17.8099023,11.2731276 17.5612377,10.5904328 17.0871268,9.99055596 L16.9397313,9.8148591 C16.0220968,8.78296977 14.514899,8.15710937 12.8817188,8.15710937 Z M9.58369141,3.98777344 C5.49423672,3.98777344 2.19009766,6.60496037 2.19009766,9.79753906 C2.19009766,11.5751198 3.2213624,13.2441047 4.99832929,14.3518173 C5.43687887,14.6251903 5.43287823,15.0315683 5.09544355,15.6582022 L5.03348047,15.7668867 L5.06608386,15.7566779 L5.44826284,15.6337494 C5.52388404,15.6098474 5.52388404,15.6098474 5.59782273,15.5866828 C6.60057425,15.2732535 6.88382259,15.2099012 7.1115971,15.2735769 C7.6760283,15.4314037 8.26218983,15.5340528 8.86050039,15.5798257 L8.87748047,15.5798867 C7.71852945,14.7241657 6.99581224,13.5140803 6.92643099,12.1888419 L6.9209375,11.9786719 C6.9209375,9.28141712 9.59563849,7.12324219 12.8817188,7.12324219 C14.2700006,7.12324219 15.5864116,7.51243086 16.6314373,8.20022133 L16.7114805,8.25488672 L16.6574186,8.10572539 C15.7581372,5.78986552 13.062144,4.07976862 9.8456072,3.9913673 L9.58369141,3.98777344 Z"
id="形状结合"
/>
</g>
</g>
</g>
</g>
</svg>
</template>

View File

@@ -0,0 +1,38 @@
<template>
<svg
width="1em"
height="1em"
viewBox="0 0 13.4769231 20.6769231"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<title>编组 5</title>
<g
id="页面-1"
stroke="none"
stroke-width="1"
fill="none"
fill-rule="evenodd"
>
<g id="聊天助理" transform="translate(-1675.2615, -973.6615)">
<g id="编组-7" transform="translate(934, 860)">
<g
id="编组-5"
transform="translate(748, 124) scale(-1, 1) translate(-748, -124)translate(736, 112)"
>
<rect id="矩形" x="0" y="0" width="24" height="24" />
<path
d="M6,9.54856308 L6,15.6 C6,18.9137085 8.6862915,21.6 12,21.6 C15.3137085,21.6 18,18.9137085 18,15.6 L18,6.34185886 C18,4.16483033 16.2351697,2.4 14.0581411,2.4 C11.8811126,2.4 10.1162823,4.16483033 10.1162823,6.34185886 L10.1162823,15.6584871 C10.1162823,16.823829 11.0609786,17.7685254 12.2263206,17.7685254 C13.3916625,17.7685254 14.3363588,16.823829 14.3363588,15.6584871 L14.3363588,5.62527693 L14.3363588,5.62527693"
id="路径-10"
stroke="currentColor"
stroke-width="1.47692308"
stroke-linecap="round"
stroke-linejoin="round"
/>
</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,39 @@
<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.png" v-else />
</div>
</template>
<style scoped>
.res-container {
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
padding: 10px;
}
</style>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue';
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.png" v-else />
</div>
</template>

View File

@@ -0,0 +1,2 @@
export { default as RunResult } from './result.vue';
export { default as RunSteps } from './steps.vue';

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { cn } from '@easyflow/utils';
import { ElEmpty } from 'element-plus';
interface RunResultProps {
class?: string;
}
defineOptions({ name: 'RunResult' });
const props = defineProps<RunResultProps>();
</script>
<template>
<div
:class="
cn('flex flex-1 flex-col gap-6 rounded-lg bg-white p-5', props.class)
"
>
<h1 class="text-base font-medium text-[#1A1A1A]">运行结果</h1>
<div class="flex-1 rounded-lg border border-[#F0F0F0] bg-[#F7F7F7] p-4">
<ElEmpty />
</div>
</div>
</template>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { cn } from '@easyflow/utils';
import { ElCollapse, ElCollapseItem } from 'element-plus';
interface RunStepsProps {
class?: string;
}
defineOptions({ name: 'RunSteps' });
const props = defineProps<RunStepsProps>();
</script>
<template>
<div
:class="
cn('flex h-full flex-col gap-6 rounded-lg bg-white p-5', props.class)
"
>
<h1 class="text-base font-medium text-[#1A1A1A]">执行步骤</h1>
<ElCollapse expand-icon-position="left" accordion>
<ElCollapseItem title="Consistency" name="1">
<div>
Consistent with real life: in line with the process and logic of real
life, and comply with languages and habits that the users are used to;
</div>
<div>
Consistent within interface: all elements should be consistent, such
as: design style, icons and texts, position of elements, etc.
</div>
</ElCollapseItem>
<ElCollapseItem title="Feedback" name="2">
<div>
Operation feedback: enable the users to clearly perceive their
operations by style updates and interactive effects;
</div>
<div>
Visual feedback: reflect current state by updating or rearranging
elements of the page.
</div>
</ElCollapseItem>
<ElCollapseItem title="Efficiency" name="3">
<div>
Simplify the process: keep operating process simple and intuitive;
</div>
<div>
Definite and clear: enunciate your intentions clearly so that the
users can quickly understand and make decisions;
</div>
<div>
Easy to identify: the interface should be straightforward, which helps
the users to identify and frees them from memorizing and recalling.
</div>
</ElCollapseItem>
</ElCollapse>
</div>
</template>

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 {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
box-sizing: border-box;
white-space: nowrap;
user-select: none;
transition: all 0.2s ease-in-out;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, sans-serif;
}
.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;
margin-left: 4px;
cursor: pointer;
width: 16px;
height: 16px;
font-size: 12px;
line-height: 1;
border-radius: 50%;
transition: background-color 0.2s;
}
.tag__close:hover {
background-color: rgba(0, 0, 0, 0.1);
}
/* 为可关闭标签调整内边距 */
.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,261 @@
<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 {
background-color: #fff;
border: 1px solid #e4e7ed;
border-radius: 6px;
width: 100%;
}
.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,448 @@
<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';
// 定义组件props
interface Props {
modelValue?: string; // 双向绑定的图片URL
crop?: boolean; // 是否启用裁剪
action?: string; // 上传地址
headers?: Record<string, string>; // 上传请求头
data?: Record<string, any>; // 上传额外数据
cropConfig?: Partial<CropConfig>; // 裁剪配置
limit?: number; // 文件大小限制(MB)
}
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,
});
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: '图片裁剪',
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 = 'image/*';
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('只能上传图片文件!');
return;
}
const isLtLimit = file.size / 1024 / 1024 < props.limit;
if (!isLtLimit) {
ElMessage.error(`图片大小不能超过 ${props.limit}MB!`);
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('只能上传图片文件!');
return false;
}
const isLtLimit = rawFile.size / 1024 / 1024 < props.limit;
if (!isLtLimit) {
ElMessage.error(`图片大小不能超过 ${props.limit}MB!`);
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(`上传失败: ${response.message}`);
}
const imageUrl = response.data.path;
if (!imageUrl) {
throw new Error('上传成功但未返回图片URL');
}
emit('update:modelValue', imageUrl);
emit('uploadSuccess', imageUrl);
ElMessage.success('上传成功!');
} catch (error) {
const err = error instanceof Error ? error : new Error('上传失败');
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('裁剪器未初始化');
return;
}
cropperRef.value.getCropBlob(async (blob: Blob | null) => {
if (!blob) {
ElMessage.error('裁剪失败,无法获取裁剪后的图片');
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('上传失败');
emit('uploadError', err);
ElMessage.error(err.message);
} finally {
uploading.value = false;
}
});
};
// 删除图片
const handleRemove = () => {
emit('update:modelValue', '');
ElMessage.success('删除成功!');
};
// 清理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="image/*"
>
<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">
取消
</ElButton>
<ElButton type="primary" @click="handleCrop" :loading="uploading">
{{ uploading ? '上传中...' : '确认裁剪' }}
</ElButton>
</span>
</template>
</ElDialog>
</div>
</template>
<style scoped>
/* 样式保持不变 */
.image-upload-container {
display: inline-block;
}
.upload-area {
display: flex;
justify-content: center;
align-items: center;
}
.avatar-uploader {
width: 100px;
height: 100px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: border-color 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-uploader:hover {
border-color: #409eff;
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.preview-area {
display: flex;
flex-direction: column;
align-items: center;
}
.preview-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.preview-image {
width: 100px;
height: 100px;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.preview-actions {
display: flex;
gap: 5px;
}
.cropper-container {
height: 400px;
background: #f5f7fa;
display: flex;
justify-content: center;
align-items: center;
}
.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,582 @@
<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, 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: '图片裁剪',
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('只能上传图片文件!');
return;
}
const isLtLimit = file.size / 1024 / 1024 < props.limit;
if (!isLtLimit) {
ElMessage.error(`图片大小不能超过 ${props.limit}MB!`);
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('只能上传图片文件!');
return false;
}
const isLtLimit = rawFile.size / 1024 / 1024 < props.limit;
if (!isLtLimit) {
ElMessage.error(`图片大小不能超过 ${props.limit}MB!`);
return false;
}
if (fileList.value.length >= props.maxCount) {
ElMessage.error(`最多只能上传 ${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(`上传失败: ${response.message}`);
}
const imageUrl = response.data.path;
if (!imageUrl) {
throw new Error('上传成功但未返回图片URL');
}
// 更新文件列表
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 ? '重新上传成功!' : '上传成功!');
} catch (error) {
const err = error instanceof Error ? error : new Error('上传失败');
// 重置上传状态
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('裁剪器未初始化');
return;
}
cropperRef.value.getCropBlob(async (blob: Blob | null) => {
if (!blob) {
ElMessage.error('裁剪失败,无法获取裁剪后的图片');
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('上传失败');
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('删除成功!');
};
// 清理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 ? '上传中...' : '重新上传' }}
</ElButton>
<ElButton type="danger" text @click="handleRemove(index)">
<ElIcon><Delete /></ElIcon>
删除
</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">点击上传</div>
<div class="upload-hint">最多 {{ 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">
取消
</ElButton>
<ElButton type="primary" @click="handleCrop" :loading="uploading">
{{ uploading ? '上传中...' : '确认裁剪' }}
</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;
align-items: center;
gap: 8px;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #fff;
transition: all 0.3s;
}
.preview-container:hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
}
.preview-image {
width: 100px;
height: 100px;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.preview-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.file-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-area {
display: flex;
justify-content: center;
align-items: center;
}
.avatar-uploader {
width: 100px;
height: 140px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: border-color 0.3s;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 8px;
}
.avatar-uploader:hover {
border-color: #409eff;
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.upload-text {
font-size: 12px;
color: #606266;
text-align: center;
margin-bottom: 4px;
}
.upload-hint {
font-size: 10px;
color: #909399;
text-align: center;
}
.cropper-container {
height: 400px;
background: #f5f7fa;
display: flex;
justify-content: center;
align-items: center;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,79 @@
<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 { Upload } from '@element-plus/icons-vue';
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);
};
defineExpose({
clear() {
fileList.value = [];
},
});
</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 :icon="Upload">{{ $t('button.upload') }}</ElButton>
</ElUpload>
</template>

View File

@@ -0,0 +1,119 @@
<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';
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(`头像只能是${formatTypes.join(', ')}格式`);
return false;
} else if (rawFile.size / 1024 / 1024 > props.fileSize) {
ElMessage.error(`头像限制 ${props.fileSize} M`);
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 {
width: 100px;
height: 100px;
display: block;
}
</style>
<style>
.avatar-uploader .el-upload {
border: 1px dashed var(--el-border-color);
border-radius: 50%;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
}
.avatar-uploader .el-upload:hover {
border-color: var(--el-color-primary);
}
.el-icon.avatar-uploader-icon {
font-size: 28px;
color: var(--el-text-color-secondary);
width: 100px;
height: 100px;
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';
import { $t } from '#/locales';
const appName = computed(() => preferences.app.name);
const logo = computed(() => preferences.logo.source);
const logoDark = computed(() => preferences.logo.sourceDark);
</script>
<template>
<AuthPageLayout
:app-name="appName"
:logo="logo"
:logo-dark="logoDark"
:page-description="$t('authentication.pageDesc')"
:page-title="$t('authentication.pageTitle')"
>
<!-- 自定义工具栏 -->
<!-- <template #toolbar></template> -->
</AuthPageLayout>
</template>

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
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 router = useRouter();
const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark();
const menus = computed(() => [
{
handler: () => {
router.push({ path: '/personalCenter' });
},
icon: 'lucide:user',
text: $t('page.auth.profile'),
},
// 品牌外链入口(文档)已隐藏
]);
const avatar = computed(() => {
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
});
async function handleLogout() {
await authStore.logout(false);
}
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 #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本身的国际化文件。

View File

@@ -0,0 +1,102 @@
import type { Language } from 'element-plus/es/locale';
import type { App } from 'vue';
import type { LocaleSetupOptions, SupportedLanguagesType } from '@easyflow/locales';
import { ref } from 'vue';
import {
$t,
setupI18n as coreSetup,
loadLocalesMapFromDir,
} from '@easyflow/locales';
import { preferences } from '@easyflow/preferences';
import dayjs from 'dayjs';
import enLocale from 'element-plus/es/locale/lang/en';
import defaultLocale from 'element-plus/es/locale/lang/zh-cn';
const elementLocale = ref<Language>(defaultLocale);
const modules = import.meta.glob('./langs/**/*.json');
const localesMap = loadLocalesMapFromDir(
/\.\/langs\/([^/]+)\/(.*)\.json$/,
modules,
);
/**
* 加载应用特有的语言包
* 这里也可以改造为从服务端获取翻译数据
* @param lang
*/
async function loadMessages(lang: SupportedLanguagesType) {
const [appLocaleMessages] = await Promise.all([
localesMap[lang]?.(),
loadThirdPartyMessage(lang),
]);
return appLocaleMessages?.default;
}
/**
* 加载第三方组件库的语言包
* @param lang
*/
async function loadThirdPartyMessage(lang: SupportedLanguagesType) {
await Promise.all([loadElementLocale(lang), loadDayjsLocale(lang)]);
}
/**
* 加载dayjs的语言包
* @param lang
*/
async function loadDayjsLocale(lang: SupportedLanguagesType) {
let locale;
switch (lang) {
case 'en-US': {
locale = await import('dayjs/locale/en');
break;
}
case 'zh-CN': {
locale = await import('dayjs/locale/zh-cn');
break;
}
// 默认使用英语
default: {
locale = await import('dayjs/locale/en');
}
}
if (locale) {
dayjs.locale(locale);
} else {
console.error(`Failed to load dayjs locale for ${lang}`);
}
}
/**
* 加载element-plus的语言包
* @param lang
*/
async function loadElementLocale(lang: SupportedLanguagesType) {
switch (lang) {
case 'en-US': {
elementLocale.value = enLocale;
break;
}
case 'zh-CN': {
elementLocale.value = defaultLocale;
break;
}
}
}
async function setupI18n(app: App, options: LocaleSetupOptions = {}) {
await coreSetup(app, {
defaultLocale: preferences.app.locale,
loadMessages,
missingWarn: !import.meta.env.PROD,
...options,
});
}
export { $t, elementLocale, setupI18n };

View File

@@ -0,0 +1,79 @@
{
"id": "Id",
"alias": "Alias",
"deptId": "DeptId",
"tenantId": "TenantId",
"icon": "Icon",
"title": "Title",
"description": "Description",
"slug": "Slug",
"vectorStoreEnable": "VectorStoreEnable",
"vectorStoreType": "VectorStoreType",
"vectorStoreCollection": "VectorStoreCollection",
"vectorStoreConfig": "VectorStoreConfig",
"vectorEmbedLlmId": "VectorEmbedLlm",
"created": "Created",
"createdBy": "CreatedBy",
"modified": "Modified",
"modifiedBy": "ModifiedBy",
"options": "Options",
"rerankLlmId": "RerankLlm",
"searchEngineEnable": "SearchEngineEnable",
"englishName": "EnglishName",
"documentType": "DocumentType",
"fileName": "fileName",
"knowledgeCount": "Number of knowledge items",
"createdModifyTime": "Creation/update time",
"documentList": "documentList",
"knowledgeRetrieval": "knowledgeRetrieval",
"sorting": "Sorting",
"content": "Content",
"placeholder": {
"title": "Please input title",
"description": "Please provide a description so that the large model can better understand the knowledge base and make calls",
"englishName": "Please enter an English name",
"alias": "Please enter an alias, Chinese is not allowed",
"embedLlm": "Please choose a vector model",
"rerankLlm": "Please choose to rearrange the model",
"vectorStoreCollection": "Can only contain letters, numbers, and underscores with a length between 3-20 characters",
"vectorStoreType": "Please select the vector database type"
},
"importDoc": {
"fileUpload": "File upload",
"parameterSettings": "ParameterSettings",
"segmentedPreview": "SegmentedPreview",
"confirmImport": "ConfirmImport",
"fileName": "File Name",
"progressUpload": "Progress of file upload",
"fileSize": "File size"
},
"splitterDoc": {
"fileType": "FileType",
"splitterName": "Segmenter",
"chunkSize": "SegmentLength",
"overlapSize": "SegmentOverlap",
"regex": "RegularExpression",
"document": "Document",
"simpleDocumentSplitter": "SimpleDocumentSplitter",
"simpleTokenizeSplitter": "SimpleTokenizeSplitter",
"regexDocumentSplitter": "RegexDocumentSplitter",
"uploadStatus": "UploadStatus",
"pendingUpload": "PendingUpload",
"completed": "Completed",
"uploading": "Parsing in progress",
"importSuccess": "ImportSuccess"
},
"documentManagement": "Document management",
"actions": {
"knowledge": "Knowledge",
"retrieve": "Retrieve",
"addKnowledge": "AddKnowledge",
"confirmImport": "ConfirmImport",
"cancelImport": "CancelImport"
},
"searchResults": "SearchResults",
"documentPreview": "DocumentPreview",
"total": "Total",
"segments": "Segments",
"similarityScore": "SimilarityScore"
}

View File

@@ -0,0 +1,26 @@
{
"id": "Id",
"pluginId": "PluginId",
"name": "Tool name",
"description": "Description",
"basePath": "BasePath",
"created": "Created",
"status": "Status",
"inputData": "InputData",
"outputData": "OutputData",
"requestMethod": "RequestMethod",
"serviceStatus": "ServiceStatus",
"debugStatus": "DebugStatus",
"englishName": "EnglishName",
"createPluginTool": "Create tool",
"pluginToolEdit": {
"basicInfo": "Basic Info",
"configureInputParameters": "Configure input parameters",
"configureOutputParameters": "Configure output parameters",
"trialRun": "Trial run",
"toolPath": "Tool path",
"requestMethod": "RequestMethod",
"runResult": "Run result",
"run": "run"
}
}

View File

@@ -0,0 +1,19 @@
{
"id": "Id",
"deptId": "DeptId",
"tenantId": "TenantId",
"resourceType": "ResourceType",
"resourceName": "ResourceName",
"suffix": "Suffix",
"resourceUrl": "ResourceUrl",
"origin": "Origin",
"status": "Status",
"created": "Created",
"createdBy": "CreatedBy",
"modified": "Modified",
"modifiedBy": "ModifiedBy",
"options": "Options",
"isDeleted": "IsDeleted",
"fileSize": "FileSize",
"choose": "Choose"
}

View File

@@ -0,0 +1,22 @@
{
"id": "Id",
"alias": "Alias",
"deptId": "DeptId",
"tenantId": "TenantId",
"title": "Title",
"description": "Description",
"icon": "Icon",
"content": "Content",
"created": "Created",
"createdBy": "CreatedBy",
"modified": "Modified",
"modifiedBy": "ModifiedBy",
"englishName": "EnglishName",
"status": "Status",
"categoryId": "Category",
"params": "Params",
"steps": "Steps",
"result": "Result",
"confirm": "For contents to be confirmed, please confirm first!",
"completed": "Chain has been completed, please start a new one."
}

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