Compare commits

..

5 Commits

Author SHA1 Message Date
e762188322 1.加入ai对话框 模块日报功能 2026-03-25 08:40:33 +08:00
bb5ab23430 1.AI助手模版、样式修改成谢提供的对话模版(有特效)。
2.点击AI分析后,对话弹出类似商品链接引入,用户点击确认后进行分析。
3.AI分析对话新增提示词编辑,根据组件名称:模块编号进行获取提示词进行模块分析。
4.商品大盘首页字体调整,数据卡片中的字体调小一点,文字描述超长不显示,鼠标放上去显示全量。
2026-03-20 08:47:39 +08:00
f3a5569f55 fix: 提交标签同步颜色字段 2026-03-12 16:04:16 +08:00
fccdcaf89a fix: 提交标签同步 2026-03-09 16:55:28 +08:00
39246c9483 fix: 标准标签 2026-03-06 15:45:30 +08:00
40 changed files with 4287 additions and 583 deletions

2
.env
View File

@@ -1,5 +1,5 @@
# 标题 # 标题
VITE_APP_TITLE=衣朵云管理系统 VITE_APP_TITLE=Ai 决策通
# 项目本地运行端口号 # 项目本地运行端口号
VITE_PORT=80 VITE_PORT=80

View File

@@ -4,8 +4,8 @@ NODE_ENV=production
VITE_DEV=true VITE_DEV=true
# 请求路径 # 请求路径
VITE_BASE_URL='http://localhost:48080' #VITE_BASE_URL='http://localhost:48080'
#VITE_BASE_URL='http://118.253.178.8:48080' VITE_BASE_URL='http://118.253.178.8:48080'
# 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务 # 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务
VITE_UPLOAD_TYPE=server VITE_UPLOAD_TYPE=server

View File

@@ -4,8 +4,8 @@ NODE_ENV=development
VITE_DEV=true VITE_DEV=true
# 请求路径 # 请求路径
#VITE_BASE_URL='http://118.253.178.8:48080' VITE_BASE_URL='http://118.253.178.8:48080'
VITE_BASE_URL='http://localhost:48080' #VITE_BASE_URL='http://localhost:48080'
# 文件上传类型server - 后端上传, client - 前端直连上传,仅支持 S3 服务 # 文件上传类型server - 后端上传, client - 前端直连上传,仅支持 S3 服务
VITE_UPLOAD_TYPE=server VITE_UPLOAD_TYPE=server

View File

@@ -142,5 +142,6 @@
"package.json": "pnpm-lock.yaml,yarn.lock,LICENSE,README*,CHANGELOG*,CNAME,.gitattributes,.eslintrc-auto-import.json,.gitignore,prettier.config.js,stylelint.config.js,commitlint.config.js,.stylelintignore,.prettierignore,.gitpod.yml,.eslintrc.js,.eslintignore" "package.json": "pnpm-lock.yaml,yarn.lock,LICENSE,README*,CHANGELOG*,CNAME,.gitattributes,.eslintrc-auto-import.json,.gitignore,prettier.config.js,stylelint.config.js,commitlint.config.js,.stylelintignore,.prettierignore,.gitpod.yml,.eslintrc.js,.eslintignore"
}, },
"terminal.integrated.scrollback": 10000, "terminal.integrated.scrollback": 10000,
"nuxt.isNuxtApp": false "nuxt.isNuxtApp": false,
"java.compile.nullAnalysis.mode": "automatic"
} }

148
AIRBT.html Normal file
View File

@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 决策通</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
.ai-drawer { transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1); transform: translateX(110%); }
.ai-drawer.active { transform: translateX(0); }
.ai-drawer.fullscreen { width: 85vw !important; height: 80vh !important; right: 7.5vw !important; top: 10vh !important; border-radius: 1.5rem !important; transform: translateX(0); }
.typing::after { content: '|'; animation: blink 1s infinite; margin-left: 2px; color: #3B82F6; }
@keyframes blink { 50% { opacity: 0; } }
.custom-scroll::-webkit-scrollbar { width: 5px; }
.custom-scroll::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
#overlay { display: none; position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); backdrop-filter: blur(4px); z-index: 55; }
#overlay.active { display: block; }
#fileInput, #imageInput { display: none; }
</style>
</head>
<body class="bg-[#F1F5F9]">
<div id="overlay" onclick="closeAI()"></div>
<div class="fixed bottom-[85px] right-6 z-50" id="aiFab">
<button onclick="openAI()" class="flex items-center gap-2 bg-white border border-blue-100 px-5 py-3 rounded-full shadow-2xl hover:scale-105 transition-all group">
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white shadow-lg shadow-blue-200">
<i class="fas fa-robot text-sm"></i>
</div>
<span class="text-blue-600 font-bold text-sm tracking-wide">AI 决策助手</span>
</button>
</div>
<div id="aiPanel" class="fixed top-0 right-0 h-full w-[420px] bg-white shadow-2xl z-[60] ai-drawer flex flex-col overflow-hidden border-l border-slate-100">
<div class="px-6 py-4 border-b flex items-center justify-between bg-white shrink-0">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center text-white shadow-md shadow-blue-100">
<i class="fas fa-robot text-lg"></i>
</div>
<div>
<h3 class="font-bold text-slate-800 text-sm">AI 决策助手</h3>
</div>
</div>
<div class="flex items-center gap-1">
<button onclick="toggleFullscreen()" class="w-9 h-9 rounded-lg hover:bg-slate-100 flex items-center justify-center text-slate-500">
<i class="fas fa-expand-alt" id="expandIcon"></i>
</button>
<button onclick="closeAI()" class="w-9 h-9 rounded-lg hover:bg-red-50 flex items-center justify-center text-slate-400 hover:text-red-500">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div id="chatBox" class="flex-1 overflow-y-auto p-6 space-y-6 bg-[#FBFCFE] custom-scroll">
<div class="flex gap-4 items-start">
<div class="w-8 h-8 bg-blue-50 text-blue-600 rounded-lg flex items-center justify-center shrink-0 mt-1 border border-blue-100">
<i class="fas fa-robot text-xs"></i>
</div>
<div class="bg-white border border-slate-200 p-4 rounded-2xl rounded-tl-none shadow-sm text-sm text-slate-700">
您好!请发送数据或输入指令。
</div>
</div>
</div>
<div class="p-4 bg-white border-t border-slate-100 shrink-0">
<div class="max-w-4xl mx-auto flex flex-col">
<div class="flex gap-1 mb-2">
<input type="file" id="fileInput" accept=".xls,.xlsx,.csv" onchange="handleFileUpload(event, 'excel')">
<input type="file" id="imageInput" accept="image/*" onchange="handleFileUpload(event, 'image')">
<button onclick="triggerInput('image')" class="w-8 h-8 flex items-center justify-center text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-all">
<i class="fas fa-image"></i>
</button>
<button onclick="triggerInput('excel')" class="w-8 h-8 flex items-center justify-center text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-md transition-all">
<i class="fas fa-file-excel"></i>
</button>
</div>
<div class="flex items-center gap-3 bg-slate-100 rounded-2xl px-5 py-1.5 border border-transparent focus-within:bg-white focus-within:border-blue-500 transition-all">
<input id="chatInput" type="text" placeholder="输入指令..."
class="flex-1 bg-transparent py-2.5 text-sm outline-none text-slate-700 font-medium"
onkeypress="if(event.key==='Enter') sendMsg()">
<button onclick="sendMsg()" class="w-9 h-9 bg-blue-600 text-white rounded-xl flex items-center justify-center hover:bg-blue-700 shadow-lg shadow-blue-100 active:scale-95 transition-all">
<i class="fas fa-paper-plane text-xs"></i>
</button>
</div>
</div>
</div>
</div>
<script>
const panel = document.getElementById('aiPanel');
const chatBox = document.getElementById('chatBox');
const input = document.getElementById('chatInput');
const overlay = document.getElementById('overlay');
const fab = document.getElementById('aiFab');
const icon = document.getElementById('expandIcon');
function openAI() { panel.classList.add('active'); overlay.classList.add('active'); fab.style.opacity = '0'; }
function closeAI() { panel.classList.remove('active'); panel.classList.remove('fullscreen'); overlay.classList.remove('active'); fab.style.opacity = '1'; icon.className = 'fas fa-expand-alt'; }
function toggleFullscreen() { panel.classList.toggle('fullscreen'); icon.className = panel.classList.contains('fullscreen') ? 'fas fa-compress-alt' : 'fas fa-expand-alt'; }
function triggerInput(type) { document.getElementById(type === 'image' ? 'imageInput' : 'fileInput').click(); }
function handleFileUpload(event, type) {
const file = event.target.files[0];
if (!file) return;
const contentHtml = type === 'image' ? `<img src="${URL.createObjectURL(file)}" class="rounded-lg max-w-full">` : `<div class="flex items-center gap-3 bg-white/10 p-3 rounded-xl border border-white/20"><div class="w-8 h-8 bg-emerald-500 rounded flex items-center justify-center text-white shrink-0"><i class="fas fa-file-excel text-xs"></i></div><p class="text-xs font-bold truncate">${file.name}</p></div>`;
const msgHtml = `<div class="flex justify-end"><div class="bg-blue-600 text-white p-3 rounded-2xl rounded-tr-none text-sm max-w-[85%] shadow-lg">${contentHtml}</div></div>`;
chatBox.insertAdjacentHTML('beforeend', msgHtml);
chatBox.scrollTop = chatBox.scrollHeight;
setTimeout(() => {
const aiId = 'ai-' + Date.now();
const aiHtml = `<div class="flex gap-4 items-start"><div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white shrink-0 mt-1 shadow-sm"><i class="fas fa-robot text-xs"></i></div><div class="bg-white border border-slate-200 p-4 rounded-2xl rounded-tl-none shadow-sm text-sm text-slate-700 flex-1"><p id="${aiId}" class="typing leading-relaxed"></p></div></div>`;
chatBox.insertAdjacentHTML('beforeend', aiHtml);
chatBox.scrollTop = chatBox.scrollHeight;
typeWriter(`正在分析您的文件数据...`, aiId);
}, 800);
}
function sendMsg() {
const text = input.value.trim();
if(!text) return;
const userHtml = `<div class="flex justify-end"><div class="bg-blue-600 text-white p-4 rounded-2xl rounded-tr-none text-sm max-w-[80%] shadow-lg shadow-blue-100/50">${text}</div></div>`;
chatBox.insertAdjacentHTML('beforeend', userHtml);
input.value = '';
chatBox.scrollTop = chatBox.scrollHeight;
setTimeout(() => {
const aiId = 'ai-' + Date.now();
const aiHtml = `<div class="flex gap-4 items-start"><div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white shrink-0 mt-1 border border-blue-100"><i class="fas fa-robot text-xs"></i></div><div class="bg-white border border-slate-200 p-4 rounded-2xl rounded-tl-none shadow-sm text-sm text-slate-700 flex-1"><p id="${aiId}" class="typing leading-relaxed"></p></div></div>`;
chatBox.insertAdjacentHTML('beforeend', aiHtml);
chatBox.scrollTop = chatBox.scrollHeight;
typeWriter(`正在处理您的指令...`, aiId);
}, 600);
}
function typeWriter(text, elementId) {
let i = 0;
const el = document.getElementById(elementId);
function type() { if (i < text.length) { el.innerHTML += text.charAt(i); i++; chatBox.scrollTop = chatBox.scrollHeight; setTimeout(type, 30); } else { el.classList.remove('typing'); } }
type();
}
</script>
</body>
</html>

View File

@@ -7,11 +7,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
name="keywords" name="keywords"
content="衣朵云管理系统 基于 vue3 + CompositionAPI + typescript + vite3 + element plus 的后台开源免费管理系统!" content="Ai 决策通 基于 vue3 + CompositionAPI + typescript + vite3 + element plus 的后台开源免费管理系统!"
/> />
<meta <meta
name="description" name="description"
content="衣朵云管理系统 基于 vue3 + CompositionAPI + typescript + vite3 + element plus 的后台开源免费管理系统!" content="Ai 决策通 基于 vue3 + CompositionAPI + typescript + vite3 + element plus 的后台开源免费管理系统!"
/> />
<title>%VITE_APP_TITLE%</title> <title>%VITE_APP_TITLE%</title>
</head> </head>

50
package-lock.json generated
View File

@@ -35,6 +35,7 @@
"element-plus": "2.11.1", "element-plus": "2.11.1",
"fast-xml-parser": "^4.3.2", "fast-xml-parser": "^4.3.2",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"html2canvas": "^1.4.1",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",
"jsoneditor": "^10.1.3", "jsoneditor": "^10.1.3",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
@@ -7813,6 +7814,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
@@ -8987,6 +8997,15 @@
"node": ">=12 || >=16" "node": ">=12 || >=16"
} }
}, },
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/css-select": { "node_modules/css-select": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmmirror.com/css-select/-/css-select-5.1.0.tgz", "resolved": "https://registry.npmmirror.com/css-select/-/css-select-5.1.0.tgz",
@@ -12286,6 +12305,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/htmlparser2": { "node_modules/htmlparser2": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-8.0.2.tgz", "resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-8.0.2.tgz",
@@ -18834,6 +18866,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/text-table": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz",
@@ -19806,6 +19847,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz", "resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",

View File

@@ -51,6 +51,7 @@
"element-plus": "2.11.1", "element-plus": "2.11.1",
"fast-xml-parser": "^4.3.2", "fast-xml-parser": "^4.3.2",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"html2canvas": "^1.4.1",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",
"jsoneditor": "^10.1.3", "jsoneditor": "^10.1.3",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",

39
pnpm-lock.yaml generated
View File

@@ -86,6 +86,9 @@ importers:
highlight.js: highlight.js:
specifier: ^11.9.0 specifier: ^11.9.0
version: 11.10.0 version: 11.10.0
html2canvas:
specifier: ^1.4.1
version: 1.4.1
jsencrypt: jsencrypt:
specifier: ^3.3.2 specifier: ^3.3.2
version: 3.3.2 version: 3.3.2
@@ -2531,6 +2534,10 @@ packages:
balanced-match@2.0.0: balanced-match@2.0.0:
resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
base@0.11.2: base@0.11.2:
resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==} resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2842,6 +2849,9 @@ packages:
resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==}
engines: {node: '>=12 || >=16'} engines: {node: '>=12 || >=16'}
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
css-select@4.3.0: css-select@4.3.0:
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
@@ -3798,6 +3808,10 @@ packages:
html-void-elements@3.0.0: html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
htmlparser2@3.10.1: htmlparser2@3.10.1:
resolution: {integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==} resolution: {integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==}
@@ -5480,6 +5494,9 @@ packages:
resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==} resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==}
engines: {node: '>=8'} engines: {node: '>=8'}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
text-table@0.2.0: text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@@ -5706,6 +5723,9 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
uuid@10.0.0: uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true hasBin: true
@@ -8603,6 +8623,8 @@ snapshots:
balanced-match@2.0.0: {} balanced-match@2.0.0: {}
base64-arraybuffer@1.0.2: {}
base@0.11.2: base@0.11.2:
dependencies: dependencies:
cache-base: 1.0.1 cache-base: 1.0.1
@@ -8962,6 +8984,10 @@ snapshots:
css-functions-list@3.2.3: {} css-functions-list@3.2.3: {}
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
css-select@4.3.0: css-select@4.3.0:
dependencies: dependencies:
boolbase: 1.0.0 boolbase: 1.0.0
@@ -10129,6 +10155,11 @@ snapshots:
html-void-elements@3.0.0: {} html-void-elements@3.0.0: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
htmlparser2@3.10.1: htmlparser2@3.10.1:
dependencies: dependencies:
domelementtype: 1.3.1 domelementtype: 1.3.1
@@ -11880,6 +11911,10 @@ snapshots:
text-extensions@2.4.0: {} text-extensions@2.4.0: {}
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
text-table@0.2.0: {} text-table@0.2.0: {}
through@2.3.8: {} through@2.3.8: {}
@@ -12163,6 +12198,10 @@ snapshots:
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
uuid@10.0.0: {} uuid@10.0.0: {}
vanilla-picker@2.12.3: vanilla-picker@2.12.3:

View File

@@ -1,9 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { isDark } from '@/utils/is'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { CACHE_KEY, useCache } from '@/hooks/web/useCache' import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
import routerSearch from '@/components/RouterSearch/index.vue' import routerSearch from '@/components/RouterSearch/index.vue'
import AiAssistantProvider from '@/components/AiAssistant/AiAssistantProvider.vue'
defineOptions({ name: 'APP' }) defineOptions({ name: 'APP' })
@@ -14,11 +14,11 @@ const currentSize = computed(() => appStore.getCurrentSize)
const greyMode = computed(() => appStore.getGreyMode) const greyMode = computed(() => appStore.getGreyMode)
const { wsCache } = useCache() const { wsCache } = useCache()
// 根据浏览器当前主题设置系统主题色 // 默认主题为白色(浅色),无缓存时使用浅色;有缓存则沿用用户选择
const setDefaultTheme = () => { const setDefaultTheme = () => {
let isDarkTheme = wsCache.get(CACHE_KEY.IS_DARK) let isDarkTheme = wsCache.get(CACHE_KEY.IS_DARK)
if (isDarkTheme === null) { if (isDarkTheme === null) {
isDarkTheme = isDark() isDarkTheme = false
} }
appStore.setIsDark(isDarkTheme) appStore.setIsDark(isDarkTheme)
} }
@@ -26,8 +26,10 @@ setDefaultTheme()
</script> </script>
<template> <template>
<ConfigGlobal :size="currentSize"> <ConfigGlobal :size="currentSize">
<AiAssistantProvider>
<RouterView :class="greyMode ? `${prefixCls}-grey-mode` : ''" /> <RouterView :class="greyMode ? `${prefixCls}-grey-mode` : ''" />
<routerSearch /> <routerSearch />
</AiAssistantProvider>
</ConfigGlobal> </ConfigGlobal>
</template> </template>
<style lang="scss"> <style lang="scss">

View File

@@ -14,6 +14,7 @@ export interface TenantVO {
accountCount: number accountCount: number
websites: string[] websites: string[]
createTime: Date createTime: Date
tenantPrompt?: string
} }
export interface TenantPageReqVO extends PageParam { export interface TenantPageReqVO extends PageParam {

View File

@@ -1,8 +1,8 @@
import request from '@/config/axios' import request from '@/config/axios'
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
/** 自定义标签类型product-产品, store-店铺, supplier-供货商 */ /** 自定义标签类型product-产品, store-店铺, supplier-供货商, member-会员 */
export type CustomTagType = 'product' | 'store' | 'supplier' export type CustomTagType = 'product' | 'store' | 'supplier' | 'member'
/** 自定义标签信息 */ /** 自定义标签信息 */
export interface CustomTag { export interface CustomTag {

View File

@@ -0,0 +1,111 @@
import request from '@/config/axios'
/** 标签同步配置 */
export interface TagConfig {
id?: number
databaseId?: number
cronExpression?: string
/** 是否开启自动同步 */
autoSync?: boolean
lastSyncTime?: string
nextSyncTime?: string
/** 剔除大类(多选,逗号分隔) */
productCategoryIds?: string
/** 剔除仓库(多选,逗号分隔) */
syncWarehouseIds?: string
}
/** 标准标签 */
export interface Tag {
id?: number
name?: string
type?: string
expression?: string
color?: string
sqlScript?: string
/** 已拼接参数的SQL执行时直接使用不展示 */
sqlScriptResolved?: string
useParams?: boolean
params?: string
/** 参数值 JSON如 {"参数1":"28","参数2":"90"} */
paramValues?: string
}
/** 标签同步历史 */
export interface TagSyncHistory {
id?: number
tagConfigId?: number
syncType?: string
syncTime?: string
syncStatus?: string
recordCount?: number
errorMessage?: string
}
/** 统一保存请求(配置+标签) */
export interface TagConfigSaveAllReq {
config?: TagConfig | null
tags?: Tag[]
}
export const TagConfigApi = {
getTagConfigPage: (params: any) =>
request.get({ url: '/ydoyun/tag-config/page', params }),
getTagConfig: (id: number) =>
request.get({ url: '/ydoyun/tag-config/get?id=' + id }),
getTagConfigByDatabaseId: (databaseId: number) =>
request.get({ url: '/ydoyun/tag-config/get-by-database?databaseId=' + databaseId }),
getTagConfigByTenant: () =>
request.get({ url: '/ydoyun/tag-config/get-by-tenant' }),
saveAll: (data: TagConfigSaveAllReq) =>
request.post({ url: '/ydoyun/tag-config/save', data }),
createTagConfig: (data: TagConfig) =>
request.post({ url: '/ydoyun/tag-config/create', data }),
updateTagConfig: (data: TagConfig) =>
request.put({ url: '/ydoyun/tag-config/update', data }),
deleteTagConfig: (id: number) =>
request.delete({ url: '/ydoyun/tag-config/delete?id=' + id }),
manualSync: (tagConfigId: number) =>
request.post({ url: '/ydoyun/tag-config/manual-sync?tagConfigId=' + tagConfigId })
}
export const TagApi = {
getTagPage: (params: any) =>
request.get({ url: '/ydoyun/tag/page', params }),
getTag: (id: number) =>
request.get({ url: '/ydoyun/tag/get?id=' + id }),
getTagListByType: (type?: string) =>
request.get({ url: '/ydoyun/tag/list-by-type', params: { type } }),
createTag: (data: Tag) =>
request.post({ url: '/ydoyun/tag/create', data }),
updateTag: (data: Tag) =>
request.put({ url: '/ydoyun/tag/update', data }),
deleteTag: (id: number) =>
request.delete({ url: '/ydoyun/tag/delete?id=' + id }),
}
export const TagSyncHistoryApi = {
getTagSyncHistoryPage: (params: any) =>
request.get({ url: '/ydoyun/tag-sync-history/page', params }),
getTagSyncHistory: (id: number) =>
request.get({ url: '/ydoyun/tag-sync-history/get?id=' + id }),
}
/** 标签同步详情 */
export interface TagSyncDetail {
id?: number
syncHistoryId?: number
tagId?: number
tagName?: string
tagType?: string
sqlExecuted?: string
execStatus?: string
recordCount?: number
errorMessage?: string
execOrder?: number
}
export const TagSyncDetailApi = {
getDetailListBySyncHistoryId: (syncHistoryId: number) =>
request.get({ url: '/ydoyun/tag-sync-detail/list-by-sync-history-id?syncHistoryId=' + syncHistoryId }),
}

View File

@@ -13,5 +13,7 @@ const prefixCls = getPrefixCls('backtop')
<ElBacktop <ElBacktop
:class="`${prefixCls}-backtop`" :class="`${prefixCls}-backtop`"
:target="`.${variables.namespace}-layout-content-scrollbar .${variables.elNamespace}-scrollbar__wrap`" :target="`.${variables.namespace}-layout-content-scrollbar .${variables.elNamespace}-scrollbar__wrap`"
:right="24"
:bottom="90"
/> />
</template> </template>

View File

@@ -59,6 +59,10 @@ service.interceptors.request.use(
// 设置超时时间为 24 小时86400000ms确保无论多久都等待响应 // 设置超时时间为 24 小时86400000ms确保无论多久都等待响应
config.timeout = 24 * 60 * 60 * 1000 // 24小时 = 86400000毫秒 config.timeout = 24 * 60 * 60 * 1000 // 24小时 = 86400000毫秒
} }
// AI 图片对话流式接口:不超时
if (config.url && config.url.includes('/ydoyun/ai-chat/stream')) {
config.timeout = 0
}
// 是否需要设置 token // 是否需要设置 token
let isToken = (config!.headers || {}).isToken === false let isToken = (config!.headers || {}).isToken === false

View File

@@ -35,6 +35,7 @@ provide('reload', reload)
<template> <template>
<section <section
id="app-main-content"
:class="[ :class="[
'p-[var(--app-content-padding)] w-full bg-[var(--app-content-bg-color)] dark:bg-[var(--el-bg-color)]', 'p-[var(--app-content-padding)] w-full bg-[var(--app-content-bg-color)] dark:bg-[var(--el-bg-color)]',
{ {

View File

@@ -66,10 +66,13 @@ watch(
]" ]"
to="/" to="/"
> >
<img <svg
class="h-[calc(var(--logo-height)-10px)] w-[calc(var(--logo-height)-10px)]" class="logo-svg h-[calc(var(--logo-height)-10px)] w-[calc(var(--logo-height)-10px)]"
src="@/assets/imgs/logo.png" viewBox="0 0 100 100"
/> >
<path d="M50 2L2 25.5v49L50 98l48-23.5v-49L50 2zm35.2 65.7L50 88.3 14.8 67.7V32.3L50 11.7l35.2 20.6v35.4z" />
<path d="M38.1 48.9h23.8L50 28.3 38.1 48.9zM50 71.7l11.9-20.6H38.1L50 71.7z" />
</svg>
<div <div
v-if="show" v-if="show"
:class="[ :class="[
@@ -86,3 +89,10 @@ watch(
</router-link> </router-link>
</div> </div>
</template> </template>
<style scoped>
.logo-svg {
fill: var(--logo-title-text-color);
flex-shrink: 0;
}
</style>

View File

@@ -114,7 +114,7 @@ export default {
small: '小' small: '小'
}, },
login: { login: {
welcome: '衣朵云管理系统', welcome: 'Ai 决策通',
message: '开箱即用的管理系统', message: '开箱即用的管理系统',
tenantname: '租户名称', tenantname: '租户名称',
username: '用户名', username: '用户名',

View File

@@ -4,6 +4,7 @@ import {
BarChart, BarChart,
FunnelChart, FunnelChart,
GaugeChart, GaugeChart,
HeatmapChart,
LineChart, LineChart,
MapChart, MapChart,
PictorialBarChart, PictorialBarChart,
@@ -13,6 +14,7 @@ import {
import { import {
AriaComponent, AriaComponent,
CalendarComponent,
DataZoomComponent, DataZoomComponent,
GridComponent, GridComponent,
LegendComponent, LegendComponent,
@@ -37,6 +39,7 @@ echarts.use([
AriaComponent, AriaComponent,
ParallelComponent, ParallelComponent,
VisualMapComponent, VisualMapComponent,
CalendarComponent,
BarChart, BarChart,
LineChart, LineChart,
PieChart, PieChart,
@@ -45,7 +48,8 @@ echarts.use([
PictorialBarChart, PictorialBarChart,
RadarChart, RadarChart,
GaugeChart, GaugeChart,
FunnelChart FunnelChart,
HeatmapChart
]) ])
export default echarts export default echarts

View File

@@ -8,7 +8,10 @@ const router = createRouter({
history: createWebHistory(import.meta.env.VITE_BASE_PATH), // createWebHashHistory URL带#createWebHistory URL不带# history: createWebHistory(import.meta.env.VITE_BASE_PATH), // createWebHashHistory URL带#createWebHistory URL不带#
strict: true, strict: true,
routes: remainingRouter as RouteRecordRaw[], routes: remainingRouter as RouteRecordRaw[],
scrollBehavior: () => ({ left: 0, top: 0 }) scrollBehavior: () =>
new Promise((resolve) => {
setTimeout(() => resolve({ left: 0, top: 0 }), 0)
})
}) })
export const resetRouter = (): void => { export const resetRouter = (): void => {

View File

@@ -599,6 +599,16 @@ const remainingRouter: AppRouteRecordRaw[] = [
hidden: true hidden: true
}, },
children: [ children: [
{
path: 'image-chat',
component: () => import('@/views/ydoyun/aichat/index.vue'),
name: 'AiImageChat',
meta: {
title: 'AI 图片对话',
icon: 'ep:chat-dot-round',
noCache: false
}
},
{ {
path: 'image/square', path: 'image/square',
component: () => import('@/views/ai/image/square/index.vue'), component: () => import('@/views/ai/image/square/index.vue'),
@@ -782,7 +792,6 @@ const remainingRouter: AppRouteRecordRaw[] = [
name: 'SupplierPerformance', name: 'SupplierPerformance',
meta: { meta: {
title: '供应商详情', title: '供应商详情',
noCache: true,
hidden: true, hidden: true,
canTo: true canTo: true
}, },
@@ -831,6 +840,16 @@ const remainingRouter: AppRouteRecordRaw[] = [
hidden: true hidden: true
}, },
children: [ children: [
{
path: 'custom-tag',
name: 'CustomTag',
meta: {
title: '定制标签',
noCache: true,
canTo: true
},
component: () => import(/* webpackChunkName: "custom-tag" */ '@/views/ydoyun/customtag/index.vue')
},
{ {
path: 'product-custom-tag', path: 'product-custom-tag',
name: 'ProductCustomTag', name: 'ProductCustomTag',

View File

@@ -1,111 +1,397 @@
<template> <template>
<div <div class="login-page">
:class="prefixCls" <!-- 动态背景 -->
class="relative h-[100%] lt-md:px-10px lt-sm:px-10px lt-xl:px-10px lt-xl:px-10px" <div class="bg-container">
> <div class="blob blob-1"></div>
<div class="relative mx-auto h-full flex"> <div class="blob blob-2"></div>
<div <div class="grid-base"></div>
:class="`${prefixCls}__left flex-1 bg-gray-500 bg-opacity-20 relative p-30px lt-xl:hidden overflow-x-hidden overflow-y-auto`" <div class="flow-line h-line" style="top: 20%; left: -150px; animation-delay: 0s"></div>
> <div class="flow-line h-line" style="top: 60%; left: -150px; animation-delay: 3s"></div>
<!-- 左上角的 logo + 系统标题 --> <div class="flow-line v-line" style="left: 30%; top: -150px; animation-delay: 1.5s"></div>
<div class="relative flex items-center text-white">
<img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" />
<span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>
</div> </div>
<!-- 左边的背景图 + 欢迎语 -->
<div class="h-[calc(100%-60px)] flex items-center justify-center"> <!-- 右上角主题语言 -->
<TransitionGroup <div class="login-header-actions">
appear
enter-active-class="animate__animated animate__bounceInLeft"
tag="div"
>
<img key="1" alt="" class="w-350px" src="@/assets/svgs/001.png" />
<div key="2" class="text-3xl text-white">{{ t('login.welcome') }}</div>
<div key="3" class="mt-5 text-14px font-normal text-white">
{{ t('login.message') }}
</div>
</TransitionGroup>
</div>
</div>
<div
class="relative flex-1 p-30px dark:bg-[var(--login-bg-color)] lt-sm:p-10px overflow-x-hidden overflow-y-auto"
>
<!-- 右上角的主题语言选择 -->
<div
class="flex items-center justify-between at-2xl:justify-end at-xl:justify-end"
style="color: var(--el-text-color-primary);"
>
<div class="flex items-center at-2xl:hidden at-xl:hidden">
<img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" />
<span class="text-20px font-bold" >{{ underlineToHump(appStore.getTitle) }}</span>
</div>
<div class="flex items-center justify-end space-x-10px h-48px">
<ThemeSwitch /> <ThemeSwitch />
<LocaleDropdown /> <LocaleDropdown />
</div> </div>
<!-- 主内容区 -->
<div class="container">
<!-- 左侧面板 -->
<div class="left-panel">
<div class="brand-box">
<div class="logo-main">
<svg viewBox="0 0 100 100" class="logo-svg">
<path d="M50 2L2 25.5v49L50 98l48-23.5v-49L50 2zm35.2 65.7L50 88.3 14.8 67.7V32.3L50 11.7l35.2 20.6v35.4z" />
<path d="M38.1 48.9h23.8L50 28.3 38.1 48.9zM50 71.7l11.9-20.6H38.1L50 71.7z" />
</svg>
</div> </div>
<!-- 右边的登录界面 --> <div class="brand-name">Ai 决策通</div>
<Transition appear enter-active-class="animate__animated animate__bounceInRight">
<div
class="m-auto h-[calc(100%-60px)] w-[100%] flex items-center at-2xl:max-w-500px at-lg:max-w-500px at-md:max-w-500px at-xl:max-w-500px"
>
<!-- 账号登录 -->
<LoginForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
</div> </div>
</Transition> <h2 class="sub-title">服鞋行业AI决策管理平台</h2>
<p class="desc">
专业的服鞋供应链 AI 决策中心通过实时大数据分析与行业领先的智能算法助力用户实现高效配补货决策及数字化经营管理
</p>
<div class="features">
<div class="feature-card">
<div class="icon-box">
<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14h-2v-4h2v4zm4 0h-2V7h2v10zm-8 0H8v-7h2v7z"/></svg>
</div> </div>
AI智能配补货
</div>
<div class="feature-card">
<div class="icon-box">
<svg viewBox="0 0 24 24"><path d="M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 17.08l1.5 1.41z"/></svg>
</div>
AI报表平台
</div>
<div class="feature-card">
<div class="icon-box">
<svg viewBox="0 0 24 24"><path d="M22 2H2v20h20V2zM10 7h4v2h-4V7zm8 10H6v-2h12v2zm0-4H6v-2h12v2zm0-4h-2V7h2v2z"/></svg>
</div>
AI管理工具包
</div>
<div class="feature-card">
<div class="icon-box">
<svg viewBox="0 0 24 24"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58.55 0 1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41 0-.55-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"/></svg>
</div>
AI智能标签
</div>
</div>
</div>
<!-- 右侧登录面板 -->
<div class="right-panel">
<div class="login-card">
<LoginForm />
</div>
</div>
</div>
<div class="footer">
长沙决策通科技有限公司 &copy; 2026 版权所有
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup>
import { underlineToHump } from '@/utils'
import { useDesign } from '@/hooks/web/useDesign' <script lang="ts" setup>
import { useAppStore } from '@/store/modules/app'
import { ThemeSwitch } from '@/layout/components/ThemeSwitch' import { ThemeSwitch } from '@/layout/components/ThemeSwitch'
import { LocaleDropdown } from '@/layout/components/LocaleDropdown' import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
import { LoginForm } from './components'
import { LoginForm, MobileForm, QrCodeForm, RegisterForm, SSOLoginVue, ForgetPasswordForm } from './components'
defineOptions({ name: 'Login' }) defineOptions({ name: 'Login' })
const { t } = useI18n()
const appStore = useAppStore()
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('login')
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
$prefix-cls: #{$namespace}-login; .login-page {
--primary-blue: #004fb0;
--btn-grad: linear-gradient(180deg, #8cbaff 0%, #3e81e0 100%);
--glass: rgba(255, 255, 255, 0.35);
--grid-size: 70px;
.#{$prefix-cls} { position: relative;
overflow: auto; min-height: 100vh;
width: 100vw;
overflow-x: hidden;
overflow-y: auto;
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
&__left { .bg-container {
&::before { position: fixed;
inset: 0;
z-index: 1;
overflow: hidden;
pointer-events: none;
}
.grid-base {
position: absolute; position: absolute;
top: 0; inset: 0;
left: 0; background-image: linear-gradient(rgba(0, 79, 176, 0.08) 1.5px, transparent 1.5px),
z-index: -1; linear-gradient(90deg, rgba(0, 79, 176, 0.08) 1.5px, transparent 1.5px);
background-size: var(--grid-size) var(--grid-size);
}
.blob {
position: absolute;
filter: blur(80px);
opacity: 0.5;
border-radius: 50%;
z-index: 1;
}
.blob-1 {
width: 700px;
height: 700px;
background: #9ec9ff;
top: -15%;
left: -5%;
}
.blob-2 {
width: 600px;
height: 600px;
background: #7fb4ff;
bottom: -15%;
right: -5%;
}
.flow-line {
position: absolute;
background: linear-gradient(90deg, transparent, #4388e4, transparent);
z-index: 2;
border-radius: 2px;
opacity: 0.6;
}
.h-line {
height: 1.5px;
width: 150px;
animation: h-move 7s linear infinite;
}
.v-line {
width: 1.5px;
height: 150px;
background: linear-gradient(180deg, transparent, #4388e4, transparent);
animation: v-move 9s linear infinite;
}
@keyframes h-move {
0% {
transform: translateX(0);
opacity: 0;
}
10%,
90% {
opacity: 0.8;
}
100% {
transform: translateX(100vw);
opacity: 0;
}
}
@keyframes v-move {
0% {
transform: translateY(0);
opacity: 0;
}
10%,
90% {
opacity: 0.8;
}
100% {
transform: translateY(100vh);
opacity: 0;
}
}
.login-header-actions {
position: fixed;
top: 20px;
right: 24px;
z-index: 100;
display: flex;
align-items: center;
gap: 12px;
}
.container {
position: relative;
z-index: 10;
width: 100%; width: 100%;
height: 100%; max-width: 1300px;
background-image: url('@/assets/svgs/login-bg.svg'); margin: 0 auto;
background-position: center; padding: 40px;
background-repeat: no-repeat; display: flex;
content: ''; justify-content: space-between;
align-items: center;
gap: 40px;
} }
.left-panel {
flex: 1.3;
padding-top: 10px;
}
.brand-box {
display: flex;
align-items: center;
margin-bottom: 25px;
}
.logo-main {
width: 64px;
height: 64px;
background: linear-gradient(135deg, #0052d4, #4364f7);
border-radius: 14px;
margin-right: 20px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 20px rgba(0, 82, 212, 0.2);
}
.logo-svg {
width: 40px;
height: 40px;
fill: white;
}
.brand-name {
font-size: 42px;
font-weight: 900;
color: var(--primary-blue);
letter-spacing: -1px;
}
.sub-title {
font-size: 24px;
font-weight: 600;
color: #222;
margin-bottom: 25px;
padding-left: 5px;
}
.desc {
color: #5c6e82;
font-size: 15px;
max-width: 500px;
line-height: 1.8;
margin-bottom: 50px;
padding-left: 5px;
}
.features {
display: grid;
grid-template-columns: repeat(2, 260px);
gap: 20px;
padding-left: 5px;
}
.feature-card {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(10px);
border: 1px solid white;
padding: 18px 22px;
border-radius: 15px;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
transition: 0.3s;
color: #333;
}
.feature-card:hover {
transform: translateY(-5px);
background: #fff;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05);
}
.icon-box {
width: 36px;
height: 36px;
background: #2a5fb2;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
}
.icon-box svg {
width: 20px;
height: 20px;
fill: white;
}
.right-panel {
flex: 0.7;
display: flex;
justify-content: flex-end;
}
.login-card {
width: 420px;
background: var(--glass);
backdrop-filter: blur(30px);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 40px;
padding: 50px 45px;
box-shadow: 0 40px 80px rgba(0, 79, 176, 0.12);
}
.footer {
position: fixed;
bottom: 30px;
left: 0;
right: 0;
text-align: center;
font-size: 13px;
color: #94a3b8;
z-index: 10;
}
@media (max-width: 1200px) {
.container {
flex-direction: column;
align-items: center;
}
.left-panel {
max-width: 600px;
}
.right-panel {
width: 100%;
justify-content: center;
}
}
@media (max-width: 768px) {
.brand-name {
font-size: 32px;
}
.sub-title {
font-size: 20px;
}
.features {
grid-template-columns: 1fr;
}
.login-card {
width: 100%;
max-width: 420px;
padding: 40px 30px;
} }
} }
</style> </style>
<!-- 暗色主题下字体颜色跟随反转 -->
<style lang="scss"> <style lang="scss">
.dark .login-form { .dark .login-page {
.el-divider__text { .brand-name {
background-color: var(--login-bg-color); color: var(--el-color-primary-light-3);
} }
.sub-title {
.el-card { color: var(--el-text-color-primary);
background-color: var(--login-bg-color); }
.desc {
color: var(--el-text-color-regular);
}
.feature-card {
color: var(--el-text-color-primary);
background: var(--el-fill-color-light);
border-color: var(--el-border-color);
&:hover {
background: var(--el-bg-color);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
}
.login-card {
background: var(--el-bg-color-overlay);
border-color: var(--el-border-color);
}
.footer {
color: var(--el-text-color-placeholder);
} }
} }
</style> </style>

View File

@@ -4,71 +4,68 @@
ref="formLogin" ref="formLogin"
:model="loginData.loginForm" :model="loginData.loginForm"
:rules="LoginRules" :rules="LoginRules"
class="login-form" class="login-form-styled"
label-position="top" label-position="top"
label-width="120px" label-width="0"
size="large" size="large"
> >
<el-row class="mx-[-10px]"> <div class="login-header">
<el-col :span="24" class="px-10px"> <div class="welcome-text">欢迎登录</div>
<el-form-item> </div>
<LoginFormTitle class="w-full" />
</el-form-item> <!-- 租户名 -->
</el-col> <div v-if="loginData.tenantEnable === 'true'" class="input-group">
<el-col :span="24" class="px-10px"> <el-form-item prop="tenantName">
<el-form-item v-if="loginData.tenantEnable === 'true'" prop="tenantName">
<el-input <el-input
v-model="loginData.loginForm.tenantName" v-model="loginData.loginForm.tenantName"
:placeholder="t('login.tenantNamePlaceholder')" :placeholder="t('login.tenantNamePlaceholder')"
:prefix-icon="iconHouse" class="styled-input"
link
type="primary"
/> />
</el-form-item> </el-form-item>
</el-col> </div>
<el-col :span="24" class="px-10px">
<!-- 用户名 -->
<div class="input-group">
<el-form-item prop="username"> <el-form-item prop="username">
<el-input <el-input
v-model="loginData.loginForm.username" v-model="loginData.loginForm.username"
:placeholder="t('login.usernamePlaceholder')" :placeholder="t('login.usernamePlaceholder')"
:prefix-icon="iconAvatar" class="styled-input"
/> />
</el-form-item> </el-form-item>
</el-col> </div>
<el-col :span="24" class="px-10px">
<!-- 密码 -->
<div class="input-group">
<el-form-item prop="password"> <el-form-item prop="password">
<el-input <el-input
v-model="loginData.loginForm.password" v-model="loginData.loginForm.password"
:placeholder="t('login.passwordPlaceholder')" :placeholder="t('login.passwordPlaceholder')"
:prefix-icon="iconLock" class="styled-input"
show-password show-password
type="password" type="password"
@keyup.enter="getCode()" @keyup.enter="getCode()"
/> />
</el-form-item> </el-form-item>
</el-col> </div>
<el-col :span="24" class="px-10px mt-[-20px] mb-[-20px]">
<el-form-item> <!-- 记住我 -->
<el-row justify="space-between" style="width: 100%"> <div class="links-row">
<el-col :span="6">
<el-checkbox v-model="loginData.loginForm.rememberMe"> <el-checkbox v-model="loginData.loginForm.rememberMe">
{{ t('login.remember') }} {{ t('login.remember') }}
</el-checkbox> </el-checkbox>
</el-col> </div>
</el-row>
</el-form-item> <!-- 登录按钮 -->
</el-col> <button
<el-col :span="24" class="px-10px"> type="button"
<el-form-item> class="login-btn"
<XButton :disabled="loginLoading"
:loading="loginLoading"
:title="t('login.login')"
class="w-full"
type="primary"
@click="getCode()" @click="getCode()"
/> >
</el-form-item> {{ loginLoading ? '登录中...' : t('login.login') }}
</el-col> </button>
<Verify <Verify
v-if="loginData.captchaEnable === 'true'" v-if="loginData.captchaEnable === 'true'"
ref="verify" ref="verify"
@@ -77,16 +74,14 @@
mode="pop" mode="pop"
@success="handleLogin" @success="handleLogin"
/> />
</el-row>
</el-form> </el-form>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ElLoading } from 'element-plus' import { ElLoading } from 'element-plus'
import LoginFormTitle from './LoginFormTitle.vue'
import type { RouteLocationNormalizedLoaded } from 'vue-router' import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { useIcon } from '@/hooks/web/useIcon' import { required } from '@/utils/formRules'
import * as authUtil from '@/utils/auth' import * as authUtil from '@/utils/auth'
import { usePermissionStore } from '@/store/modules/permission' import { usePermissionStore } from '@/store/modules/permission'
import * as LoginApi from '@/api/login' import * as LoginApi from '@/api/login'
@@ -96,28 +91,27 @@ defineOptions({ name: 'LoginForm' })
const { t } = useI18n() const { t } = useI18n()
const message = useMessage() const message = useMessage()
const iconHouse = useIcon({ icon: 'ep:house' })
const iconAvatar = useIcon({ icon: 'ep:avatar' })
const iconLock = useIcon({ icon: 'ep:lock' })
const formLogin = ref() const formLogin = ref()
const { validForm } = useFormValid(formLogin) const { validForm } = useFormValid(formLogin)
const { setLoginState, getLoginState } = useLoginState() const { getLoginState } = useLoginState()
const { currentRoute, push } = useRouter() const { currentRoute, push } = useRouter()
const permissionStore = usePermissionStore() const permissionStore = usePermissionStore()
const redirect = ref<string>('') const redirect = ref<string>('')
const loginLoading = ref(false) const loginLoading = ref(false)
const verify = ref() const verify = ref()
const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字 pictureWord 文字验证码 const captchaType = ref('blockPuzzle')
const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN) const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN)
const LoginRules = { const LoginRules: Record<string, any> = {
tenantName: [required],
username: [required], username: [required],
password: [required] password: [required]
} }
if (import.meta.env.VITE_APP_TENANT_ENABLE === 'true') {
LoginRules.tenantName = [required]
}
const loginData = reactive({ const loginData = reactive({
isShowPassword: false,
captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE, captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE,
tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE, tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE,
loginForm: { loginForm: {
@@ -125,49 +119,38 @@ const loginData = reactive({
username: import.meta.env.VITE_APP_DEFAULT_LOGIN_USERNAME || '', username: import.meta.env.VITE_APP_DEFAULT_LOGIN_USERNAME || '',
password: import.meta.env.VITE_APP_DEFAULT_LOGIN_PASSWORD || '', password: import.meta.env.VITE_APP_DEFAULT_LOGIN_PASSWORD || '',
captchaVerification: '', captchaVerification: '',
rememberMe: true // 默认记录我。如果不需要,可手动修改 rememberMe: true
} }
}) })
const socialList = [
{ icon: 'ant-design:wechat-filled', type: 30 },
{ icon: 'ant-design:dingtalk-circle-filled', type: 20 },
{ icon: 'ant-design:github-filled', type: 0 },
{ icon: 'ant-design:alipay-circle-filled', type: 0 }
]
// 获取验证码
const getCode = async () => { const getCode = async () => {
// 情况一,未开启:则直接登录
if (loginData.captchaEnable === 'false') { if (loginData.captchaEnable === 'false') {
await handleLogin({}) await handleLogin({})
} else { } else {
// 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行登录 verify.value?.show()
// 弹出验证码
verify.value.show()
} }
} }
// 获取租户 ID
const getTenantId = async () => { const getTenantId = async () => {
if (loginData.tenantEnable === 'true') { if (loginData.tenantEnable === 'true') {
const res = await LoginApi.getTenantIdByName(loginData.loginForm.tenantName) const res = await LoginApi.getTenantIdByName(loginData.loginForm.tenantName)
authUtil.setTenantId(res) authUtil.setTenantId(res)
} }
} }
// 记住我
const getLoginFormCache = () => { const getLoginFormCache = () => {
const loginForm = authUtil.getLoginForm() const loginForm = authUtil.getLoginForm()
if (loginForm) { if (loginForm) {
loginData.loginForm = { loginData.loginForm = {
...loginData.loginForm, ...loginData.loginForm,
username: loginForm.username ? loginForm.username : loginData.loginForm.username, username: loginForm.username ?? loginData.loginForm.username,
password: loginForm.password ? loginForm.password : loginData.loginForm.password, password: loginForm.password ?? loginData.loginForm.password,
rememberMe: loginForm.rememberMe, rememberMe: loginForm.rememberMe ?? true,
tenantName: loginForm.tenantName ? loginForm.tenantName : loginData.loginForm.tenantName tenantName: loginForm.tenantName ?? loginData.loginForm.tenantName
} }
} }
} }
// 根据域名,获得租户信息
const getTenantByWebsite = async () => { const getTenantByWebsite = async () => {
if (loginData.tenantEnable === 'true') { if (loginData.tenantEnable === 'true') {
const website = location.host const website = location.host
@@ -178,8 +161,9 @@ const getTenantByWebsite = async () => {
} }
} }
} }
const loading = ref() // ElLoading.service 返回的实例
// 登录 let loading: any = null
const handleLogin = async (params: any) => { const handleLogin = async (params: any) => {
loginLoading.value = true loginLoading.value = true
try { try {
@@ -188,19 +172,19 @@ const handleLogin = async (params: any) => {
if (!data) { if (!data) {
return return
} }
const loginDataLoginForm = { ...loginData.loginForm } const loginFormData = { ...loginData.loginForm }
loginDataLoginForm.captchaVerification = params.captchaVerification loginFormData.captchaVerification = params.captchaVerification
const res = await LoginApi.login(loginDataLoginForm) const res = await LoginApi.login(loginFormData)
if (!res) { if (!res) {
return return
} }
loading.value = ElLoading.service({ loading = ElLoading.service({
lock: true, lock: true,
text: '正在加载系统中...', text: '正在加载系统中...',
background: 'rgba(0, 0, 0, 0.7)' background: 'rgba(0, 0, 0, 0.7)'
}) })
if (loginDataLoginForm.rememberMe) { if (loginFormData.rememberMe) {
authUtil.setLoginForm(loginDataLoginForm) authUtil.setLoginForm(loginFormData)
} else { } else {
authUtil.removeLoginForm() authUtil.removeLoginForm()
} }
@@ -208,7 +192,6 @@ const handleLogin = async (params: any) => {
if (!redirect.value) { if (!redirect.value) {
redirect.value = '/' redirect.value = '/'
} }
// 判断是否为SSO登录
if (redirect.value.indexOf('sso') !== -1) { if (redirect.value.indexOf('sso') !== -1) {
window.location.href = window.location.href.replace('/login?redirect=', '') window.location.href = window.location.href.replace('/login?redirect=', '')
} else { } else {
@@ -216,54 +199,18 @@ const handleLogin = async (params: any) => {
} }
} finally { } finally {
loginLoading.value = false loginLoading.value = false
loading.value.close() loading?.close?.()
} }
} }
// 社交登录
const doSocialLogin = async (type: number) => {
if (type === 0) {
message.error('此方式未配置')
} else {
loginLoading.value = true
if (loginData.tenantEnable === 'true') {
// 尝试先通过 tenantName 获取租户
await getTenantId()
// 如果获取不到,则需要弹出提示,进行处理
if (!authUtil.getTenantId()) {
try {
const data = await message.prompt('请输入租户名称', t('common.reminder'))
if (data?.action !== 'confirm') throw 'cancel'
const res = await LoginApi.getTenantIdByName(data.value)
authUtil.setTenantId(res)
} catch (error) {
if (error === 'cancel') return
} finally {
loginLoading.value = false
}
}
}
// 计算 redirectUri
// 注意: type、redirect 需要先 encode 一次,否则钉钉回调会丢失。
// 配合 social-login.vue#getUrlValue() 使用
const redirectUri =
location.origin +
'/social-login?' +
encodeURIComponent(`type=${type}&redirect=${redirect.value || '/'}`)
// 进行跳转
window.location.href = await LoginApi.socialAuthRedirect(type, encodeURIComponent(redirectUri))
}
}
watch( watch(
() => currentRoute.value, () => currentRoute.value,
(route: RouteLocationNormalizedLoaded) => { (route: RouteLocationNormalizedLoaded) => {
redirect.value = route?.query?.redirect as string redirect.value = route?.query?.redirect as string
}, },
{ { immediate: true }
immediate: true
}
) )
onMounted(() => { onMounted(() => {
getLoginFormCache() getLoginFormCache()
getTenantByWebsite() getTenantByWebsite()
@@ -271,23 +218,119 @@ onMounted(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.anticon) { .login-form-styled {
&:hover { :deep(.el-form-item) {
color: var(--el-color-primary) !important; margin-bottom: 0;
}
:deep(.el-form-item__error) {
padding-top: 4px;
} }
} }
.login-code { .login-header {
float: right; margin-bottom: 40px;
width: 100%; }
height: 38px;
img { .welcome-text {
font-size: 26px;
font-weight: bold;
color: #222;
}
.input-group {
margin-bottom: 20px;
:deep(.styled-input) {
.el-input__wrapper {
width: 100%; width: 100%;
height: auto; padding: 18px 22px;
max-width: 100px; border-radius: 15px;
vertical-align: middle; border: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(255, 255, 255, 0.85);
font-size: 16px;
transition: 0.3s;
box-shadow: none;
}
.el-input__wrapper:hover {
border-color: rgba(0, 79, 176, 0.3);
}
.el-input__wrapper.is-focus {
border-color: #004fb0;
background: #fff;
}
.el-input__inner,
input {
font-size: 16px;
color: #333 !important;
-webkit-text-fill-color: #333 !important;
}
}
}
.links-row {
display: flex;
align-items: center;
font-size: 14px;
margin: 15px 0 40px;
:deep(.el-checkbox__label) {
color: #6e7c8d;
}
}
.login-btn {
width: 100%;
padding: 18px;
background: linear-gradient(180deg, #8cbaff 0%, #3e81e0 100%);
border: none;
border-radius: 35px;
color: white;
font-size: 20px;
font-weight: bold;
cursor: pointer; cursor: pointer;
box-shadow: 0 12px 25px rgba(62, 129, 224, 0.35);
transition: 0.3s;
}
.login-btn:hover:not(:disabled) {
transform: scale(1.02);
filter: brightness(1.1);
}
.login-btn:disabled {
opacity: 0.8;
cursor: not-allowed;
}
</style>
<!-- 暗色主题下登录表单字体颜色跟随反转 -->
<style lang="scss">
.dark .login-form-styled {
.welcome-text {
color: var(--el-text-color-primary);
}
.links-row :deep(.el-checkbox__label) {
color: var(--el-text-color-regular);
}
.input-group :deep(.el-input__wrapper) {
background: var(--el-fill-color);
border-color: var(--el-border-color);
}
/* 已输入文字颜色 - 确保暗色下清晰可见 */
.input-group :deep(.el-input__inner),
.input-group :deep(input) {
color: var(--el-text-color-primary) !important;
-webkit-text-fill-color: var(--el-text-color-primary) !important;
}
.input-group :deep(input::placeholder) {
color: var(--el-text-color-placeholder);
}
.input-group :deep(.el-input__wrapper.is-focus) {
background: var(--el-bg-color);
border-color: var(--el-color-primary);
}
.input-group :deep(.el-input__wrapper.is-focus .el-input__inner),
.input-group :deep(.el-input__wrapper.is-focus input) {
color: var(--el-text-color-primary) !important;
-webkit-text-fill-color: var(--el-text-color-primary) !important;
} }
} }
</style> </style>

View File

@@ -61,6 +61,9 @@
class="w-full" class="w-full"
/> />
</el-form-item> </el-form-item>
<el-form-item label="租户提示词" prop="tenantPrompt">
<el-input v-model="formData.tenantPrompt" type="textarea" :rows="3" placeholder="请输入租户提示词" />
</el-form-item>
<el-form-item label="租户状态" prop="status"> <el-form-item label="租户状态" prop="status">
<el-radio-group v-model="formData.status"> <el-radio-group v-model="formData.status">
<el-radio <el-radio
@@ -103,13 +106,15 @@ const formData = ref({
expireTime: undefined, expireTime: undefined,
websites: [], websites: [],
status: CommonStatusEnum.ENABLE, status: CommonStatusEnum.ENABLE,
tenantPrompt: '',
// 新增专属 // 新增专属
username: undefined, username: undefined,
password: undefined password: undefined
}) })
const formRules = reactive({ const formRules = reactive({
name: [{ required: true, message: '租户名不能为空', trigger: 'blur' }], name: [{ required: true, message: '租户名不能为空', trigger: 'blur' }],
packageId: [{ required: true, message: '租户套<EFBFBD><EFBFBD><EFBFBD>不能为空', trigger: 'blur' }], packageId: [{ required: true, message: '租户套不能为空', trigger: 'blur' }],
contactName: [{ required: true, message: '联系人不能为空', trigger: 'blur' }], contactName: [{ required: true, message: '联系人不能为空', trigger: 'blur' }],
status: [{ required: true, message: '租户状态不能为空', trigger: 'blur' }], status: [{ required: true, message: '租户状态不能为空', trigger: 'blur' }],
accountCount: [{ required: true, message: '账号额度不能为空', trigger: 'blur' }], accountCount: [{ required: true, message: '账号额度不能为空', trigger: 'blur' }],
@@ -178,6 +183,7 @@ const resetForm = () => {
expireTime: undefined, expireTime: undefined,
websites: [], websites: [],
status: CommonStatusEnum.ENABLE, status: CommonStatusEnum.ENABLE,
tenantPrompt: '',
username: undefined, username: undefined,
password: undefined password: undefined
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<Dialog :title="dialogTitle" v-model="dialogVisible" width="560px"> <Dialog :title="dialogTitle" v-model="dialogVisible" width="720px">
<el-form <el-form
ref="formRef" ref="formRef"
:model="formData" :model="formData"
@@ -7,9 +7,18 @@
label-width="110px" label-width="110px"
v-loading="formLoading" v-loading="formLoading"
> >
<el-form-item label="标签类别" prop="type">
<el-select v-model="formData.type" placeholder="请选择标签类别" class="!w-full" @change="loadInitialSqlByType">
<el-option label="产品 (product)" value="product" />
<el-option label="门店 (store)" value="store" />
<el-option label="供货商 (supplier)" value="supplier" />
<el-option label="会员 (member)" value="member" />
</el-select>
<div class="form-tip">选择与标准标签一致的类别名称不能与同类型标准标签重复</div>
</el-form-item>
<el-form-item label="标签名称" prop="name"> <el-form-item label="标签名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入标签名称,如:销售区域" maxlength="50" show-word-limit /> <el-input v-model="formData.name" placeholder="请输入标签名称,如:销售区域" maxlength="50" show-word-limit @blur="validateNameWithStandardTags" />
<div class="form-tip">用于报表中展示的标签名称</div> <div class="form-tip">用于报表中展示的标签名称不能与同类型标准标签重名</div>
</el-form-item> </el-form-item>
<el-form-item label="公式描述" prop="expression"> <el-form-item label="公式描述" prop="expression">
<el-input <el-input
@@ -45,13 +54,22 @@
<div class="form-tip">用于报表展示时的颜色标识可点击预设色或使用取色盘</div> <div class="form-tip">用于报表展示时的颜色标识可点击预设色或使用取色盘</div>
</el-form-item> </el-form-item>
<el-form-item label="SQL 脚本" prop="sqlScript"> <el-form-item label="SQL 脚本" prop="sqlScript">
<div class="sql-script-field">
<div class="param-ref-row">
<el-button size="small" type="success" plain @click="insertInsertTemplate">
插入 {{ insertTableName }} 模板
</el-button>
</div>
<el-input <el-input
ref="sqlScriptInputRef"
v-model="formData.sqlScript" v-model="formData.sqlScript"
type="textarea" type="textarea"
:rows="4" :rows="8"
placeholder="请输入 SQL 脚本,用于获取标签数据" placeholder="请输入标签同步时执行的 SQL 脚本,支持多行。可点击上方按钮插入 INSERT 模板"
class="sql-script-area"
/> />
<div class="form-tip">执行 SQL 获取标签选项支持多列第一列作为显示值</div> <div class="form-tip"> SQL 将用于标签同步将结果写入对应标签表支持多列第一列作为 code第二列作为 name</div>
</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -62,6 +80,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { CustomTagApi, CustomTag } from '@/api/ydoyun/customtag' import { CustomTagApi, CustomTag } from '@/api/ydoyun/customtag'
import { TagApi } from '@/api/ydoyun/tagconfig'
defineOptions({ name: 'CustomTagForm' }) defineOptions({ name: 'CustomTagForm' })
@@ -75,17 +94,77 @@ const formType = ref('')
const formData = ref({ const formData = ref({
id: undefined, id: undefined,
type: undefined,
name: undefined, name: undefined,
expression: undefined, expression: undefined,
color: undefined, color: undefined,
sqlScript: undefined sqlScript: undefined
}) })
const validateNameWithStandardTags = async (_rule: any, value: string, callback: (e?: Error) => void) => {
if (!value?.trim()) return callback()
const type = formData.value.type
if (!type) return callback()
try {
const list = await TagApi.getTagListByType(type)
const res = list as any
const tags = res?.data ?? res ?? []
const exists = Array.isArray(tags) && tags.some((t: any) => (t.name || '').trim() === value.trim())
if (exists) {
callback(new Error('该名称与同类型标准标签重复,请更换'))
} else {
callback()
}
} catch {
callback()
}
}
const formRules = reactive({ const formRules = reactive({
name: [{ required: true, message: '标签名称不能为空', trigger: 'blur' }] type: [{ required: true, message: '请选择标签类别', trigger: 'change' }],
name: [
{ required: true, message: '标签名称不能为空', trigger: 'blur' },
{ validator: validateNameWithStandardTags, trigger: 'blur' }
]
}) })
const formRef = ref() const formRef = ref()
const sqlScriptInputRef = ref()
const INSERT_TABLE_MAP: Record<string, string> = {
product: 'ydoyun_tag_product',
supplier: 'ydoyun_tag_supplier',
store: 'ydoyun_tag_store',
member: 'ydoyun_tag_member'
}
const insertTableName = computed(() => {
const type = formData.value.type || 'product'
return INSERT_TABLE_MAP[type] || 'ydoyun_tag_product'
})
const getInsertTemplate = () => {
const table = insertTableName.value
return `INSERT INTO ${table} (code, name, color)
SELECT code, name, '\${颜色}' AS color FROM (
-- 在此编写您的查询逻辑,将 code、name 替换为实际字段color 使用 \${颜色} 参数引用,同步时代入标签颜色
SELECT '' AS code, '' AS name FROM (SELECT 1) _ LIMIT 0
) t
`
}
const insertInsertTemplate = () => {
const template = getInsertTemplate()
const oldScript = formData.value.sqlScript || ''
formData.value.sqlScript = template + (oldScript ? '\n' + oldScript : '')
}
/** 根据类型加载初始 SQL仅新增且当前为空时 */
const loadInitialSqlByType = () => {
if (formType.value !== 'create' || formData.value.sqlScript?.trim()) return
if (!formData.value.type) return
formData.value.sqlScript = getInsertTemplate()
}
/** 预设颜色选项 */ /** 预设颜色选项 */
const presetColors = [ const presetColors = [
@@ -114,6 +193,8 @@ const open = async (type: string, id?: number) => {
} finally { } finally {
formLoading.value = false formLoading.value = false
} }
} else {
loadInitialSqlByType()
} }
} }
@@ -124,7 +205,8 @@ const submitForm = async () => {
await formRef.value.validate() await formRef.value.validate()
formLoading.value = true formLoading.value = true
try { try {
const data = formData.value as unknown as CustomTag const base = { ...formData.value }
const data = (formType.value === 'create' ? { ...base, useParams: false } : base) as unknown as CustomTag
if (formType.value === 'create') { if (formType.value === 'create') {
await CustomTagApi.createCustomTag(data) await CustomTagApi.createCustomTag(data)
message.success(t('common.createSuccess')) message.success(t('common.createSuccess'))
@@ -143,6 +225,7 @@ const submitForm = async () => {
const resetForm = () => { const resetForm = () => {
formData.value = { formData.value = {
id: undefined, id: undefined,
type: undefined,
name: undefined, name: undefined,
expression: undefined, expression: undefined,
color: undefined, color: undefined,
@@ -200,4 +283,18 @@ const resetForm = () => {
flex: 1; flex: 1;
min-width: 120px; min-width: 120px;
} }
.sql-script-field { width: 100%; }
.param-ref-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.sql-script-area { width: 100%; }
.sql-script-area :deep(textarea) {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
}
</style> </style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<Dialog :title="dialogTitle" v-model="dialogVisible" width="560px"> <Dialog :title="dialogTitle" v-model="dialogVisible" width="720px">
<el-form <el-form
ref="formRef" ref="formRef"
:model="formData" :model="formData"
@@ -7,9 +7,18 @@
label-width="110px" label-width="110px"
v-loading="formLoading" v-loading="formLoading"
> >
<el-form-item label="标签类型" prop="type">
<el-select v-model="formData.type" placeholder="请选择标签类型" class="!w-full" :disabled="!!props.tagType">
<el-option label="产品 (product)" value="product" />
<el-option label="门店 (store)" value="store" />
<el-option label="供货商 (supplier)" value="supplier" />
<el-option label="会员 (member)" value="member" />
</el-select>
<div class="form-tip">选择与标准标签一致的类别名称不能与同类型标准标签重复</div>
</el-form-item>
<el-form-item label="标签名称" prop="name"> <el-form-item label="标签名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入标签名称" maxlength="50" show-word-limit /> <el-input v-model="formData.name" placeholder="请输入标签名称" maxlength="50" show-word-limit @blur="validateNameWithStandardTags" />
<div class="form-tip">用于报表中展示的标签名称</div> <div class="form-tip">用于报表中展示的标签名称不能与同类型标准标签重名</div>
</el-form-item> </el-form-item>
<el-form-item label="公式描述" prop="expression"> <el-form-item label="公式描述" prop="expression">
<el-input <el-input
@@ -52,30 +61,23 @@
</el-form-item> </el-form-item>
<el-form-item label="SQL 脚本" prop="sqlScript"> <el-form-item label="SQL 脚本" prop="sqlScript">
<div class="sql-script-field"> <div class="sql-script-field">
<div v-if="formData.useParams === 1" class="param-insert-bar"> <div class="param-ref-row">
<span class="param-label">插入参数变量</span> <el-button size="small" type="success" plain @click="insertInsertTemplate">
<el-select 插入 {{ insertTableName }} 模板
v-model="selectedParamIndex" </el-button>
:placeholder="paramList.length > 0 ? '选择参数后点击插入' : '请先填写上方参数列表'" <template v-if="formData.useParams === 1">
clearable <span class="param-ref-label">引用参数</span>
class="param-select" <el-button
:disabled="paramList.length === 0"
>
<el-option
v-for="(p, idx) in paramList" v-for="(p, idx) in paramList"
:key="idx" :key="idx"
:label="`参数${idx + 1}: ${p}`"
:value="idx"
/>
</el-select>
<el-button
type="primary"
size="small" size="small"
:disabled="paramList.length === 0 || selectedParamIndex === null" type="info"
@click="selectedParamIndex != null ? insertParamVar(selectedParamIndex) : null" plain
@click="insertParamRef(p, idx)"
> >
插入 引用{{ p }}
</el-button> </el-button>
</template>
</div> </div>
<el-input <el-input
ref="sqlScriptInputRef" ref="sqlScriptInputRef"
@@ -112,6 +114,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { CustomTagApi, CustomTag, CustomTagType } from '@/api/ydoyun/customtag' import { CustomTagApi, CustomTag, CustomTagType } from '@/api/ydoyun/customtag'
import { TagApi } from '@/api/ydoyun/tagconfig'
defineOptions({ name: 'CustomTagFormWithParams' }) defineOptions({ name: 'CustomTagFormWithParams' })
@@ -138,14 +141,85 @@ const formData = ref<Partial<CustomTag>>({
params: undefined params: undefined
}) })
const validateNameWithStandardTags = async (_rule: any, value: string, callback: (e?: Error) => void) => {
if (!value?.trim()) return callback()
const type = formData.value.type ?? props.tagType
if (!type) return callback()
try {
const list = await TagApi.getTagListByType(type)
const res = list as any
const tags = res?.data ?? res ?? []
const exists = Array.isArray(tags) && tags.some((t: any) => (t.name || '').trim() === value.trim())
if (exists) {
callback(new Error('该名称与同类型标准标签重复,请更换'))
} else {
callback()
}
} catch {
callback()
}
}
const formRules = reactive({ const formRules = reactive({
name: [{ required: true, message: '标签名称不能为空', trigger: 'blur' }] type: [{ required: true, message: '请选择标签类型', trigger: 'change' }],
name: [
{ required: true, message: '标签名称不能为空', trigger: 'blur' },
{ validator: validateNameWithStandardTags, trigger: 'blur' }
]
}) })
const formRef = ref() const formRef = ref()
const sqlScriptInputRef = ref() const sqlScriptInputRef = ref()
const sqlCursorPos = ref({ start: 0, end: 0 }) const sqlCursorPos = ref({ start: 0, end: 0 })
const selectedParamIndex = ref<number | null>(null)
const INSERT_TABLE_MAP: Record<string, string> = {
product: 'ydoyun_tag_product',
supplier: 'ydoyun_tag_supplier',
store: 'ydoyun_tag_store',
member: 'ydoyun_tag_member'
}
const insertTableName = computed(() => {
const type = formData.value.type ?? props.tagType ?? 'product'
return INSERT_TABLE_MAP[type] || 'ydoyun_tag_product'
})
const getInsertTemplate = () => {
const table = insertTableName.value
return `INSERT INTO ${table} (code, name, color)
SELECT code, name, '\${颜色}' AS color FROM (
-- 在此编写您的查询逻辑,将 code、name 替换为实际字段color 使用 \${颜色} 参数引用,同步时代入标签颜色
SELECT '' AS code, '' AS name FROM (SELECT 1) _ LIMIT 0
) t
`
}
const insertInsertTemplate = () => {
const template = getInsertTemplate()
const oldScript = formData.value.sqlScript || ''
formData.value.sqlScript = template + (oldScript ? '\n' + oldScript : '')
}
const insertAtCursor = (text: string) => {
const textarea = sqlScriptInputRef.value?.$el?.querySelector('textarea')
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const sql = formData.value.sqlScript || ''
formData.value.sqlScript = sql.slice(0, start) + text + sql.slice(end)
nextTick(() => {
textarea.focus()
const newPos = start + text.length
textarea.setSelectionRange(newPos, newPos)
})
} else {
formData.value.sqlScript = (formData.value.sqlScript || '') + text
}
}
const insertParamRef = (_p: string, idx: number) => {
insertAtCursor(`\${参数${idx + 1}}`)
}
const paramList = computed(() => { const paramList = computed(() => {
const p = formData.value.params const p = formData.value.params
@@ -160,24 +234,6 @@ const saveSqlCursor = () => {
} }
} }
const insertParamVar = (index: number) => {
const variable = `\${参数${index + 1}}`
const current = formData.value.sqlScript || ''
const { start, end } = sqlCursorPos.value
const before = current.slice(0, start)
const after = current.slice(end)
formData.value.sqlScript = before + variable + after
selectedParamIndex.value = null
nextTick(() => {
sqlCursorPos.value = { start: start + variable.length, end: start + variable.length }
const textarea = sqlScriptInputRef.value?.$el?.querySelector('textarea')
if (textarea) {
textarea.focus()
textarea.setSelectionRange(start + variable.length, start + variable.length)
}
})
}
const sqlSegments = computed(() => { const sqlSegments = computed(() => {
const sql = formData.value.sqlScript || '' const sql = formData.value.sqlScript || ''
const regex = /\$\{([^}]+)\}/g const regex = /\$\{([^}]+)\}/g
@@ -241,9 +297,19 @@ const open = async (type: string, id?: number) => {
} finally { } finally {
formLoading.value = false formLoading.value = false
} }
} else if (type === 'create') {
loadInitialSqlByType()
} }
} }
/** 根据类型加载初始 SQL仅新增且当前为空时 */
const loadInitialSqlByType = () => {
if (formType.value !== 'create' || formData.value.sqlScript?.trim()) return
const type = formData.value.type ?? props.tagType
if (!type) return
formData.value.sqlScript = getInsertTemplate()
}
defineExpose({ open }) defineExpose({ open })
const emit = defineEmits(['success']) const emit = defineEmits(['success'])
@@ -294,12 +360,10 @@ const resetForm = () => {
.color-picker-wrap { display: flex; align-items: center; gap: 12px; } .color-picker-wrap { display: flex; align-items: center; gap: 12px; }
.color-picker-wrap .color-input { flex: 1; min-width: 120px; } .color-picker-wrap .color-input { flex: 1; min-width: 120px; }
.sql-script-field { width: 100%; } .sql-script-field { width: 100%; }
.param-insert-bar { .param-ref-row {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 8px;
padding: 8px 12px; background: var(--el-fill-color-light); border-radius: 6px;
} }
.param-label { font-size: 13px; color: var(--el-text-color-secondary); margin-right: 4px; } .param-ref-label { font-size: 13px; color: var(--el-text-color-regular); margin-right: 4px; }
.param-select { width: 180px; }
.sql-preview { .sql-preview {
margin-top: 10px; padding: 10px 12px; background: var(--el-fill-color-lighter); margin-top: 10px; padding: 10px 12px; background: var(--el-fill-color-lighter);
border-radius: 6px; font-family: monospace; font-size: 13px; border-radius: 6px; font-family: monospace; font-size: 13px;

View File

@@ -8,6 +8,14 @@
:inline="true" :inline="true"
label-width="90px" label-width="90px"
> >
<el-form-item label="标签类型" prop="type">
<el-select v-model="queryParams.type" placeholder="全部" clearable class="!w-180px">
<el-option label="产品" value="product" />
<el-option label="门店" value="store" />
<el-option label="供货商" value="supplier" />
<el-option label="会员" value="member" />
</el-select>
</el-form-item>
<el-form-item label="标签名称" prop="name"> <el-form-item label="标签名称" prop="name">
<el-input <el-input
v-model="queryParams.name" v-model="queryParams.name"
@@ -81,16 +89,20 @@
@selection-change="handleRowCheckboxChange" @selection-change="handleRowCheckboxChange"
> >
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />
<el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true" /> <el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true">
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="标签颜色" align="center" prop="color" width="100">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small"> <el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small" class="!border-0">
{{ scope.row.color }} {{ scope.row.name || '-' }}
</el-tag> </el-tag>
<span v-else>-</span> <span v-else>{{ scope.row.name || '-' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="标签类型" align="center" prop="type" width="100">
<template #default="scope">
{{ typeLabelMap[scope.row.type] || scope.row.type || '-' }}
</template>
</el-table-column>
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column <el-table-column
label="创建时间" label="创建时间"
align="center" align="center"
@@ -146,9 +158,17 @@ const loading = ref(true)
const list = ref<CustomTag[]>([]) const list = ref<CustomTag[]>([])
const total = ref(0) const total = ref(0)
const typeLabelMap: Record<string, string> = {
product: '产品',
store: '门店',
supplier: '供货商',
member: '会员'
}
const queryParams = reactive({ const queryParams = reactive({
pageNo: 1, pageNo: 1,
pageSize: 10, pageSize: 10,
type: undefined as string | undefined,
name: undefined, name: undefined,
expression: undefined, expression: undefined,
createTime: [] as string[] createTime: [] as string[]
@@ -178,6 +198,7 @@ const handleQuery = () => {
/** 重置 */ /** 重置 */
const resetQuery = () => { const resetQuery = () => {
queryFormRef.value?.resetFields() queryFormRef.value?.resetFields()
queryParams.type = undefined
handleQuery() handleQuery()
} }

View File

@@ -7,6 +7,11 @@
:inline="true" :inline="true"
label-width="90px" label-width="90px"
> >
<el-form-item label="标签类型" prop="type">
<el-select v-model="queryParams.type" placeholder="标签类型" class="!w-140px" disabled>
<el-option label="产品" value="product" />
</el-select>
</el-form-item>
<el-form-item label="标签名称" prop="name"> <el-form-item label="标签名称" prop="name">
<el-input <el-input
v-model="queryParams.name" v-model="queryParams.name"
@@ -62,14 +67,20 @@
@selection-change="handleRowCheckboxChange" @selection-change="handleRowCheckboxChange"
> >
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />
<el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true" /> <el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true">
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="标签颜色" align="center" prop="color" width="100">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small">{{ scope.row.color }}</el-tag> <el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small" class="!border-0">
<span v-else>-</span> {{ scope.row.name || '-' }}
</el-tag>
<span v-else>{{ scope.row.name || '-' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="标签类型" align="center" prop="type" width="90">
<template #default="scope">
{{ typeLabelMap[scope.row.type] || scope.row.type || '产品' }}
</template>
</el-table-column>
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="代入参数" align="center" prop="useParams" width="90"> <el-table-column label="代入参数" align="center" prop="useParams" width="90">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag> <el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag>
@@ -107,6 +118,13 @@ defineOptions({ name: 'ProductCustomTag' })
const message = useMessage() const message = useMessage()
const { t } = useI18n() const { t } = useI18n()
const typeLabelMap: Record<string, string> = {
product: '产品',
store: '门店',
supplier: '供货商',
member: '会员'
}
const loading = ref(true) const loading = ref(true)
const list = ref<CustomTag[]>([]) const list = ref<CustomTag[]>([])
const total = ref(0) const total = ref(0)

View File

@@ -7,7 +7,9 @@
<div class="kpi-value">{{ kpi.value }}</div> <div class="kpi-value">{{ kpi.value }}</div>
<div class="kpi-footer"> <div class="kpi-footer">
<span class="kpi-trend" :class="kpi.trendClass">{{ kpi.trendText }}</span> <span class="kpi-trend" :class="kpi.trendClass">{{ kpi.trendText }}</span>
<el-tooltip :content="kpi.desc" placement="top" :show-after="300" class="kpi-desc-tooltip">
<span class="kpi-desc">{{ kpi.desc }}</span> <span class="kpi-desc">{{ kpi.desc }}</span>
</el-tooltip>
</div> </div>
<!-- 迷你柱状图 --> <!-- 迷你柱状图 -->
<div v-if="kpi.miniBars" class="mini-bars"> <div v-if="kpi.miniBars" class="mini-bars">
@@ -77,7 +79,7 @@ defineProps<{
} }
.kpi-value { .kpi-value {
font-size: 32px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: #1e293b; color: #1e293b;
line-height: 1.2; line-height: 1.2;
@@ -90,8 +92,10 @@ defineProps<{
gap: 8px; gap: 8px;
font-size: 12px; font-size: 12px;
margin-bottom: 8px; margin-bottom: 8px;
min-width: 0;
.kpi-trend { .kpi-trend {
flex-shrink: 0;
font-weight: 500; font-weight: 500;
&.trend-up { &.trend-up {
@@ -103,7 +107,16 @@ defineProps<{
} }
} }
.kpi-desc-tooltip {
flex: 1;
min-width: 0;
overflow: hidden;
}
.kpi-desc { .kpi-desc {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #9ca3af; color: #9ca3af;
} }
} }

View File

@@ -162,11 +162,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { ReportApi } from '@/api/ydoyun/report/reportpage' import { ReportApi } from '@/api/ydoyun/report/reportpage'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAiAssistant } from '@/components/AiAssistant/useAiAssistant'
const REPORT_ID = 6 const REPORT_ID = 6
@@ -190,7 +191,10 @@ interface MiddleClassItem {
defineOptions({ name: 'MiddleClassRanking' }) defineOptions({ name: 'MiddleClassRanking' })
const route = useRoute() const route = useRoute()
const { setPageLoading } = useAiAssistant()
const loading = ref(false) const loading = ref(false)
watch(loading, (v) => setPageLoading(v), { immediate: true })
onUnmounted(() => setPageLoading(false))
const dateRange = ref<[string, string] | null>([ const dateRange = ref<[string, string] | null>([
dayjs().subtract(6, 'day').format('YYYY-MM-DD'), dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD') dayjs().format('YYYY-MM-DD')

View File

@@ -520,13 +520,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Picture, Grid, List, Search } from '@element-plus/icons-vue' import { Picture, Grid, List, Search } from '@element-plus/icons-vue'
import { ReportApi } from '@/api/ydoyun/report/reportpage' import { ReportApi } from '@/api/ydoyun/report/reportpage'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { useAiAssistant } from '@/components/AiAssistant/useAiAssistant'
const REPORT_ID = 6 const REPORT_ID = 6
@@ -585,7 +586,10 @@ defineOptions({ name: 'ProductCardsPage' })
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { setPageLoading } = useAiAssistant()
const loading = ref(false) const loading = ref(false)
watch(loading, (v) => setPageLoading(v), { immediate: true })
onUnmounted(() => setPageLoading(false))
const userStore = useUserStore() const userStore = useUserStore()
const username = computed(() => userStore.user?.username || '') const username = computed(() => userStore.user?.username || '')

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="supplier-performance-page"> <div ref="pageRootRef" class="supplier-performance-page">
<!-- 查询条件区域与主页一致 --> <!-- 查询条件区域与主页一致 -->
<el-card class="query-card" shadow="never"> <el-card class="query-card" shadow="never">
<div class="query-form"> <div class="query-form">
@@ -212,6 +212,11 @@
</el-radio-button> </el-radio-button>
</el-radio-group> </el-radio-group>
</div> </div>
<div class="view-toolbar-right">
<el-button link type="primary" size="small" @click="openPromptEdit">
<Icon icon="ep:edit" /> 编辑提示词
</el-button>
</div>
</div> </div>
<!-- 标签分类筛选与主页供应商表现一致 --> <!-- 标签分类筛选与主页供应商表现一致 -->
@@ -260,7 +265,7 @@
</div> </div>
<!-- 指标表格三列网格布局 --> <!-- 指标表格三列网格布局 -->
<div v-if="getColumnsWithDate(row).length > 0" class="metric-section"> <div v-if="getColumnsWithDate().length > 0" class="metric-section">
<div class="grid-layout metric-header"> <div class="grid-layout metric-header">
<span>检测指标</span> <span>检测指标</span>
<span>实际结果</span> <span>实际结果</span>
@@ -294,17 +299,24 @@
<!-- 底部 --> <!-- 底部 -->
<div class="card-footer"> <div class="card-footer">
<span class="footer-label">&nbsp;</span> <el-button
<span class="footer-link">详情 </span> type="primary"
size="default"
class="ai-analysis-btn"
@click="onCardAiAnalysis($event)"
>
<el-icon class="btn-icon"><ChatDotRound /></el-icon>
AI分析
</el-button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 列表形式不需要悬浮卡片 --> <!-- 列表形式每个字段独立一列铺开展示 -->
<div v-show="displayMode === 'table'" class="table-wrap"> <div v-show="displayMode === 'table'" class="table-wrap">
<el-table :data="visibleRows" v-loading="loading" border stripe style="width: 100%"> <el-table :data="visibleRows" v-loading="loading" border stripe style="width: 100%">
<el-table-column label="供应商" min-width="220" show-overflow-tooltip> <el-table-column label="供应商" min-width="180" fixed="left" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<div class="table-title"> <div class="table-title">
<div class="table-title-main">{{ String((row as any)?.title ?? (row as any)?.name ?? '-') }}</div> <div class="table-title-main">{{ String((row as any)?.title ?? (row as any)?.name ?? '-') }}</div>
@@ -321,24 +333,21 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="核心指标" width="160" align="center"> <el-table-column label="核心指标名称" min-width="120" align="center" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">{{ getTitleName(row) || '-' }}</template>
<div class="table-core">
<div class="core-name">{{ getTitleName(row) || '-' }}</div>
<div class="core-value">{{ getTitleValue(row) || '-' }}</div>
</div>
</template>
</el-table-column> </el-table-column>
<el-table-column label="检测指标明细" min-width="420"> <el-table-column label="核心指标值" min-width="100" align="center" show-overflow-tooltip>
<template #default="{ row }">{{ getTitleValue(row) || '-' }}</template>
</el-table-column>
<template v-for="(col, ci) in metricColumns" :key="col.key || ci">
<el-table-column :label="col.title + '(实际)'" min-width="110" align="center" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<div class="metric-cell">
<div <div
v-for="(item, mi) in getTableData(row)" v-for="(item, mi) in getMetricItemAt(row, ci)"
:key="mi" :key="item.metricName + '-' + mi"
class="metric-cell-row" class="table-metric-actual"
:class="item.valueColorClass"
> >
<div class="metric-cell-name">{{ item.metricName }}</div>
<div class="metric-cell-actual" :class="item.valueColorClass">
{{ item.actualValue }} {{ item.actualValue }}
<span v-if="item.arrow" class="trend-arrow" :class="item.arrowClass">{{ item.arrow }}</span> <span v-if="item.arrow" class="trend-arrow" :class="item.arrowClass">{{ item.arrow }}</span>
<template v-if="item.labelPairs && item.labelPairs.length > 0"> <template v-if="item.labelPairs && item.labelPairs.length > 0">
@@ -352,25 +361,44 @@
</span> </span>
</template> </template>
</div> </div>
<div class="metric-cell-ref">{{ item.paramValue }}</div> </template>
</div> </el-table-column>
<el-table-column :label="col.title + '(基准)'" min-width="100" align="center" show-overflow-tooltip>
<template #default="{ row }">
<div v-for="(item, mi) in getMetricItemAt(row, ci)" :key="item.metricName + '-' + mi" class="table-metric-ref">
{{ item.paramValue }}
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
</template>
</el-table> </el-table>
</div> </div>
</el-card> </el-card>
<AiPromptEditDialog
v-model="promptEditVisible"
:module-key="SUPPLIER_PERFORMANCE_MODULE_KEY"
module-name="供应商表现"
:initial-prompt="promptMap[SUPPLIER_PERFORMANCE_MODULE_KEY]"
@saved="loadPromptMap"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue' import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
defineOptions({ name: 'SupplierPerformance' })
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Grid, List } from '@element-plus/icons-vue' import { Grid, List, ChatDotRound } from '@element-plus/icons-vue'
import { Icon } from '@/components/Icon'
import { ReportApi } from '@/api/ydoyun/report/reportpage' import { ReportApi } from '@/api/ydoyun/report/reportpage'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { useAiAssistant } from '@/components/AiAssistant/useAiAssistant'
import { AiModulePromptApi } from '@/api/ydoyun/aiModulePrompt'
import AiPromptEditDialog from './AiPromptEditDialog.vue'
interface ColumnCfg { interface ColumnCfg {
title: string title: string
@@ -380,10 +408,59 @@ interface ColumnCfg {
colorKey?: string colorKey?: string
} }
const SUPPLIER_PERFORMANCE_COMPONENT = 'SupplierPerformance'
const SUPPLIER_PERFORMANCE_MODULE_KEY = `${SUPPLIER_PERFORMANCE_COMPONENT}:main`
const REPORT_ID = 6 const REPORT_ID = 6
const route = useRoute() const route = useRoute()
const userStore = useUserStore() const userStore = useUserStore()
const { openWithScreenshot, setPageLoading, setPageModuleName, setPageModuleCode, setScreenshotTarget } =
useAiAssistant()
const pageRootRef = ref<HTMLElement | null>(null)
const loading = ref(false) const loading = ref(false)
watch(loading, (v) => setPageLoading(v), { immediate: true })
onMounted(() => {
setPageModuleName('供应商表现')
setPageModuleCode(SUPPLIER_PERFORMANCE_MODULE_KEY)
nextTick(() => {
if (pageRootRef.value) setScreenshotTarget(pageRootRef.value)
})
})
onUnmounted(() => {
setPageLoading(false)
setPageModuleName(null)
setPageModuleCode(null)
setScreenshotTarget(null)
})
const promptMap = ref<Record<string, string>>({})
const promptEditVisible = ref(false)
const openPromptEdit = () => {
promptEditVisible.value = true
}
const loadPromptMap = async () => {
try {
const map = await AiModulePromptApi.getPromptMapByComponent(SUPPLIER_PERFORMANCE_COMPONENT)
promptMap.value = map || {}
} catch {
promptMap.value = {}
}
}
/** 卡片 AI 分析:截图当前卡片并打开 AI 对话框,代入模块名+提示词 */
function onCardAiAnalysis(e: MouseEvent) {
const card = (e.currentTarget as HTMLElement).closest('.supplier-card')
if (card instanceof HTMLElement) {
const prompt = promptMap.value[SUPPLIER_PERFORMANCE_MODULE_KEY]
openWithScreenshot(card, {
moduleName: '供应商表现',
moduleCode: SUPPLIER_PERFORMANCE_MODULE_KEY,
prompt
})
}
}
/** 展示形式:卡片 | 列表 */ /** 展示形式:卡片 | 列表 */
const displayMode = ref<'card' | 'table'>('card') const displayMode = ref<'card' | 'table'>('card')
@@ -392,10 +469,6 @@ const rows = ref<any[]>([])
/** 标签分类筛选(与主页供应商表现一致) */ /** 标签分类筛选(与主页供应商表现一致) */
const labelFilter = ref<string>('all') const labelFilter = ref<string>('all')
const username = computed(() => {
return userStore.user?.username || ''
})
// 查询参数(与主页一致) // 查询参数(与主页一致)
const queryParams = reactive({ const queryParams = reactive({
rq: dayjs().subtract(6, 'day').format('YYYY-MM-DD'), rq: dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
@@ -602,51 +675,35 @@ function mapKehuToOptions(list: any): { label: string; value: string }[] {
if (!Array.isArray(list)) return [] if (!Array.isArray(list)) return []
return list.map((item: any) => { return list.map((item: any) => {
const label = item?.khmc ?? item?.CKMC ?? item?.KHMC ?? item?.DLMC ?? item?.label ?? item?.name ?? '' const label = item?.khmc ?? item?.CKMC ?? item?.KHMC ?? item?.DLMC ?? item?.label ?? item?.name ?? ''
const value = item?.khdm != null ? String(item.khdm) : item?.CKDM != null ? String(item.CKDM) : item?.KHDM != null ? String(item.KHDM) : item?.DLDM != null ? String(item.DLDM) : item?.value != null ? String(item.value) : item?.code != null ? String(item.code) : '' const rawVal = item?.khdm ?? item?.CKDM ?? item?.KHDM ?? item?.DLDM ?? item?.value ?? item?.code
const value = rawVal != null ? String(rawVal) : ''
return { label, value } return { label, value }
}).filter((o) => o.label && o.value) }).filter((o) => o.label && o.value)
} }
// 拉取下拉选项 // 拉取下拉选项(通用)
async function fetchBrandOptions() { const OPTION_TABLE_MAP: Array<{
try { tableName: string
const res = await ReportApi.executeTable({ reportId: REPORT_ID, tableName: 'pinpai' }) target: ReturnType<typeof ref<{ label: string; value: string }[]>>
brandOptions.value = mapPinpaiToOptions(resolveTableList(res)) mapper: (list: any) => { label: string; value: string }[]
} catch (_) { }> = [
brandOptions.value = [] { tableName: 'pinpai', target: brandOptions, mapper: mapPinpaiToOptions },
} { tableName: 'jijie', target: seasonOptions, mapper: mapJijieToOptions },
} { tableName: 'dalei', target: categoryOptions, mapper: mapDaleiToOptions },
{ tableName: 'kehu', target: storeOptions, mapper: mapKehuToOptions }
]
async function fetchSeasonOptions() { async function fetchAllOptions() {
await Promise.all(
OPTION_TABLE_MAP.map(async ({ tableName, target, mapper }) => {
try { try {
const res = await ReportApi.executeTable({ reportId: REPORT_ID, tableName: 'jijie' }) const res = await ReportApi.executeTable({ reportId: REPORT_ID, tableName })
seasonOptions.value = mapJijieToOptions(resolveTableList(res)) target.value = mapper(resolveTableList(res))
} catch (_) { } catch {
seasonOptions.value = [] target.value = []
} }
} })
)
async function fetchCategoryOptions() {
try {
const res = await ReportApi.executeTable({ reportId: REPORT_ID, tableName: 'dalei' })
categoryOptions.value = mapDaleiToOptions(resolveTableList(res))
} catch (_) {
categoryOptions.value = []
}
}
async function fetchStoreOptions() {
try {
const res = await ReportApi.executeTable({ reportId: REPORT_ID, tableName: 'kehu' })
storeOptions.value = mapKehuToOptions(resolveTableList(res))
} catch (_) {
storeOptions.value = []
}
}
/** 线路选项:暂不查后端,使用空默认值 */
function fetchLineOptions() {
lineOptions.value = []
} }
// 查询条件默认值:门店默认全选,品牌有数据且含 1 则默认 1 // 查询条件默认值:门店默认全选,品牌有数据且含 1 则默认 1
@@ -666,6 +723,9 @@ const columnsSorted = computed(() => {
return cols.sort((a, b) => (a.order ?? 999) - (b.order ?? 999)) return cols.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
}) })
/** 列表表格:检测指标列(排除第一列 title */
const metricColumns = computed(() => columnsSorted.value.slice(1))
/** 从 rows 中收集所有 label/tag按逗号拆分去重后作为筛选项首项为「全部」 */ /** 从 rows 中收集所有 label/tag按逗号拆分去重后作为筛选项首项为「全部」 */
const labelOpts = computed(() => { const labelOpts = computed(() => {
const labels = new Set<string>() const labels = new Set<string>()
@@ -715,9 +775,8 @@ function parseLabelColorPairs(labelStr: unknown, colorStr: unknown): { label: st
return labels.map((label, i) => ({ label, color: colors[i] ?? 'flat' })) return labels.map((label, i) => ({ label, color: colors[i] ?? 'flat' }))
} }
function formatVal(val: unknown, key: string): string { function formatVal(val: unknown): string {
if (val == null) return '-' if (val == null) return '-'
// 后端 value1/value2/value3 多为字符串(含 %),直接展示;数值则按千分位
if (typeof val === 'number') return val.toLocaleString('zh-CN') if (typeof val === 'number') return val.toLocaleString('zh-CN')
return String(val) return String(val)
} }
@@ -769,16 +828,16 @@ function getTitleValue(row: any): string {
return v != null ? String(v).trim() : '-' return v != null ? String(v).trim() : '-'
} }
/** 获取数据列(排除第一列 title:按接口列配置全部展示,不要求 date 非空 */ /** 获取数据列(排除第一列 title */
function getColumnsWithDate(row: any): ColumnCfg[] { function getColumnsWithDate(): ColumnCfg[] {
return columnsSorted.value.slice(1) return columnsSorted.value.slice(1)
} }
/** 生成表格数据:按列配置渲染,实际结果取 row[key],基准参考取 row[key+date] 或 '-' */ /** 生成表格数据:按列配置渲染,实际结果取 row[key],基准参考取 row[key+date] 或 '-' */
function getTableData(row: any): Array<{ metricName: string; actualValue: string; paramValue: string; valueColorClass?: string; arrow?: string; arrowClass?: string; labelPairs?: Array<{ label: string; color: string }> }> { function getTableData(row: any): Array<{ metricName: string; actualValue: string; paramValue: string; valueColorClass?: string; arrow?: string; arrowClass?: string; labelPairs?: Array<{ label: string; color: string }> }> {
const colsWithDate = getColumnsWithDate(row) const colsWithDate = getColumnsWithDate()
return colsWithDate.map((col) => { return colsWithDate.map((col) => {
const actualVal = formatVal((row as any)?.[col.key], col.key) const actualVal = formatVal((row as any)?.[col.key])
const dateVal = (row as any)?.[col.key + 'date'] const dateVal = (row as any)?.[col.key + 'date']
const paramVal = dateVal != null && String(dateVal).trim() !== '' ? String(dateVal).trim() : '-' const paramVal = dateVal != null && String(dateVal).trim() !== '' ? String(dateVal).trim() : '-'
@@ -827,6 +886,13 @@ function getTableData(row: any): Array<{ metricName: string; actualValue: string
}) })
} }
/** 获取某行第 ci 个指标项(用于列表表格按列展示),返回数组供 v-for 使用 */
function getMetricItemAt(row: any, ci: number) {
const data = getTableData(row)
const item = data[ci]
return item ? [item] : []
}
function parseResponseData(raw: unknown): { columns: ColumnCfg[]; list: any[] } { function parseResponseData(raw: unknown): { columns: ColumnCfg[]; list: any[] } {
if (Array.isArray(raw)) { if (Array.isArray(raw)) {
// 二维数组 [[列], [list]] // 二维数组 [[列], [list]]
@@ -860,8 +926,6 @@ function parseResponseData(raw: unknown): { columns: ColumnCfg[]; list: any[] }
const handleQuery = async () => { const handleQuery = async () => {
loading.value = true loading.value = true
try { try {
// 确保 username 有值:优先使用 computed如果为空则直接从 userStore 获取
const currentUsername = username.value || userStore.user?.username || ''
const res = await ReportApi.getCategoryPerformance({ const res = await ReportApi.getCategoryPerformance({
reportId: REPORT_ID, reportId: REPORT_ID,
name: 'YDY_AI_GET_GHSZX', name: 'YDY_AI_GET_GHSZX',
@@ -872,7 +936,7 @@ const handleQuery = async () => {
dalei: arrToQuery(queryParams.category), dalei: arrToQuery(queryParams.category),
jj: arrToQuery(queryParams.season), jj: arrToQuery(queryParams.season),
p: '123', p: '123',
username: currentUsername, username: userStore.user?.username || '',
ghsdm: supplierCode.value || undefined ghsdm: supplierCode.value || undefined
}) })
const raw = (res as any)?.data ?? res const raw = (res as any)?.data ?? res
@@ -906,27 +970,9 @@ const handleReset = () => {
} }
onMounted(async () => { onMounted(async () => {
// 等待下一个 tick确保路由参数完全加载
await nextTick()
// 先初始化路由参数
initQueryParamsFromRoute() initQueryParamsFromRoute()
await Promise.all([loadPromptMap(), fetchAllOptions()])
// 并行获取下拉选项数据(这些不依赖路由参数,用于填充下拉框)
// 注意:这些调用会立即执行,但它们是获取下拉选项数据,不是主查询
await Promise.all([
fetchBrandOptions(),
fetchSeasonOptions(),
fetchCategoryOptions(),
fetchLineOptions(),
fetchStoreOptions()
])
// 设置默认值
applyQueryDefaults() applyQueryDefaults()
// 最后执行主查询(此时路由参数已经读取完成,包括 code 参数)
// 主查询会使用 supplierCode.value从路由参数中读取的 code作为 ghsdm 参数
handleQuery() handleQuery()
}) })
</script> </script>
@@ -952,6 +998,10 @@ onMounted(async () => {
gap: 16px; gap: 16px;
margin-bottom: 16px; margin-bottom: 16px;
.view-toolbar-right {
margin-left: auto;
}
.view-switch { .view-switch {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1148,44 +1198,9 @@ onMounted(async () => {
} }
} }
.table-core { .table-metric-actual,
display: flex; .table-metric-ref {
flex-direction: column;
gap: 4px;
.core-name {
font-size: 12px; font-size: 12px;
color: var(--el-text-color-secondary);
}
.core-value {
font-weight: 700;
color: var(--el-text-color-primary);
}
}
.metric-cell {
display: flex;
flex-direction: column;
gap: 10px;
padding: 2px 0;
}
.metric-cell-row {
display: grid;
grid-template-columns: 160px 1fr 120px;
gap: 12px;
align-items: start;
}
.metric-cell-name {
font-size: 12px;
color: var(--el-text-color-regular);
}
.metric-cell-actual {
font-size: 12px;
color: var(--el-text-color-primary);
line-height: 1.4; line-height: 1.4;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1212,21 +1227,18 @@ onMounted(async () => {
font-weight: 700; font-weight: 700;
display: inline-block; display: inline-block;
&.badge-danger,
&.label-trend-up { &.label-trend-up {
background: #fee2e2; background: #fee2e2;
color: #ef4444; color: #ef4444;
border: 1px solid #fecaca; border: 1px solid #fecaca;
} }
&.badge-warn,
&.label-trend-flat { &.label-trend-flat {
background: #fef3c7; background: #fef3c7;
color: #d97706; color: #d97706;
border: 1px solid #fde68a; border: 1px solid #fde68a;
} }
&.badge-success,
&.label-trend-down { &.label-trend-down {
background: #dcfce7; background: #dcfce7;
color: #22c55e; color: #22c55e;
@@ -1235,12 +1247,22 @@ onMounted(async () => {
} }
} }
.metric-cell-ref { .table-metric-actual {
font-size: 12px; &.label-trend-up {
color: #ef4444;
}
&.label-trend-down {
color: #22c55e;
}
&.label-trend-flat {
color: #d97706;
}
}
.table-metric-ref {
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
text-align: right;
line-height: 1.4;
white-space: pre-line;
} }
.empty { .empty {
@@ -1469,36 +1491,38 @@ onMounted(async () => {
/* 底部 */ /* 底部 */
.card-footer { .card-footer {
margin-top: 16px; margin-top: 20px;
padding-top: 16px; padding: 18px 8px 0;
border-top: 2px solid var(--el-border-color-lighter); border-top: 1px solid var(--el-border-color-lighter);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-end;
padding: 0 8px; min-height: 52px;
} }
.footer-label { .ai-analysis-btn {
font-size: 10px; font-size: 14px !important;
color: var(--el-text-color-placeholder); font-weight: 600 !important;
font-weight: 900; letter-spacing: 0.5px;
text-transform: uppercase; padding: 10px 24px !important;
letter-spacing: 2px; border-radius: 12px !important;
font-style: italic; border: none !important;
background: linear-gradient(135deg, var(--el-color-primary) 0%, var(--el-color-primary-light-3) 100%) !important;
color: #fff !important;
box-shadow: 0 2px 10px rgba(64, 158, 255, 0.22);
transition: all 0.25s ease;
.btn-icon {
font-size: 16px;
margin-right: 8px;
vertical-align: -0.12em;
}
} }
.footer-link { .ai-analysis-btn:hover {
font-size: 10px; background: linear-gradient(135deg, var(--el-color-primary-light-3) 0%, var(--el-color-primary) 100%) !important;
color: #3b82f6; transform: translateY(-2px);
font-weight: 900; box-shadow: 0 6px 16px rgba(64, 158, 255, 0.32);
text-transform: uppercase;
cursor: pointer;
transition: all 0.2s;
}
.footer-link:hover {
text-decoration: underline;
color: #2563eb;
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {

View File

@@ -168,11 +168,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { ReportApi } from '@/api/ydoyun/report/reportpage' import { ReportApi } from '@/api/ydoyun/report/reportpage'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAiAssistant } from '@/components/AiAssistant/useAiAssistant'
const REPORT_ID = 6 const REPORT_ID = 6
@@ -195,7 +196,10 @@ interface SupplierContributionData {
defineOptions({ name: 'SupplierRanking' }) defineOptions({ name: 'SupplierRanking' })
const route = useRoute() const route = useRoute()
const { setPageLoading } = useAiAssistant()
const loading = ref(false) const loading = ref(false)
watch(loading, (v) => setPageLoading(v), { immediate: true })
onUnmounted(() => setPageLoading(false))
const dateRange = ref<[string, string] | null>([ const dateRange = ref<[string, string] | null>([
dayjs().subtract(6, 'day').format('YYYY-MM-DD'), dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD') dayjs().format('YYYY-MM-DD')

View File

@@ -371,13 +371,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Search, Grid, List } from '@element-plus/icons-vue' import { Search, Grid, List } from '@element-plus/icons-vue'
import { ReportApi } from '@/api/ydoyun/report/reportpage' import { ReportApi } from '@/api/ydoyun/report/reportpage'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { useAiAssistant } from '@/components/AiAssistant/useAiAssistant'
defineOptions({ name: 'CategoryCardListComponents' }) defineOptions({ name: 'CategoryCardListComponents' })
@@ -472,7 +473,10 @@ const getTimeRange = (range: string) => {
return { rq: start.format('YYYY-MM-DD'), rq2: end.format('YYYY-MM-DD') } return { rq: start.format('YYYY-MM-DD'), rq2: end.format('YYYY-MM-DD') }
} }
const { setPageLoading } = useAiAssistant()
const loading = ref(false) const loading = ref(false)
watch(loading, (v) => setPageLoading(v), { immediate: true })
onUnmounted(() => setPageLoading(false))
/** 展示形式:卡片 | 列表 */ /** 展示形式:卡片 | 列表 */
const displayMode = ref<'card' | 'table'>('card') const displayMode = ref<'card' | 'table'>('card')
const columns = ref<{ title: string; key: string; order?: number; labelKey?: string; colorKey?: string }[]>([]) const columns = ref<{ title: string; key: string; order?: number; labelKey?: string; colorKey?: string }[]>([])

View File

@@ -434,13 +434,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Picture, Grid, List, Search } from '@element-plus/icons-vue' import { Picture, Grid, List, Search } from '@element-plus/icons-vue'
import { ReportApi } from '@/api/ydoyun/report/reportpage' import { ReportApi } from '@/api/ydoyun/report/reportpage'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { useAiAssistant } from '@/components/AiAssistant/useAiAssistant'
import type { ProductDetailData, ProductSizeStatus, UiStatus } from './index.vue' import type { ProductDetailData, ProductSizeStatus, UiStatus } from './index.vue'
defineOptions({ name: 'ProductDetailPage' }) defineOptions({ name: 'ProductDetailPage' })
@@ -449,7 +450,10 @@ const REPORT_ID = 6
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const { setPageLoading } = useAiAssistant()
const loading = ref(false) const loading = ref(false)
watch(loading, (v) => setPageLoading(v), { immediate: true })
onUnmounted(() => setPageLoading(false))
const productList = ref<ProductDetailData[]>([]) const productList = ref<ProductDetailData[]>([])
/** 标签分类筛选 */ /** 标签分类筛选 */
const labelFilter = ref<string>('all') const labelFilter = ref<string>('all')

View File

@@ -196,7 +196,11 @@
</div> </div>
</el-card> </el-card>
<!-- KPI 卡片区域加载中显示特效 --> <!-- KPI 卡片区域 -->
<div class="kpi-section">
<div class="module-header">
<span class="module-title">KPI 指标</span>
</div>
<div <div
class="kpi-grid" class="kpi-grid"
v-loading="loadingKpi" v-loading="loadingKpi"
@@ -211,7 +215,9 @@
<div v-if="kpi.value != null && kpi.value !== ''" class="kpi-value">{{ kpi.value }}</div> <div v-if="kpi.value != null && kpi.value !== ''" class="kpi-value">{{ kpi.value }}</div>
<div v-if="(formatTrendText(kpi) || (kpi.descs != null && kpi.descs !== ''))" class="kpi-footer"> <div v-if="(formatTrendText(kpi) || (kpi.descs != null && kpi.descs !== ''))" class="kpi-footer">
<span v-if="formatTrendText(kpi)" class="kpi-trend" :class="kpi.trend">{{ formatTrendText(kpi) }}</span> <span v-if="formatTrendText(kpi)" class="kpi-trend" :class="kpi.trend">{{ formatTrendText(kpi) }}</span>
<span v-if="kpi.descs != null && kpi.descs !== ''" class="kpi-desc">{{ kpi.descs }}</span> <el-tooltip v-if="kpi.descs != null && kpi.descs !== ''" :content="kpi.descs" placement="top" :show-after="300" class="kpi-desc-tooltip">
<span class="kpi-desc">{{ kpi.descs }}</span>
</el-tooltip>
</div> </div>
<!-- 迷你柱状图 --> <!-- 迷你柱状图 -->
<div v-if="kpi.miniBars" class="mini-bars"> <div v-if="kpi.miniBars" class="mini-bars">
@@ -238,10 +244,11 @@
</div> </div>
</el-card> </el-card>
</div> </div>
</div>
<!-- 供应商表现 (Top 10) + 中类销售排名 Top 5 --> <!-- 供应商表现 (Top 10) + 中类销售排名 Top 5 -->
<div class="middle-section"> <div class="middle-section">
<!-- 供应商表现 (Top 10)KBStyle 筛选 + 紧凑表格 + 展开更多 --> <!-- 供应商表现 (Top 10) -->
<el-card <el-card
class="kb-cat-card" class="kb-cat-card"
shadow="never" shadow="never"
@@ -253,10 +260,12 @@
<el-icon class="kb-title-icon"><StarFilled /></el-icon> <el-icon class="kb-title-icon"><StarFilled /></el-icon>
<h3 class="kb-card-title">供应商表现 (Top 10)</h3> <h3 class="kb-card-title">供应商表现 (Top 10)</h3>
</div> </div>
<div class="header-actions">
<el-button type="primary" link size="small" @click="handleViewMoreCategories" class="more-button"> <el-button type="primary" link size="small" @click="handleViewMoreCategories" class="more-button">
更多 <el-icon><ArrowRight /></el-icon> 更多 <el-icon><ArrowRight /></el-icon>
</el-button> </el-button>
</div> </div>
</div>
<div class="kb-card-content"> <div class="kb-card-content">
<div class="filter-bar"> <div class="filter-bar">
<div <div
@@ -489,6 +498,7 @@
<el-icon class="category-title-icon"><StarFilled /></el-icon> <el-icon class="category-title-icon"><StarFilled /></el-icon>
<h3 class="category-card-title">中类销售排名 Top 5</h3> <h3 class="category-card-title">中类销售排名 Top 5</h3>
</div> </div>
<div class="header-actions">
<el-button <el-button
type="primary" type="primary"
link link
@@ -500,6 +510,7 @@
<el-icon><ArrowRight /></el-icon> <el-icon><ArrowRight /></el-icon>
</el-button> </el-button>
</div> </div>
</div>
<div class="category-pie-wrap"> <div class="category-pie-wrap">
<Echart <Echart
v-if="categoryTop6.length > 0" v-if="categoryTop6.length > 0"
@@ -542,7 +553,7 @@
row-class-name="product-list-row-clickable" row-class-name="product-list-row-clickable"
@row-click="handleProductRowClick" @row-click="handleProductRowClick"
> >
<el-table-column prop="productInfo" label="商品信息" width="280"> <el-table-column prop="productInfo" label="商品信息" width="200" fixed="left">
<template #default="{ row }"> <template #default="{ row }">
<el-popover <el-popover
placement="right" placement="right"
@@ -553,25 +564,19 @@
@show="handleProductHoverShow(row)" @show="handleProductHoverShow(row)"
> >
<template #reference> <template #reference>
<div class="product-info"> <div class="product-info product-info-compact">
<div class="product-image"> <div class="product-image">
<img v-if="row.imageUrl" :src="row.imageUrl" alt="商品图片" class="product-img" /> <img v-if="row.imageUrl" :src="row.imageUrl" alt="商品图片" class="product-img" />
<el-icon v-else><Picture /></el-icon> <el-icon v-else><Picture /></el-icon>
</div> </div>
<div class="product-details"> <div class="product-details">
<div class="product-code"> <div class="product-name">{{ row.name || '-' }}</div>
款号: <div
<span
class="product-code-link" class="product-code-link"
@click.stop="handleProductCodeClick(row)" @click.stop="handleProductCodeClick(row)"
> >
{{ getProductCode(row) }} {{ getProductCode(row) }}
</span>
</div> </div>
<div class="product-code">条码: {{ row.barcode || '-' }}</div>
<div class="product-code">颜色: {{ row.color }}</div>
<div class="product-code">进价: ¥{{ formatNumber(row.purchasePrice || 0) }}</div>
<div class="product-code">售价: ¥{{ formatNumber(row.sellingPrice || 0) }}</div>
</div> </div>
</div> </div>
</template> </template>
@@ -707,13 +712,37 @@
</el-popover> </el-popover>
</template> </template>
</el-table-column> </el-table-column>
<!-- 第二列商品属性 --> <!-- 商品属性拆开列展示 -->
<el-table-column prop="category" label="商品属性" width="200" show-overflow-tooltip> <el-table-column prop="code" label="款号" min-width="100" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<div>{{ row.category }}</div> <span
class="product-code-link"
@click.stop="handleProductCodeClick(row)"
>
{{ getProductCode(row) }}
</span>
</template> </template>
</el-table-column> </el-table-column>
<!-- 第三列销售数据 --> <el-table-column prop="barcode" label="条码" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.barcode || '-' }}</template>
</el-table-column>
<el-table-column prop="color" label="颜色" min-width="80" show-overflow-tooltip>
<template #default="{ row }">{{ row.color || '-' }}</template>
</el-table-column>
<el-table-column prop="category" label="类目" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.category || '-' }}</template>
</el-table-column>
<el-table-column prop="purchasePrice" label="进价" min-width="90" align="right">
<template #default="{ row }">
{{ row.purchasePrice != null ? '¥' + formatNumber(row.purchasePrice) : '-' }}
</template>
</el-table-column>
<el-table-column prop="sellingPrice" label="售价" min-width="90" align="right">
<template #default="{ row }">
{{ row.sellingPrice != null ? '¥' + formatNumber(row.sellingPrice) : '-' }}
</template>
</el-table-column>
<!-- 销售数据 -->
<el-table-column prop="ls" label="销售数据" align="right" width="150"> <el-table-column prop="ls" label="销售数据" align="right" width="150">
<template #default="{ row }"> <template #default="{ row }">
<div class="font-bold">{{ row.lsRaw || '-' }}</div> <div class="font-bold">{{ row.lsRaw || '-' }}</div>
@@ -799,7 +828,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount, onDeactivated, nextTick } from 'vue' import { ref, reactive, computed, watch, onMounted, onUnmounted, onBeforeUnmount, onDeactivated, nextTick } from 'vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { EChartsOption } from 'echarts' import type { EChartsOption } from 'echarts'
import Echart from '@/components/Echart/src/Echart.vue' import Echart from '@/components/Echart/src/Echart.vue'
@@ -817,6 +846,7 @@ import { ElMessage } from 'element-plus'
import { Icon } from '@/components/Icon' import { Icon } from '@/components/Icon'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import type { CategoryDiagnosticData } from './components/categoryCardListComponents.vue' import type { CategoryDiagnosticData } from './components/categoryCardListComponents.vue'
import { useAiAssistant } from '@/components/AiAssistant/useAiAssistant'
defineOptions({ name: 'ProductDashboard' }) defineOptions({ name: 'ProductDashboard' })
@@ -1233,6 +1263,15 @@ const loadingCategory = ref(false)
const loadingPie = ref(false) const loadingPie = ref(false)
/** 商品明细列表加载状态 */ /** 商品明细列表加载状态 */
const loadingProductList = ref(false) const loadingProductList = ref(false)
const { setPageLoading } = useAiAssistant()
watch(
[loadingKpi, loadingCategory, loadingPie, loadingProductList],
([a, b, c, d]) => setPageLoading(!!(a || b || c || d)),
{ immediate: true }
)
onUnmounted(() => setPageLoading(false))
const searchKeyword = ref('') const searchKeyword = ref('')
const hoverDetail = ref<ProductDetailData | null>(null) const hoverDetail = ref<ProductDetailData | null>(null)
@@ -2861,7 +2900,7 @@ onDeactivated(() => {
} }
.kpi-value { .kpi-value {
font-size: 32px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
line-height: 1.2; line-height: 1.2;
@@ -2874,8 +2913,10 @@ onDeactivated(() => {
gap: 8px; gap: 8px;
font-size: 12px; font-size: 12px;
margin-bottom: 8px; margin-bottom: 8px;
min-width: 0;
.kpi-trend { .kpi-trend {
flex-shrink: 0;
font-weight: 500; font-weight: 500;
&.up { &.up {
@@ -2891,7 +2932,16 @@ onDeactivated(() => {
} }
} }
.kpi-desc-tooltip {
flex: 1;
min-width: 0;
overflow: hidden;
}
.kpi-desc { .kpi-desc {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--el-text-color-placeholder); color: var(--el-text-color-placeholder);
} }
} }
@@ -3554,6 +3604,17 @@ onDeactivated(() => {
gap: 12px; gap: 12px;
align-items: flex-start; align-items: flex-start;
&.product-info-compact .product-details {
.product-name {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px;
}
.product-code-link {
font-size: 12px;
}
}
.product-image { .product-image {
width: 48px; width: 48px;
height: 56px; height: 56px;
@@ -4197,4 +4258,5 @@ onDeactivated(() => {
.kb22-legend-item { display: flex; align-items: center; gap: 4px; font-size: 9px; color: var(--el-text-color-placeholder); } .kb22-legend-item { display: flex; align-items: center; gap: 4px; font-size: 9px; color: var(--el-text-color-placeholder); }
.kb22-dot { width: 6px; height: 6px; border-radius: 50%; } .kb22-dot { width: 6px; height: 6px; border-radius: 50%; }
} }
</style> </style>

View File

@@ -7,6 +7,11 @@
:inline="true" :inline="true"
label-width="90px" label-width="90px"
> >
<el-form-item label="标签类型" prop="type">
<el-select v-model="queryParams.type" placeholder="标签类型" class="!w-140px" disabled>
<el-option label="门店" value="store" />
</el-select>
</el-form-item>
<el-form-item label="标签名称" prop="name"> <el-form-item label="标签名称" prop="name">
<el-input <el-input
v-model="queryParams.name" v-model="queryParams.name"
@@ -62,14 +67,20 @@
@selection-change="handleRowCheckboxChange" @selection-change="handleRowCheckboxChange"
> >
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />
<el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true" /> <el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true">
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="标签颜色" align="center" prop="color" width="100">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small">{{ scope.row.color }}</el-tag> <el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small" class="!border-0">
<span v-else>-</span> {{ scope.row.name || '-' }}
</el-tag>
<span v-else>{{ scope.row.name || '-' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="标签类型" align="center" prop="type" width="90">
<template #default="scope">
{{ typeLabelMap[scope.row.type] || scope.row.type || '门店' }}
</template>
</el-table-column>
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="代入参数" align="center" prop="useParams" width="90"> <el-table-column label="代入参数" align="center" prop="useParams" width="90">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag> <el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag>
@@ -107,6 +118,13 @@ defineOptions({ name: 'StoreCustomTag' })
const message = useMessage() const message = useMessage()
const { t } = useI18n() const { t } = useI18n()
const typeLabelMap: Record<string, string> = {
product: '产品',
store: '门店',
supplier: '供货商',
member: '会员'
}
const loading = ref(true) const loading = ref(true)
const list = ref<CustomTag[]>([]) const list = ref<CustomTag[]>([])
const total = ref(0) const total = ref(0)

View File

@@ -7,6 +7,11 @@
:inline="true" :inline="true"
label-width="90px" label-width="90px"
> >
<el-form-item label="标签类型" prop="type">
<el-select v-model="queryParams.type" placeholder="标签类型" class="!w-140px" disabled>
<el-option label="供货商" value="supplier" />
</el-select>
</el-form-item>
<el-form-item label="标签名称" prop="name"> <el-form-item label="标签名称" prop="name">
<el-input <el-input
v-model="queryParams.name" v-model="queryParams.name"
@@ -62,14 +67,20 @@
@selection-change="handleRowCheckboxChange" @selection-change="handleRowCheckboxChange"
> >
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />
<el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true" /> <el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true">
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="标签颜色" align="center" prop="color" width="100">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small">{{ scope.row.color }}</el-tag> <el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small" class="!border-0">
<span v-else>-</span> {{ scope.row.name || '-' }}
</el-tag>
<span v-else>{{ scope.row.name || '-' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="标签类型" align="center" prop="type" width="90">
<template #default="scope">
{{ typeLabelMap[scope.row.type] || scope.row.type || '供货商' }}
</template>
</el-table-column>
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="代入参数" align="center" prop="useParams" width="90"> <el-table-column label="代入参数" align="center" prop="useParams" width="90">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag> <el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag>
@@ -107,6 +118,13 @@ defineOptions({ name: 'SupplierCustomTag' })
const message = useMessage() const message = useMessage()
const { t } = useI18n() const { t } = useI18n()
const typeLabelMap: Record<string, string> = {
product: '产品',
store: '门店',
supplier: '供货商',
member: '会员'
}
const loading = ref(true) const loading = ref(true)
const list = ref<CustomTag[]>([]) const list = ref<CustomTag[]>([])
const total = ref(0) const total = ref(0)

View File

@@ -0,0 +1,192 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
v-loading="formLoading"
>
<el-form-item label="标签名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入标签名称" />
</el-form-item>
<el-form-item label="标签类型" prop="type">
<el-select v-model="formData.type" placeholder="请选择标签类型" class="!w-full">
<el-option label="产品基础标签 (product)" value="product" />
<el-option label="门店基础标签 (store)" value="store" />
<el-option label="供应商基础标签 (supplier)" value="supplier" />
<el-option label="会员基础标签 (member)" value="member" />
</el-select>
</el-form-item>
<el-form-item label="标签表达式" prop="expression">
<el-input v-model="formData.expression" type="textarea" placeholder="请输入标签表达式" />
</el-form-item>
<el-form-item label="标签颜色" prop="color">
<div class="color-selector-row">
<div class="dot-group">
<span
v-for="c in presetColors"
:key="c"
class="color-dot color-dot-clickable"
:class="{ 'color-dot-active': formData.color === c }"
:style="{ backgroundColor: c }"
:title="'预设色 ' + c"
@click="formData.color = c"
></span>
</div>
<el-color-picker v-model="formData.color" :predefine="presetColors" size="default" />
</div>
</el-form-item>
<el-form-item label="执行SQL脚本" prop="sqlScript">
<el-input
v-model="formData.sqlScript"
type="textarea"
:rows="10"
placeholder="请输入标签同步时执行的SQL脚本支持多行"
class="sql-script-area"
/>
<div class="form-item-hint">该SQL将用于标签计算与同步请确保语法正确</div>
</el-form-item>
<el-form-item label="是否代入参数" prop="useParams">
<el-radio-group v-model="formData.useParams">
<el-radio :value="true"></el-radio>
<el-radio :value="false"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="参数列表" prop="params">
<el-input v-model="formData.params" placeholder="逗号分隔param1,param2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { TagApi } from '@/api/ydoyun/tagconfig'
import type { Tag } from '@/api/ydoyun/tagconfig'
defineOptions({ name: 'TagForm' })
const { t } = useI18n()
const message = useMessage()
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formLoading = ref(false)
const formType = ref('')
const formData = ref<Tag>({
id: undefined,
name: undefined,
type: undefined,
expression: undefined,
color: undefined,
sqlScript: undefined,
useParams: false,
params: undefined,
})
const formRules = reactive({
name: [{ required: true, message: '标签名称不能为空', trigger: 'blur' }],
type: [{ required: true, message: '请选择标签类型', trigger: 'change' }],
sqlScript: [{ required: true, message: '执行SQL脚本不能为空', trigger: 'blur' }],
useParams: [{ required: true, message: '请选择是否代入参数', trigger: 'change' }],
})
const formRef = ref()
/** 4 个预设颜色(蓝、红、黄、绿) */
const presetColors = ['#2f54eb', '#f5222d', '#fa8c16', '#52c41a']
const open = async (type: string, id?: number, initialType?: string) => {
dialogVisible.value = true
dialogTitle.value = type === 'create' ? '新增标签' : '编辑标签'
formType.value = type
resetForm()
if (id) {
formLoading.value = true
try {
const res = await TagApi.getTag(id)
formData.value = (res as any)?.data || {}
} finally {
formLoading.value = false
}
} else if (type === 'create' && initialType) {
formData.value.type = initialType
}
}
defineExpose({ open })
const emit = defineEmits(['success'])
const submitForm = async () => {
await formRef.value.validate()
formLoading.value = true
try {
const data = formData.value
if (formType.value === 'create') {
await TagApi.createTag(data)
message.success(t('common.createSuccess'))
} else {
await TagApi.updateTag(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
emit('success')
} finally {
formLoading.value = false
}
}
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
type: undefined,
expression: undefined,
color: undefined,
sqlScript: undefined,
useParams: false,
params: undefined,
}
formRef.value?.resetFields()
}
</script>
<style scoped>
.color-selector-row {
display: flex;
align-items: center;
gap: 12px;
}
.dot-group {
display: flex;
gap: 8px;
}
.color-dot {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #d9d9d9;
flex-shrink: 0;
}
.color-dot-clickable {
cursor: pointer;
transition: transform 0.15s;
}
.color-dot-clickable:hover {
transform: scale(1.1);
}
.color-dot-active {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--el-color-primary);
}
.sql-script-area :deep(textarea) {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
}
.form-item-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
</style>

File diff suppressed because it is too large Load Diff