#add jira & message sync

This commit is contained in:
2025-12-02 10:16:32 +08:00
parent 5c4492d8f8
commit 2ec44b5665
49 changed files with 6633 additions and 1209 deletions

View File

@@ -1,11 +1,11 @@
import './bootstrap';
import { createApp } from 'vue';
import EnvManager from './components/EnvManager.vue';
import AdminDashboard from './components/admin/AdminDashboard.vue';
console.log('App.js loading...');
const app = createApp({});
app.component('env-manager', EnvManager);
app.component('admin-dashboard', AdminDashboard);
console.log('Mounting app...');
app.mount('#app');

View File

@@ -1,162 +0,0 @@
<template>
<div class="admin-layout h-screen bg-gray-50">
<!-- 侧边栏 -->
<div class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg border-r border-gray-200 flex flex-col">
<!-- Logo区域 -->
<div class="flex items-center justify-center h-16 px-6 bg-gradient-to-r from-blue-600 to-blue-700 border-b border-blue-800 flex-shrink-0">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z"/>
</svg>
</div>
<div class="text-white">
<div class="text-lg font-bold">管理系统</div>
<div class="text-xs text-blue-100">Environment Manager</div>
</div>
</div>
</div>
<!-- 导航菜单 -->
<nav class="mt-6 px-3 flex flex-col flex-1">
<div class="space-y-1 flex-1">
<a
href="#"
@click.prevent="setActiveMenu('env')"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'env'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'env' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
环境配置管理
</a>
<!-- 预留的其他菜单项 -->
<a
href="#"
@click.prevent="setActiveMenu('settings')"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'settings'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'settings' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
系统设置
</a>
<a
href="#"
@click.prevent="setActiveMenu('logs')"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'logs'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'logs' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
操作日志
</a>
</div>
<!-- 底部信息 -->
<div class="mt-auto pt-6 border-t border-gray-200">
<div class="px-3 py-2">
<div class="text-xs text-gray-500 mb-1">系统信息</div>
<div class="text-xs text-gray-400">版本 v1.0.0</div>
<div class="text-xs text-gray-400">Laravel 12 + Vue 3</div>
</div>
</div>
</nav>
</div>
<!-- 主内容区域 -->
<div class="pl-64 h-screen">
<!-- 页面内容 -->
<main class="h-full">
<slot></slot>
</main>
</div>
</div>
</template>
<script>
export default {
name: 'AdminLayout',
props: {
pageTitle: {
type: String,
default: '环境配置管理'
}
},
data() {
return {
activeMenu: 'env'
}
},
methods: {
setActiveMenu(menu) {
this.activeMenu = menu;
this.$emit('menu-change', menu);
}
}
}
</script>
<style scoped>
.admin-layout {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.admin-layout .pl-64 {
padding-left: 0;
}
.admin-layout .fixed.w-64 {
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
}
.admin-layout .fixed.w-64.open {
transform: translateX(0);
}
}
</style>

View File

@@ -1,969 +0,0 @@
<template>
<div class="simple-code-editor">
<!-- 编辑器头部工具栏 -->
<div class="editor-header">
<div class="editor-tabs">
<div class="tab active">
<div class="tab-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/>
</svg>
</div>
<span class="tab-name">{{ getFileName() }}</span>
<div class="tab-close">×</div>
</div>
</div>
<div class="editor-controls">
<button class="control-btn" @click="formatCode" title="格式化代码">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.22,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.68 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/>
</svg>
</button>
<button class="control-btn" @click="toggleWrap" title="切换自动换行">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M4,6H20V8H4V6M4,18V16H20V18H4M4,13H16L13,16H15L19,12L15,8H13L16,11H4V13Z"/>
</svg>
</button>
</div>
</div>
<!-- 编辑器主体 - 单层渲染方案 -->
<div class="editor-body" :class="{ 'dark-theme': theme === 'dark', 'word-wrap': wordWrap }">
<!-- 行号区域 -->
<div class="line-numbers-area" ref="lineNumbers">
<div
v-for="(line, index) in lines"
:key="index"
class="line-number"
:class="{ 'active': index + 1 === currentLine }"
>
{{ String(index + 1).padStart(3, ' ') }}
</div>
</div>
<!-- 代码区域 - 只使用一个带语法高亮的可编辑div -->
<div class="code-area">
<div
ref="codeEditor"
class="code-editor-div"
:contenteditable="!readonly"
@input="handleDivInput"
@scroll="handleScroll"
@keydown="handleKeydown"
@click="updateCursor"
@keyup="updateCursor"
@focus="handleFocus"
@blur="handleBlur"
@paste="handlePaste"
spellcheck="false"
v-html="highlightedContent"
></div>
</div>
</div>
<!-- 编辑器底部状态栏 -->
<div class="editor-footer">
<div class="status-left">
<span class="status-item"> {{ currentLine }}</span>
<span class="status-item"> {{ currentColumn }}</span>
<span class="status-item">{{ lines.length }} </span>
<span class="status-item">{{ content.length }} 字符</span>
</div>
<div class="status-right">
<span class="status-item">{{ language.toUpperCase() }}</span>
<span class="status-item">UTF-8</span>
<span class="status-item" :class="{ 'status-success': !readonly }">
{{ readonly ? '只读' : '可编辑' }}
</span>
</div>
</div>
</div>
</template>
<script>
// 纯原生实现,无外部依赖
export default {
name: 'CodeEditor',
props: {
modelValue: {
type: String,
default: ''
},
language: {
type: String,
default: 'env' // 'env', 'javascript', 'php'
},
theme: {
type: String,
default: 'light' // 'light', 'dark'
},
readonly: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: ''
}
},
emits: ['update:modelValue', 'change'],
data() {
return {
content: this.modelValue,
currentLine: 1,
currentColumn: 1,
focused: false,
wordWrap: false
}
},
computed: {
lines() {
return this.content.split('\n');
},
highlightedContent() {
if (!this.content) return '';
if (this.language === 'env') {
return this.highlightEnvContent(this.content);
} else if (this.language === 'javascript') {
return this.highlightJavaScriptContent(this.content);
} else if (this.language === 'php') {
return this.highlightPhpContent(this.content);
}
return this.escapeHtml(this.content);
}
},
mounted() {
// 初始化时自动检测语言
this.autoDetectLanguage();
// 初始化编辑器
this.initEditor();
},
watch: {
modelValue(newValue) {
if (newValue !== this.content) {
this.content = newValue;
this.$nextTick(() => {
this.initEditor();
});
}
},
content() {
this.$nextTick(() => {
this.initEditor();
});
},
language() {
// 语言变化时重新渲染语法高亮
this.$nextTick(() => {
this.initEditor();
});
}
},
methods: {
handleInput() {
this.$emit('update:modelValue', this.content);
this.$emit('change', this.content);
this.updateCursor();
},
handleDivInput(event) {
// 获取纯文本内容
const text = this.getPlainTextFromDiv(event.target);
this.content = text;
this.$emit('update:modelValue', text);
this.$emit('change', text);
// 延迟更新语法高亮,避免光标跳动
this.$nextTick(() => {
this.updateSyntaxHighlight();
this.updateCursor();
});
},
handleScroll() {
// 同步行号滚动
const codeEditor = this.$refs.codeEditor;
const lineNumbers = this.$refs.lineNumbers;
if (codeEditor && lineNumbers) {
lineNumbers.scrollTop = codeEditor.scrollTop;
}
},
handleFocus() {
this.focused = true;
},
handleBlur() {
this.focused = false;
},
handleKeydown(event) {
// Tab键处理
if (event.key === 'Tab') {
event.preventDefault();
this.insertText(' ');
}
},
handlePaste(event) {
event.preventDefault();
const text = (event.clipboardData || window.clipboardData).getData('text');
this.insertText(text);
},
insertText(text) {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(text));
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
// 触发input事件
this.handleDivInput({ target: this.$refs.codeEditor });
}
},
updateCursor() {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const beforeCursor = this.getTextBeforeCursor(range);
const lines = beforeCursor.split('\n');
this.currentLine = lines.length;
this.currentColumn = lines[lines.length - 1].length + 1;
}
},
initEditor() {
// 初始化编辑器内容
const codeEditor = this.$refs.codeEditor;
if (codeEditor && this.content) {
codeEditor.innerHTML = this.highlightedContent;
}
},
getPlainTextFromDiv(div) {
// 从contenteditable div中提取纯文本
return div.innerText || div.textContent || '';
},
getTextBeforeCursor(range) {
// 获取光标前的文本
const container = this.$refs.codeEditor;
if (!container) return '';
const tempRange = document.createRange();
tempRange.setStart(container, 0);
tempRange.setEnd(range.startContainer, range.startOffset);
return tempRange.toString();
},
updateSyntaxHighlight() {
const codeEditor = this.$refs.codeEditor;
if (!codeEditor) return;
// 保存当前光标位置
const selection = window.getSelection();
let savedRange = null;
let cursorOffset = 0;
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
cursorOffset = this.getTextBeforeCursor(range).length;
}
// 更新语法高亮内容
codeEditor.innerHTML = this.highlightedContent;
// 恢复光标位置
this.$nextTick(() => {
this.restoreCursorPosition(cursorOffset);
});
},
restoreCursorPosition(offset) {
const codeEditor = this.$refs.codeEditor;
if (!codeEditor) return;
const selection = window.getSelection();
const range = document.createRange();
try {
let currentOffset = 0;
const walker = document.createTreeWalker(
codeEditor,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= offset) {
range.setStart(node, offset - currentOffset);
range.setEnd(node, offset - currentOffset);
break;
}
currentOffset += nodeLength;
}
selection.removeAllRanges();
selection.addRange(range);
} catch (e) {
// 如果恢复失败,将光标放到末尾
range.selectNodeContents(codeEditor);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
},
formatCode() {
// 简单的代码格式化
if (this.language === 'env') {
const lines = this.content.split('\n');
const formatted = lines.map(line => {
line = line.trim();
if (line.includes('=') && !line.startsWith('#')) {
const [key, ...valueParts] = line.split('=');
const value = valueParts.join('=');
return `${key.trim()}=${value}`;
}
return line;
}).join('\n');
this.content = formatted;
this.handleInput();
}
},
toggleWrap() {
this.wordWrap = !this.wordWrap;
},
getFileName() {
const fileNames = {
'env': '.env',
'javascript': 'script.js',
'php': 'config.php'
};
return fileNames[this.language] || '.env';
},
// 根据文件名自动检测语言
detectLanguageFromFilename(filename) {
if (!filename) return 'env';
const ext = filename.toLowerCase();
// .env 文件检测
if (ext === '.env' || ext.startsWith('.env.') || ext.endsWith('.env')) {
return 'env';
}
// JavaScript 文件检测
if (ext.endsWith('.js') || ext.endsWith('.jsx') || ext.endsWith('.ts') || ext.endsWith('.tsx') || ext.endsWith('.mjs')) {
return 'javascript';
}
// PHP 文件检测
if (ext.endsWith('.php') || ext.endsWith('.phtml') || ext.endsWith('.php3') || ext.endsWith('.php4') || ext.endsWith('.php5')) {
return 'php';
}
// 默认返回 env
return 'env';
},
// 自动设置语言
autoDetectLanguage() {
const filename = this.getFileName();
const detectedLanguage = this.detectLanguageFromFilename(filename);
if (detectedLanguage !== this.language) {
this.$emit('language-change', detectedLanguage);
}
},
highlightEnvContent(content) {
return content
.split('\n')
.map(line => {
if (line.trim().startsWith('#')) {
// 注释
return `<span class="env-comment">${this.escapeHtml(line)}</span>`;
} else if (line.includes('=')) {
// 键值对
const equalIndex = line.indexOf('=');
const key = line.substring(0, equalIndex);
const value = line.substring(equalIndex + 1);
return `<span class="env-key">${this.escapeHtml(key)}</span><span class="env-operator">=</span><span class="env-value">${this.escapeHtml(value)}</span>`;
} else {
return this.escapeHtml(line);
}
})
.join('\n');
},
highlightJavaScriptContent(content) {
// 简单的JavaScript语法高亮
const keywords = ['function', 'const', 'let', 'var', 'if', 'else', 'for', 'while', 'return', 'class', 'import', 'export', 'default'];
const keywordRegex = new RegExp(`\\b(${keywords.join('|')})\\b`, 'g');
return content
.split('\n')
.map(line => {
let highlightedLine = this.escapeHtml(line);
// 高亮关键字
highlightedLine = highlightedLine.replace(keywordRegex, '<span class="js-keyword">$1</span>');
// 高亮字符串
highlightedLine = highlightedLine.replace(/(["'`])((?:\\.|(?!\1)[^\\])*?)\1/g, '<span class="js-string">$1$2$1</span>');
// 高亮注释
highlightedLine = highlightedLine.replace(/(\/\/.*$)/g, '<span class="js-comment">$1</span>');
return highlightedLine;
})
.join('\n');
},
highlightPhpContent(content) {
// 简单的PHP语法高亮
const keywords = ['function', 'class', 'if', 'else', 'elseif', 'while', 'for', 'foreach', 'return', 'public', 'private', 'protected', 'static'];
const keywordRegex = new RegExp(`\\b(${keywords.join('|')})\\b`, 'g');
return content
.split('\n')
.map(line => {
let highlightedLine = this.escapeHtml(line);
// 高亮PHP标签
highlightedLine = highlightedLine.replace(/(&lt;\?php|&lt;\?=|\?&gt;)/g, '<span class="php-tag">$1</span>');
// 高亮关键字
highlightedLine = highlightedLine.replace(keywordRegex, '<span class="php-keyword">$1</span>');
// 高亮变量
highlightedLine = highlightedLine.replace(/(\$\w+)/g, '<span class="php-variable">$1</span>');
// 高亮字符串
highlightedLine = highlightedLine.replace(/(["'])((?:\\.|(?!\1)[^\\])*?)\1/g, '<span class="php-string">$1$2$1</span>');
// 高亮注释
highlightedLine = highlightedLine.replace(/(\/\/.*$|\/\*.*?\*\/)/g, '<span class="php-comment">$1</span>');
return highlightedLine;
})
.join('\n');
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
focus() {
if (this.$refs.textarea) {
this.$refs.textarea.focus();
}
},
getContent() {
return this.content;
},
}
}
</script>
<style scoped>
.simple-code-editor {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Monaco', 'Inconsolata', 'Consolas', monospace;
background: #ffffff;
border-radius: 12px;
overflow: visible; /* 移除容器级别的滚动条限制 */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: 1px solid #e5e7eb;
}
/* 编辑器头部 */
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid #e2e8f0;
padding: 0;
min-height: 40px;
}
.editor-tabs {
display: flex;
align-items: center;
}
.tab {
display: flex;
align-items: center;
padding: 8px 16px;
background: #ffffff;
border-right: 1px solid #e2e8f0;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.tab.active {
background: #ffffff;
border-bottom: 2px solid #3b82f6;
}
.tab:hover {
background: #f8fafc;
}
.tab-icon {
margin-right: 8px;
color: #6b7280;
display: flex;
align-items: center;
}
.tab-name {
font-size: 13px;
font-weight: 500;
color: #374151;
margin-right: 8px;
}
.tab-close {
color: #9ca3af;
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 2px;
border-radius: 2px;
transition: all 0.2s ease;
}
.tab-close:hover {
background: #f3f4f6;
color: #374151;
}
.editor-controls {
display: flex;
align-items: center;
padding: 0 12px;
gap: 4px;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #6b7280;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.control-btn:hover {
background: #f3f4f6;
color: #374151;
}
/* 编辑器主体 */
.editor-body {
display: flex;
flex: 1;
min-height: 0;
background: #ffffff;
overflow: visible; /* 允许内容正常显示,不被截断 */
}
.editor-body.dark-theme {
background: #1e1e1e;
}
/* 行号区域 */
.line-numbers-area {
background: linear-gradient(180deg, #fafbfc 0%, #f4f6f8 100%);
border-right: 1px solid #e2e8f0;
padding: 16px 0;
min-width: 60px;
overflow: hidden; /* 行号区域保持隐藏溢出,避免水平滚动 */
user-select: none;
font-size: 13px;
line-height: 1.6;
color: #8b949e;
position: relative;
flex-shrink: 0; /* 防止行号区域被压缩 */
}
.dark-theme .line-numbers-area {
background: linear-gradient(180deg, #2d3748 0%, #1a202c 100%);
border-right-color: #4a5568;
color: #718096;
}
.line-number {
text-align: right;
padding: 0 12px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
transition: all 0.2s ease;
}
.line-number.active {
color: #3b82f6;
font-weight: 600;
background: rgba(59, 130, 246, 0.1);
}
.dark-theme .line-number.active {
color: #60a5fa;
background: rgba(96, 165, 250, 0.1);
}
/* 代码区域 */
.code-area {
flex: 1;
min-height: 0;
position: relative;
overflow: visible; /* 确保代码区域不被截断 */
display: flex;
flex-direction: column;
}
/* 可编辑的代码编辑器div - 单层方案,无重影 */
.code-editor-div {
width: 100%;
height: 100%; /* 改为100%高度以充分利用父容器空间 */
min-height: 200px; /* 设置最小高度确保可见 */
padding: 16px 20px;
margin: 0;
font-size: 14px;
line-height: 1.6;
font-family: inherit;
color: #24292f;
background: transparent;
border: none;
outline: none;
overflow: auto; /* 确保编辑器内部滚动条正常工作 */
white-space: pre;
caret-color: #3b82f6;
font-variant-ligatures: common-ligatures;
/* 确保可编辑性 */
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
/* 优化渲染性能 */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
/* 防止内容溢出 */
word-wrap: break-word;
overflow-wrap: break-word;
/* 确保编辑器能够自适应父容器 */
box-sizing: border-box;
}
.word-wrap .code-editor-div {
white-space: pre-wrap;
word-wrap: break-word;
}
/* 确保编辑器在不同容器中都能正确显示 */
.code-editor-div:focus {
outline: none;
}
/* 自适应高度的额外样式 */
.simple-code-editor {
max-height: 100vh; /* 防止超出视口高度 */
}
.editor-body {
min-height: 300px; /* 确保编辑器有足够的最小高度 */
}
.dark-theme .code-editor-div {
color: #f0f6fc;
caret-color: #60a5fa;
}
/* 编辑器底部状态栏 */
.editor-footer {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-top: 1px solid #e2e8f0;
padding: 6px 16px;
font-size: 12px;
color: #6b7280;
min-height: 32px;
}
.dark-theme .editor-footer {
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
border-top-color: #4a5568;
color: #a0aec0;
}
.status-left,
.status-right {
display: flex;
align-items: center;
gap: 16px;
}
.status-item {
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.status-success {
color: #059669;
}
.dark-theme .status-success {
color: #68d391;
}
/* 语法高亮样式 */
.code-editor-div :deep(.env-comment) {
color: #6a737d;
font-style: italic;
}
.code-editor-div :deep(.env-key) {
color: #005cc5;
font-weight: 600;
}
.code-editor-div :deep(.env-operator) {
color: #d73a49;
font-weight: 600;
}
.code-editor-div :deep(.env-value) {
color: #032f62;
}
/* 深色主题语法高亮 */
.dark-theme .code-editor-div :deep(.env-comment) {
color: #8b949e;
}
.dark-theme .code-editor-div :deep(.env-key) {
color: #79c0ff;
font-weight: 600;
}
.dark-theme .code-editor-div :deep(.env-operator) {
color: #ff7b72;
font-weight: 600;
}
.dark-theme .code-editor-div :deep(.env-value) {
color: #a5d6ff;
}
/* JavaScript 语法高亮样式 */
.code-editor-div :deep(.js-keyword) {
color: #d73a49;
font-weight: 600;
}
.code-editor-div :deep(.js-string) {
color: #032f62;
}
.code-editor-div :deep(.js-comment) {
color: #6a737d;
font-style: italic;
}
/* PHP 语法高亮样式 */
.code-editor-div :deep(.php-tag) {
color: #d73a49;
font-weight: 600;
}
.code-editor-div :deep(.php-keyword) {
color: #6f42c1;
font-weight: 600;
}
.code-editor-div :deep(.php-variable) {
color: #e36209;
}
.code-editor-div :deep(.php-string) {
color: #032f62;
}
.code-editor-div :deep(.php-comment) {
color: #6a737d;
font-style: italic;
}
/* 深色主题 JavaScript */
.dark-theme .code-editor-div :deep(.js-keyword) {
color: #ff7b72;
font-weight: 600;
}
.dark-theme .code-editor-div :deep(.js-string) {
color: #a5d6ff;
}
.dark-theme .code-editor-div :deep(.js-comment) {
color: #8b949e;
font-style: italic;
}
/* 深色主题 PHP */
.dark-theme .code-editor-div :deep(.php-tag) {
color: #ff7b72;
font-weight: 600;
}
.dark-theme .code-editor-div :deep(.php-keyword) {
color: #d2a8ff;
font-weight: 600;
}
.dark-theme .code-editor-div :deep(.php-variable) {
color: #ffa657;
}
.dark-theme .code-editor-div :deep(.php-string) {
color: #a5d6ff;
}
.dark-theme .code-editor-div :deep(.php-comment) {
color: #8b949e;
font-style: italic;
}
/* 滚动条样式 - 只为代码编辑器设置滚动条 */
.code-editor-div::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.code-editor-div::-webkit-scrollbar-track {
background: #f6f8fa;
border-radius: 6px;
}
.code-editor-div::-webkit-scrollbar-thumb {
background: #d0d7de;
border-radius: 6px;
border: 2px solid #f6f8fa;
}
.code-editor-div::-webkit-scrollbar-thumb:hover {
background: #a8b3c1;
}
/* 深色主题滚动条样式 */
.dark-theme .code-editor-div::-webkit-scrollbar-track {
background: #21262d;
}
.dark-theme .code-editor-div::-webkit-scrollbar-thumb {
background: #484f58;
border-color: #21262d;
}
.dark-theme .code-editor-div::-webkit-scrollbar-thumb:hover {
background: #6e7681;
}
/* 选择文本样式 */
.code-editor-div::selection {
background: rgba(59, 130, 246, 0.2);
}
.dark-theme .code-editor-div::selection {
background: rgba(96, 165, 250, 0.3);
}
/* placeholder样式 */
.code-editor-div:empty:before {
content: attr(data-placeholder);
color: #8b949e;
font-style: italic;
pointer-events: none;
}
.dark-theme .code-editor-div:empty:before {
color: #6e7681;
}
/* 响应式设计 */
@media (max-width: 768px) {
.editor-header {
padding: 0 8px;
}
.tab {
padding: 6px 12px;
}
.tab-name {
font-size: 12px;
}
.line-numbers-area {
min-width: 50px;
}
.line-number {
padding: 0 8px;
}
.code-editor-div {
padding: 12px 16px;
font-size: 13px;
}
.status-left,
.status-right {
gap: 12px;
}
}
</style>

View File

@@ -9,6 +9,36 @@
ref="envManagement"
/>
<!-- 生成周报页面 -->
<weekly-report
v-else-if="currentPage === 'weekly-report'"
ref="weeklyReport"
/>
<!-- JIRA 工时查询页面 -->
<jira-worklog
v-else-if="currentPage === 'worklog'"
ref="jiraWorklog"
/>
<!-- 消息同步页面 -->
<message-sync
v-else-if="currentPage === 'message-sync'"
ref="messageSync"
/>
<!-- 事件消费者同步页面 -->
<event-consumer-sync
v-else-if="currentPage === 'event-consumer-sync'"
ref="eventConsumerSync"
/>
<!-- 消息分发异常查询页面 -->
<message-dispatch
v-else-if="currentPage === 'message-dispatch'"
ref="messageDispatch"
/>
<!-- 系统设置页面 -->
<div v-else-if="currentPage === 'settings'" class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<div class="text-center py-12">
@@ -32,18 +62,27 @@
</div>
</div>
</admin-layout>
</template>
<script>
import AdminLayout from './AdminLayout.vue';
import EnvManagement from './EnvManagement.vue';
import EnvManagement from '../env/EnvManagement.vue';
import WeeklyReport from '../jira/WeeklyReport.vue';
import JiraWorklog from '../jira/JiraWorklog.vue';
import MessageSync from '../message-sync/MessageSync.vue';
import EventConsumerSync from '../message-sync/EventConsumerSync.vue';
import MessageDispatch from '../message-sync/MessageDispatch.vue';
export default {
name: 'EnvManager',
name: 'AdminDashboard',
components: {
AdminLayout,
EnvManagement
EnvManagement,
WeeklyReport,
JiraWorklog,
MessageSync,
EventConsumerSync,
MessageDispatch
},
data() {
return {
@@ -52,7 +91,10 @@ export default {
}
},
mounted() {
console.log('EnvManager mounted');
console.log('AdminDashboard mounted');
// URL
this.setCurrentPageFromPath();
},
methods: {
handleMenuChange(menu) {
@@ -61,6 +103,11 @@ export default {
//
const titles = {
'env': '环境配置管理',
'weekly-report': '生成周报',
'worklog': 'JIRA 工时查询',
'message-sync': '消息同步',
'event-consumer-sync': '事件消费者同步对比',
'message-dispatch': '消息分发异常查询',
'settings': '系统设置',
'logs': '操作日志'
};
@@ -68,9 +115,31 @@ export default {
this.pageTitle = titles[menu] || '环境配置管理';
},
setCurrentPageFromPath() {
const path = window.location.pathname;
let page = 'env'; //
if (path === '/') {
page = 'env';
} else if (path === '/weekly-report') {
page = 'weekly-report';
} else if (path === '/worklog') {
page = 'worklog';
} else if (path === '/message-sync') {
page = 'message-sync';
} else if (path === '/event-consumer-sync') {
page = 'event-consumer-sync';
} else if (path === '/message-dispatch') {
page = 'message-dispatch';
} else if (path === '/settings') {
page = 'settings';
} else if (path === '/logs') {
page = 'logs';
}
this.currentPage = page;
this.handleMenuChange(page);
}
}
}
</script>

View File

@@ -0,0 +1,322 @@
<template>
<div class="admin-layout h-screen bg-gray-50">
<!-- 侧边栏 -->
<div class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg border-r border-gray-200 flex flex-col">
<!-- Logo区域 -->
<div class="flex items-center justify-center h-16 px-6 bg-gradient-to-r from-blue-600 to-blue-700 border-b border-blue-800 flex-shrink-0">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z"/>
</svg>
</div>
<div class="text-white">
<div class="text-lg font-bold">Tradewind Toolbox</div>
<div class="text-xs text-blue-100">Development Tools</div>
</div>
</div>
</div>
<!-- 导航菜单 -->
<nav class="mt-6 px-3 flex flex-col flex-1">
<div class="space-y-1 flex-1">
<a
href="#"
@click.prevent="setActiveMenu('env')"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'env'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'env' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
环境配置管理
</a>
<!-- JIRA 相关菜单项 -->
<a
href="#"
@click.prevent="setActiveMenu('weekly-report')"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'weekly-report'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'weekly-report' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
生成周报
</a>
<a
href="#"
@click.prevent="setActiveMenu('worklog')"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'worklog'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'worklog' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
JIRA 工时查询
</a>
<a
href="#"
@click.prevent="setActiveMenu('message-sync')"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'message-sync'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'message-sync' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
消息同步
</a>
<a
href="#"
@click.prevent="setActiveMenu('event-consumer-sync')"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'event-consumer-sync'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'event-consumer-sync' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
事件消费者同步
</a>
<a
href="#"
@click.prevent="setActiveMenu('message-dispatch')"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'message-dispatch'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'message-dispatch' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
消息分发异常
</a>
<a
href="#"
@click.prevent="setActiveMenu('settings')"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'settings'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'settings' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
系统设置
</a>
<a
href="#"
@click.prevent="setActiveMenu('logs')"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'logs'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'logs' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
操作日志
</a>
</div>
<!-- 底部信息 -->
<div class="mt-auto pt-6 border-t border-gray-200">
<div class="px-3 py-2">
<div class="text-xs text-gray-500 mb-1">系统信息</div>
<div class="text-xs text-gray-400">版本 v1.0.0</div>
<div class="text-xs text-gray-400">Laravel 12 + Vue 3</div>
</div>
</div>
</nav>
</div>
<!-- 主内容区域 -->
<div class="pl-64 h-screen">
<!-- 页面内容 -->
<main class="h-full">
<slot></slot>
</main>
</div>
</div>
</template>
<script>
export default {
name: 'AdminLayout',
props: {
pageTitle: {
type: String,
default: '环境配置管理'
}
},
data() {
return {
activeMenu: 'env'
}
},
mounted() {
// 根据 URL 路径设置初始菜单
this.setActiveMenuFromPath();
// 监听浏览器前进后退按钮
window.addEventListener('popstate', this.setActiveMenuFromPath);
},
beforeUnmount() {
window.removeEventListener('popstate', this.setActiveMenuFromPath);
},
methods: {
setActiveMenu(menu) {
this.activeMenu = menu;
// 更新 URL 路径
const path = menu === 'env' ? '/' : `/${menu}`;
window.history.pushState({}, '', path);
this.$emit('menu-change', menu);
},
setActiveMenuFromPath() {
const path = window.location.pathname;
let menu = 'env'; // 默认页面
if (path === '/') {
menu = 'env';
} else if (path === '/weekly-report') {
menu = 'weekly-report';
} else if (path === '/worklog') {
menu = 'worklog';
} else if (path === '/message-sync') {
menu = 'message-sync';
} else if (path === '/event-consumer-sync') {
menu = 'event-consumer-sync';
} else if (path === '/message-dispatch') {
menu = 'message-dispatch';
} else if (path === '/settings') {
menu = 'settings';
} else if (path === '/logs') {
menu = 'logs';
}
this.activeMenu = menu;
this.$emit('menu-change', menu);
}
}
}
</script>
<style scoped>
.admin-layout {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.admin-layout .pl-64 {
padding-left: 0;
}
.admin-layout .fixed.w-64 {
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
}
.admin-layout .fixed.w-64.open {
transform: translateX(0);
}
}
</style>

View File

@@ -0,0 +1,289 @@
<template>
<div class="codemirror-editor">
<div ref="editorContainer" class="editor-container"></div>
</div>
</template>
<script>
import { EditorView, keymap, highlightActiveLine, highlightActiveLineGutter, lineNumbers, scrollPastEnd } from '@codemirror/view'
import { EditorState, Compartment } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'
import { foldGutter, indentOnInput, indentUnit, bracketMatching } from '@codemirror/language'
import { highlightSelectionMatches as highlightSelection } from '@codemirror/search'
import { javascript } from '@codemirror/lang-javascript'
import { php } from '@codemirror/lang-php'
import { oneDark } from '@codemirror/theme-one-dark'
import { env } from '../../lang-env.js'
export default {
name: 'CodeEditor',
props: {
modelValue: {
type: String,
default: ''
},
language: {
type: String,
default: 'env' // 'env', 'javascript', 'php'
},
theme: {
type: String,
default: 'light' // 'light', 'dark'
},
readonly: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: ''
}
},
emits: ['update:modelValue', 'change'],
data() {
return {
editor: null,
languageCompartment: new Compartment(),
themeCompartment: new Compartment()
}
},
mounted() {
this.initEditor();
},
watch: {
modelValue(newValue) {
if (this.editor && newValue !== this.editor.state.doc.toString()) {
this.editor.dispatch({
changes: {
from: 0,
to: this.editor.state.doc.length,
insert: newValue || ''
}
});
}
},
language() {
this.updateLanguage();
},
theme() {
this.updateTheme();
}
},
beforeUnmount() {
if (this.editor) {
this.editor.destroy();
}
},
methods: {
getLanguageExtension() {
switch (this.language) {
case 'javascript':
return javascript();
case 'php':
return php();
case 'env':
return env();
default:
return [];
}
},
getThemeExtension() {
return this.theme === 'dark' ? oneDark : [];
},
initEditor() {
const extensions = [
lineNumbers(),
highlightActiveLineGutter(),
highlightActiveLine(),
foldGutter(),
indentOnInput(),
indentUnit.of(' '),
bracketMatching(),
closeBrackets(),
autocompletion(),
highlightSelectionMatches(),
history(),
scrollPastEnd(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...completionKeymap,
]),
this.languageCompartment.of(this.getLanguageExtension()),
this.themeCompartment.of(this.getThemeExtension()),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const content = update.state.doc.toString();
this.$emit('update:modelValue', content);
this.$emit('change', content);
}
}),
// 确保编辑器可以滚动
EditorView.theme({
'&': {
height: '100%'
},
'.cm-scroller': {
fontFamily: 'inherit',
overflow: 'auto'
},
'.cm-content': {
padding: '12px',
minHeight: '100%'
},
'.cm-editor': {
height: '100%'
},
'.cm-focused': {
outline: 'none'
}
})
];
// 添加只读模式
if (this.readonly) {
extensions.push(EditorView.editable.of(false));
}
const state = EditorState.create({
doc: this.modelValue || '',
extensions
});
this.editor = new EditorView({
state,
parent: this.$refs.editorContainer
});
},
updateLanguage() {
if (!this.editor) return;
this.editor.dispatch({
effects: this.languageCompartment.reconfigure(this.getLanguageExtension())
});
},
updateTheme() {
if (!this.editor) return;
this.editor.dispatch({
effects: this.themeCompartment.reconfigure(this.getThemeExtension())
});
}
}
}
</script>
<style scoped>
.codemirror-editor {
width: 100%;
height: 100%;
border-radius: 8px;
border: 1px solid #e5e7eb;
background: #ffffff;
display: flex;
flex-direction: column;
}
.editor-container {
width: 100%;
height: 100%;
flex: 1;
min-height: 0;
}
/* CodeMirror 自定义样式 */
.codemirror-editor :deep(.cm-editor) {
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Monaco', 'Inconsolata', 'Consolas', monospace;
font-size: 14px;
line-height: 1.6;
height: 100%;
}
.codemirror-editor :deep(.cm-scroller) {
overflow: auto !important;
max-height: 100%;
}
/* 基本语法高亮样式 */
.codemirror-editor :deep(.cm-variableName) {
color: #0969da;
font-weight: 600;
}
.codemirror-editor :deep(.cm-string) {
color: #0a3069;
}
.codemirror-editor :deep(.cm-number) {
color: #0550ae;
}
.codemirror-editor :deep(.cm-keyword) {
color: #8250df;
font-weight: 500;
}
.codemirror-editor :deep(.cm-operator) {
color: #cf222e;
}
.codemirror-editor :deep(.cm-comment) {
color: #6a737d;
font-style: italic;
}
.codemirror-editor :deep(.cm-content) {
padding: 12px;
min-height: 100%;
}
.codemirror-editor :deep(.cm-focused) {
outline: none;
}
/* 自定义滚动条样式 */
.codemirror-editor :deep(.cm-scroller)::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.codemirror-editor :deep(.cm-scroller)::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.codemirror-editor :deep(.cm-scroller)::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.codemirror-editor :deep(.cm-scroller)::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 深色主题样式 */
.codemirror-editor.dark :deep(.cm-editor) {
background: #1e1e1e;
color: #d4d4d4;
}
.codemirror-editor.dark :deep(.cm-scroller)::-webkit-scrollbar-track {
background: #2d2d2d;
}
.codemirror-editor.dark :deep(.cm-scroller)::-webkit-scrollbar-thumb {
background: #555;
}
.codemirror-editor.dark :deep(.cm-scroller)::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>

View File

@@ -9,8 +9,13 @@
</svg>
项目选择
</h2>
<div class="text-xs text-gray-500">
{{ projects.length }} 个项目
<div class="text-right text-xs text-gray-500 space-y-0.5">
<div> {{ projects.length }} 个项目</div>
<div v-if="selectedProject">
当前 APP_ENV:
<span v-if="currentAppEnv">{{ currentAppEnv }}</span>
<span v-else class="text-gray-400">未检测到</span>
</div>
</div>
</div>
@@ -143,8 +148,19 @@
<div class="flex items-center justify-between">
<div class="flex-1 cursor-pointer" @click="loadEnvContent(env.name)">
<div class="flex items-center space-x-2">
<div class="w-2.5 h-2.5 bg-green-400 rounded-full"></div>
<h4 class="font-medium text-gray-900 hover:text-blue-600 transition-colors duration-200 text-sm">{{ env.name }}</h4>
<div
class="w-2.5 h-2.5 rounded-full"
:class="env.name === currentAppEnv ? 'bg-blue-500' : 'bg-green-400'"
></div>
<h4 class="font-medium text-gray-900 hover:text-blue-600 transition-colors duration-200 text-sm">
{{ env.name }}
<span
v-if="env.name === currentAppEnv"
class="ml-1 px-1.5 py-0.5 text-[10px] rounded-full bg-blue-100 text-blue-700 align-middle"
>
当前
</span>
</h4>
</div>
<div class="text-xs text-gray-500 mt-0.5 space-x-3 ml-4.5">
<span>{{ formatFileSize(env.size) }}</span>
@@ -268,7 +284,7 @@
</div>
<!-- 新增配置文件模态框 -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div v-if="showCreateModal" class="fixed inset-0 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-md">
<div class="p-6 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900 flex items-center">
@@ -313,7 +329,7 @@
</div>
<!-- 导入模态框 -->
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div v-if="showImportModal" class="fixed inset-0 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-md">
<div class="p-6 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900 flex items-center">
@@ -403,6 +419,7 @@ export default {
importEnvName: '',
editorLanguage: 'env',
editorTheme: 'light',
currentAppEnv: '',
message: {
text: '',
type: ''
@@ -457,7 +474,7 @@ export default {
methods: {
async loadProjects() {
try {
const response = await fetch('/env/api/projects');
const response = await fetch('/api/env/projects');
const data = await response.json();
if (data.success) {
@@ -477,11 +494,12 @@ export default {
}
try {
const response = await fetch(`/env/api/projects/${this.selectedProject}/envs`);
const response = await fetch(`/api/env/projects/${this.selectedProject}/envs`);
const data = await response.json();
if (data.success) {
this.envs = data.data;
this.envs = Array.isArray(data.data) ? data.data : [];
this.tryAutoSelectCurrentEnv();
} else {
this.showMessage('加载环境列表失败', 'error');
}
@@ -490,12 +508,51 @@ export default {
}
},
async loadCurrentProjectEnv() {
if (!this.selectedProject) {
this.currentAppEnv = '';
return;
}
try {
const response = await fetch(`/api/env/projects/${this.selectedProject}/current-env`);
const data = await response.json();
if (!data.success || !data.data || typeof data.data.content !== 'string') {
this.currentAppEnv = '';
return;
}
const content = data.data.content;
const match = content.match(/^\s*APP_ENV\s*=\s*([^\r\n#]+)/m);
this.currentAppEnv = match ? match[1].trim() : '';
this.tryAutoSelectCurrentEnv();
} catch (error) {
this.currentAppEnv = '';
}
},
tryAutoSelectCurrentEnv() {
if (!this.currentAppEnv || !Array.isArray(this.envs) || this.envs.length === 0) {
return;
}
if (this.selectedEnv === this.currentAppEnv) {
return;
}
const matched = this.envs.find(env => env.name === this.currentAppEnv);
if (matched) {
this.loadEnvContent(matched.name);
}
},
async loadEnvContent(envName) {
this.selectedEnv = envName;
this.newEnvName = envName;
try {
const response = await fetch(`/env/api/projects/${this.selectedProject}/envs/${envName}`);
const response = await fetch(`/api/env/projects/${this.selectedProject}/envs/${envName}`);
const data = await response.json();
if (data.success) {
@@ -521,7 +578,7 @@ export default {
}
try {
const response = await fetch('/env/api/envs/save', {
const response = await fetch('/api/env/envs/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -555,7 +612,7 @@ export default {
}
try {
const response = await fetch('/env/api/envs/apply', {
const response = await fetch('/api/env/envs/apply', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -568,9 +625,11 @@ export default {
});
const data = await response.json();
if (data.success) {
this.showMessage('环境配置应用成功', 'success');
await this.loadCurrentProjectEnv();
await this.loadEnvs();
} else {
this.showMessage(data.message || '应用失败', 'error');
}
@@ -585,7 +644,7 @@ export default {
}
try {
const response = await fetch(`/env/api/projects/${this.selectedProject}/envs/${envName}`, {
const response = await fetch(`/api/env/projects/${this.selectedProject}/envs/${envName}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
@@ -616,7 +675,7 @@ export default {
}
try {
const response = await fetch('/env/api/envs/import', {
const response = await fetch('/api/env/envs/import', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -650,7 +709,7 @@ export default {
}
try {
const response = await fetch('/env/api/envs/create', {
const response = await fetch('/api/env/envs/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -704,7 +763,12 @@ export default {
this.projectSearchQuery = project.name;
this.showProjectDropdown = false;
this.highlightedIndex = -1;
this.selectedEnv = '';
this.envContent = '';
this.newEnvName = '';
this.currentAppEnv = '';
this.loadEnvs();
this.loadCurrentProjectEnv();
},
handleProjectFocus() {

View File

@@ -0,0 +1,503 @@
<template>
<div class="p-6">
<!-- 页面标题 -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">JIRA 工时查询</h1>
<p class="text-gray-600 mt-2">查询指定时间范围内的工时记录</p>
</div>
<!-- 工时查询区域 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">工时记录查询</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">用户名</label>
<input
type="text"
v-model="workLogs.username"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="输入 JIRA 用户名"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">开始日期</label>
<input
type="date"
v-model="workLogs.startDate"
:disabled="workLogs.activeQuickSelect"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">结束日期</label>
<input
type="date"
v-model="workLogs.endDate"
:disabled="workLogs.activeQuickSelect"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
>
</div>
<div class="flex items-end">
<button
@click="getWorkLogs"
:disabled="workLogs.loading"
class="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="workLogs.loading">查询中...</span>
<span v-else>查询工时</span>
</button>
</div>
</div>
<!-- 快速查询选项 -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-3">快速查询</label>
<div class="flex flex-wrap gap-2">
<button
@click="setQuickDateRange('lastWeek')"
:class="[
'px-4 py-2 text-sm font-medium rounded-md transition-colors',
workLogs.activeQuickSelect === 'lastWeek'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
]"
>
查询上周数据
</button>
<button
@click="setQuickDateRange('yesterday')"
:class="[
'px-4 py-2 text-sm font-medium rounded-md transition-colors',
workLogs.activeQuickSelect === 'yesterday'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
]"
>
查询昨天数据
</button>
<button
@click="setQuickDateRange('today')"
:class="[
'px-4 py-2 text-sm font-medium rounded-md transition-colors',
workLogs.activeQuickSelect === 'today'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
]"
>
查询今天数据
</button>
<button
@click="clearQuickSelect()"
v-if="workLogs.activeQuickSelect"
class="px-4 py-2 text-sm font-medium rounded-md bg-gray-300 text-gray-700 hover:bg-gray-400 transition-colors"
>
清除
</button>
</div>
</div>
<!-- 工时统计 -->
<div v-if="workLogs.data" class="mb-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-blue-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-blue-800">总工时</h3>
<p class="text-2xl font-bold text-blue-600">{{ workLogs.data.total_hours }} 小时</p>
</div>
<div class="bg-green-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-green-800">记录数量</h3>
<p class="text-2xl font-bold text-green-600">{{ workLogs.data.total_records }} </p>
</div>
<div class="bg-purple-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-purple-800">查询范围</h3>
<p class="text-sm text-purple-600">{{ workLogs.data.date_range.start }} {{ workLogs.data.date_range.end }}</p>
</div>
</div>
<!-- 工时记录表格 -->
<div v-if="workLogs.data && workLogs.data.work_logs && workLogs.data.work_logs.length > 0" class="w-full">
<table class="w-full bg-white border border-gray-200 table-fixed">
<thead class="bg-gray-50">
<tr>
<th @click="sortBy('project')" class="w-16 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
项目
<span v-if="sortField === 'project'" class="ml-1">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</th>
<th @click="sortBy('issue_key')" class="w-1/4 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
任务
<span v-if="sortField === 'issue_key'" class="ml-1">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</th>
<th @click="sortBy('parent_task')" class="w-1/4 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
父任务
<span v-if="sortField === 'parent_task'" class="ml-1">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</th>
<th @click="sortBy('date')" class="w-20 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
日期时间
<span v-if="sortField === 'date'" class="ml-1">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</th>
<th @click="sortBy('hours')" class="w-16 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
工时
<span v-if="sortField === 'hours'" class="ml-1">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</th>
<th @click="sortBy('comment')" class="w-1/6 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
备注
<span v-if="sortField === 'comment'" class="ml-1">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</th>
<th class="w-16 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="log in sortedWorkLogs" :key="log.id">
<td class="px-3 py-4 text-sm text-gray-900 break-words">{{ log.project_key }}</td>
<td class="px-3 py-4 text-sm break-words">
<a :href="log.issue_url" target="_blank" class="text-blue-600 hover:text-blue-800">
{{ log.issue_key }} {{ log.issue_summary }}
</a>
</td>
<td class="px-3 py-4 text-sm break-words">
<a v-if="log.parent_task" :href="getJiraUrl(log.parent_task.key)" target="_blank" class="text-blue-600 hover:text-blue-800">
{{ log.parent_task.key }} {{ log.parent_task.summary }}
</a>
<span v-else class="text-gray-400">-</span>
</td>
<td class="px-3 py-4 text-sm text-gray-900 break-words">{{ log.date }}<br>{{ log.time }}</td>
<td class="px-3 py-4 text-sm text-gray-900">{{ log.hours }}h</td>
<td class="px-3 py-4 text-sm text-gray-900 break-words">{{ log.comment }}</td>
<td class="px-3 py-4 text-sm text-gray-900">
<button
@click="showWorklogDetail(log)"
class="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
详情
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 无数据提示 -->
<div v-else-if="workLogs.data && workLogs.data.work_logs && workLogs.data.work_logs.length === 0" class="text-center py-8">
<p class="text-gray-500">指定日期范围内没有找到工时记录</p>
</div>
<!-- 错误信息 -->
<div v-if="workLogs.error" class="mt-4 p-4 bg-red-50 border border-red-200 rounded-md">
<p class="text-red-700">{{ workLogs.error }}</p>
</div>
</div>
<!-- 工时详情模态框 -->
<div v-if="showDetailModal" class="fixed inset-0 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">工时详情</h3>
<button @click="closeDetailModal" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div v-if="selectedWorklog" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">工时ID</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.id }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">项目</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.project }} ({{ selectedWorklog.project_key }})</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">任务</label>
<p class="mt-1 text-sm text-gray-900">
<a :href="selectedWorklog.issue_url" target="_blank" class="text-blue-600 hover:text-blue-800">
{{ selectedWorklog.issue_key }}: {{ selectedWorklog.issue_summary }}
</a>
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">父任务</label>
<p class="mt-1 text-sm text-gray-900">
<a v-if="selectedWorklog.parent_task" :href="getJiraUrl(selectedWorklog.parent_task.key)" target="_blank" class="text-blue-600 hover:text-blue-800">
{{ selectedWorklog.parent_task.key }} {{ selectedWorklog.parent_task.summary }}
</a>
<span v-else class="text-gray-400">无父任务</span>
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">工时</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.hours }}h ({{ selectedWorklog.time_spent }})</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">开始时间</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.started }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">记录者</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.author.display_name }} ({{ selectedWorklog.author.name }})</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">创建时间</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.created }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">更新时间</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.updated }}</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">备注</label>
<p class="mt-1 text-sm text-gray-900 bg-gray-50 p-3 rounded-md">
{{ selectedWorklog.comment || '无备注' }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'JiraWorklog',
data() {
return {
workLogs: {
username: '',
startDate: '',
endDate: '',
activeQuickSelect: null, // 'lastWeek', 'yesterday', 'today'
loading: false,
data: null,
error: ''
},
showDetailModal: false,
selectedWorklog: null,
sortField: 'date',
sortDirection: 'desc'
}
},
computed: {
sortedWorkLogs() {
if (!this.workLogs.data || !this.workLogs.data.work_logs) {
return [];
}
const logs = [...this.workLogs.data.work_logs];
return logs.sort((a, b) => {
let aValue = a[this.sortField];
let bValue = b[this.sortField];
// 特殊处理父任务排序
if (this.sortField === 'parent_task') {
aValue = a.parent_task ? a.parent_task.key : '';
bValue = b.parent_task ? b.parent_task.key : '';
}
// 处理空值
if (!aValue && !bValue) return 0;
if (!aValue) return this.sortDirection === 'asc' ? -1 : 1;
if (!bValue) return this.sortDirection === 'asc' ? 1 : -1;
// 数字类型排序
if (this.sortField === 'hours') {
aValue = parseFloat(aValue) || 0;
bValue = parseFloat(bValue) || 0;
}
// 字符串排序
if (typeof aValue === 'string' && typeof bValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (aValue < bValue) {
return this.sortDirection === 'asc' ? -1 : 1;
}
if (aValue > bValue) {
return this.sortDirection === 'asc' ? 1 : -1;
}
return 0;
});
}
},
async mounted() {
// 获取默认用户名
await this.loadDefaultUser();
// 设置默认日期范围:本周一到今天
this.setDefaultDateRange();
},
methods: {
async loadDefaultUser() {
try {
const response = await fetch('/api/jira/config');
const data = await response.json();
if (data.success && data.data.default_user) {
this.workLogs.username = data.data.default_user;
}
} catch (error) {
console.error('获取默认用户失败:', error);
}
},
setDefaultDateRange() {
const today = new Date();
const currentDay = today.getDay(); // 0 = 周日, 1 = 周一, ...
const mondayOffset = currentDay === 0 ? -6 : 1 - currentDay; // 计算到周一的偏移
const monday = new Date(today);
monday.setDate(today.getDate() + mondayOffset);
this.workLogs.startDate = monday.toISOString().split('T')[0];
this.workLogs.endDate = today.toISOString().split('T')[0];
},
setLastWeekDateRange() {
const today = new Date();
const currentDay = today.getDay();
const lastMondayOffset = currentDay === 0 ? -13 : -6 - currentDay; // 上周一的偏移
const lastSundayOffset = currentDay === 0 ? -7 : -currentDay; // 上周日的偏移
const lastMonday = new Date(today);
lastMonday.setDate(today.getDate() + lastMondayOffset);
const lastSunday = new Date(today);
lastSunday.setDate(today.getDate() + lastSundayOffset);
this.workLogs.startDate = lastMonday.toISOString().split('T')[0];
this.workLogs.endDate = lastSunday.toISOString().split('T')[0];
},
setYesterdayDateRange() {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dateStr = yesterday.toISOString().split('T')[0];
this.workLogs.startDate = dateStr;
this.workLogs.endDate = dateStr;
},
setTodayDateRange() {
const today = new Date();
const dateStr = today.toISOString().split('T')[0];
this.workLogs.startDate = dateStr;
this.workLogs.endDate = dateStr;
},
setQuickDateRange(type) {
this.workLogs.activeQuickSelect = type;
switch (type) {
case 'lastWeek':
this.setLastWeekDateRange();
break;
case 'yesterday':
this.setYesterdayDateRange();
break;
case 'today':
this.setTodayDateRange();
break;
}
},
clearQuickSelect() {
this.workLogs.activeQuickSelect = null;
this.setDefaultDateRange();
},
sortBy(field) {
if (this.sortField === field) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortField = field;
this.sortDirection = 'asc';
}
},
async getWorkLogs() {
if (!this.workLogs.username.trim()) {
this.workLogs.error = '请输入用户名';
return;
}
if (!this.workLogs.startDate || !this.workLogs.endDate) {
this.workLogs.error = '请选择日期范围';
return;
}
this.workLogs.loading = true;
this.workLogs.error = '';
this.workLogs.data = null;
try {
const response = await fetch('/api/jira/work-logs', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
username: this.workLogs.username,
start_date: this.workLogs.startDate,
end_date: this.workLogs.endDate
})
});
const data = await response.json();
if (data.success) {
this.workLogs.data = data.data;
} else {
this.workLogs.error = data.message;
}
} catch (error) {
this.workLogs.error = '网络请求失败: ' + error.message;
} finally {
this.workLogs.loading = false;
}
},
showWorklogDetail(worklog) {
this.selectedWorklog = worklog;
this.showDetailModal = true;
},
closeDetailModal() {
this.showDetailModal = false;
this.selectedWorklog = null;
},
getJiraUrl(issueKey) {
// 从现有的issue_url中提取host部分或者使用默认的JIRA host
if (this.workLogs.data && this.workLogs.data.work_logs && this.workLogs.data.work_logs.length > 0) {
const firstLog = this.workLogs.data.work_logs[0];
const baseUrl = firstLog.issue_url.replace(/\/browse\/.*$/, '');
return `${baseUrl}/browse/${issueKey}`;
}
// 如果没有数据,使用默认配置
return `http://jira.eainc.com:8080/browse/${issueKey}`;
}
}
}
</script>

View File

@@ -0,0 +1,169 @@
<template>
<div class="p-6">
<!-- 页面标题 -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">生成周报</h1>
<p class="text-gray-600 mt-2">生成上周的工作周报</p>
</div>
<!-- 周报生成区域 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">生成上周周报</h2>
<div class="flex flex-wrap gap-4 mb-4">
<div class="flex-1 min-w-64">
<label class="block text-sm font-medium text-gray-700 mb-2">用户名</label>
<input
type="text"
v-model="weeklyReport.username"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="输入 JIRA 用户名"
>
</div>
<div class="flex items-end">
<button
@click="generateWeeklyReport"
:disabled="weeklyReport.loading"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="weeklyReport.loading">生成中...</span>
<span v-else>生成周报</span>
</button>
</div>
</div>
<!-- 周报结果 -->
<div v-if="weeklyReport.result" class="mt-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-700">周报内容</h3>
<div class="flex gap-2">
<button
@click="copyMarkdown"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
复制 Markdown
</button>
<button
@click="downloadWeeklyReport"
class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
>
下载 Markdown
</button>
</div>
</div>
<div class="bg-gray-50 p-4 rounded-md">
<pre class="whitespace-pre-wrap text-sm">{{ weeklyReport.result }}</pre>
</div>
</div>
<!-- 错误信息 -->
<div v-if="weeklyReport.error" class="mt-4 p-4 bg-red-50 border border-red-200 rounded-md">
<p class="text-red-700">{{ weeklyReport.error }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'WeeklyReport',
data() {
return {
weeklyReport: {
username: '',
loading: false,
result: '',
error: ''
}
}
},
async mounted() {
// 获取默认用户名
await this.loadDefaultUser();
},
methods: {
async loadDefaultUser() {
try {
const response = await fetch('/api/jira/config');
const data = await response.json();
if (data.success && data.data.default_user) {
this.weeklyReport.username = data.data.default_user;
}
} catch (error) {
console.error('获取默认用户失败:', error);
}
},
async generateWeeklyReport() {
if (!this.weeklyReport.username.trim()) {
this.weeklyReport.error = '请输入用户名';
return;
}
this.weeklyReport.loading = true;
this.weeklyReport.error = '';
this.weeklyReport.result = '';
try {
const response = await fetch('/api/jira/weekly-report', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
username: this.weeklyReport.username
})
});
const data = await response.json();
if (data.success) {
this.weeklyReport.result = data.data.report;
} else {
this.weeklyReport.error = data.message;
}
} catch (error) {
this.weeklyReport.error = '网络请求失败: ' + error.message;
} finally {
this.weeklyReport.loading = false;
}
},
async copyMarkdown() {
if (!this.weeklyReport.result) {
return;
}
try {
await navigator.clipboard.writeText(this.weeklyReport.result);
// 可以添加一个成功提示
alert('Markdown 内容已复制到剪贴板');
} catch (error) {
// 如果浏览器不支持 clipboard API使用传统方法
const textArea = document.createElement('textarea');
textArea.value = this.weeklyReport.result;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
alert('Markdown 内容已复制到剪贴板');
}
},
downloadWeeklyReport() {
if (!this.weeklyReport.username.trim()) {
return;
}
const params = new URLSearchParams({
username: this.weeklyReport.username
});
window.open(`/api/jira/weekly-report/download?${params}`, '_blank');
}
}
}
</script>

View File

@@ -0,0 +1,373 @@
<template>
<div class="p-6">
<!-- 页面标题 -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">事件消费者同步对比</h1>
<p class="text-gray-600 mt-2">对比CRM和Agent的事件消费者消息找出缺失的消息</p>
</div>
<!-- 查询条件 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">查询条件</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">开始时间</label>
<input
v-model="startTime"
type="datetime-local"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">结束时间</label>
<input
v-model="endTime"
type="datetime-local"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">消息名称 (可选)</label>
<input
v-model="messageName"
type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="输入要查询的消息名称,留空则查询所有"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">排除的消息名称 (每行一个)</label>
<textarea
v-model="excludeMessagesText"
rows="4"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="输入要排除的消息名称,每行一个"
></textarea>
</div>
<div class="flex space-x-4">
<button
@click="compareSync"
:disabled="loading"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
对比同步状态
</button>
<button
@click="exportMissing"
:disabled="loading || !compareResult"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
导出缺失消息
</button>
</div>
</div>
<!-- 错误信息 -->
<div v-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex">
<svg class="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="text-sm font-medium text-red-800">错误</h3>
<p class="text-sm text-red-700 mt-1">{{ error }}</p>
</div>
</div>
</div>
<!-- 对比结果 -->
<div v-if="compareResult" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">对比结果</h2>
<!-- 统计信息 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-2xl font-bold text-blue-600">{{ compareResult.crm_total }}</div>
<div class="text-sm text-blue-600">CRM消息总数</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600">{{ compareResult.agent_total }}</div>
<div class="text-sm text-green-600">Agent消息总数</div>
</div>
<div class="bg-red-50 rounded-lg p-4">
<div class="text-2xl font-bold text-red-600">{{ compareResult.missing_count }}</div>
<div class="text-sm text-red-600">缺失消息数</div>
</div>
<div class="bg-purple-50 rounded-lg p-4">
<div class="text-2xl font-bold text-purple-600">{{ compareResult.sync_rate }}%</div>
<div class="text-sm text-purple-600">同步率</div>
</div>
</div>
<!-- 按Topic统计缺失消息 -->
<div v-if="compareResult.missing_by_topic && compareResult.missing_by_topic.length > 0" class="mb-6">
<h3 class="text-lg font-semibold text-gray-700 mb-4">按Topic统计缺失消息</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Topic名称</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">缺失数量</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">占比</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="item in compareResult.missing_by_topic" :key="item.topic">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ item.topic }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
{{ item.count }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ ((item.count / compareResult.missing_count) * 100).toFixed(2) }}%
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 缺失消息列表 -->
<div v-if="compareResult.missing_messages.length > 0">
<h3 class="text-lg font-semibold text-gray-700 mb-4">缺失的消息详情</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息名称</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息体</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">创建时间</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="msg in compareResult.missing_messages" :key="msg.msg_id">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">{{ msg.msg_id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ msg.event_name }}</td>
<td class="px-6 py-4 text-sm">
<button
@click="showDetail(msg)"
class="text-blue-600 hover:text-blue-800 hover:underline"
>
查看详情
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDateTime(msg.created) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else class="bg-green-50 border border-green-200 rounded-lg p-4">
<p class="text-green-700">所有消息都已同步到Agent</p>
</div>
</div>
<!-- 消息详情弹窗 -->
<div v-if="showDetailModal" class="fixed inset-0 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-96 overflow-y-auto">
<div class="sticky top-0 bg-gray-50 border-b border-gray-200 px-6 py-4 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900">消息详情</h3>
<button
@click="showDetailModal = false"
class="text-gray-400 hover:text-gray-600"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="px-6 py-4">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">消息ID</label>
<p class="text-sm text-gray-900 font-mono break-all">{{ selectedMessage?.msg_id }}</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">消息名称</label>
<p class="text-sm text-gray-900">{{ selectedMessage?.event_name }}</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">消息体</label>
<div class="bg-gray-50 rounded p-3 max-h-48 overflow-y-auto">
<pre class="text-sm text-gray-900 whitespace-pre-wrap break-words">{{ selectedMessage?.msg_body }}</pre>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">创建时间</label>
<p class="text-sm text-gray-900">{{ formatDateTime(selectedMessage?.created) }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">更新时间</label>
<p class="text-sm text-gray-900">{{ formatDateTime(selectedMessage?.updated) }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'EventConsumerSync',
data() {
return {
startTime: '',
endTime: '',
messageName: '',
excludeMessagesText: '',
compareResult: null,
loading: false,
error: '',
showDetailModal: false,
selectedMessage: null
}
},
computed: {
excludeMessages() {
return this.excludeMessagesText
.split('\n')
.map(msg => msg.trim())
.filter(msg => msg.length > 0);
}
},
methods: {
async compareSync() {
this.loading = true;
this.error = '';
try {
const payload = {
exclude_messages: this.excludeMessages
};
if (this.startTime) {
payload.start_time = this.formatDateTimeForAPI(this.startTime);
}
if (this.endTime) {
payload.end_time = this.formatDateTimeForAPI(this.endTime);
}
if (this.messageName.trim()) {
payload.message_name = this.messageName.trim();
}
const response = await fetch('/api/message-sync/compare-event-consumer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
this.compareResult = data.data;
} else {
this.error = data.message;
}
} catch (error) {
this.error = '网络请求失败: ' + error.message;
} finally {
this.loading = false;
}
},
exportMissing() {
if (!this.compareResult || this.compareResult.missing_messages.length === 0) {
alert('没有缺失的消息可以导出');
return;
}
try {
// 准备导出数据
const exportData = {
export_time: new Date().toLocaleString(),
query_conditions: {
start_time: this.startTime || '未设置',
end_time: this.endTime || '未设置',
message_name: this.messageName || '未设置',
exclude_messages: this.excludeMessages.length > 0 ? this.excludeMessages : []
},
summary: {
crm_total: this.compareResult.crm_total,
agent_total: this.compareResult.agent_total,
missing_count: this.compareResult.missing_count,
sync_rate: this.compareResult.sync_rate + '%'
},
missing_messages: this.compareResult.missing_messages.map(msg => ({
msg_id: msg.msg_id,
event_name: msg.event_name,
msg_body: msg.msg_body,
created: msg.created,
updated: msg.updated
}))
};
// 生成JSON文件
const jsonString = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
// 生成文件名
const timestamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, -5);
const filename = `missing_messages_${timestamp}.json`;
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
alert(`成功导出 ${this.compareResult.missing_messages.length} 条缺失消息`);
} catch (error) {
this.error = '导出失败: ' + error.message;
}
},
showDetail(message) {
this.selectedMessage = message;
this.showDetailModal = true;
},
formatDateTime(dateString) {
if (!dateString) return '-';
return new Date(dateString).toLocaleString();
},
formatDateTimeForAPI(dateTimeLocal) {
if (!dateTimeLocal) return null;
const date = new Date(dateTimeLocal);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
}
}
</script>

View File

@@ -0,0 +1,746 @@
<template>
<div class="p-6">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">消息分发异常查询</h1>
<p class="text-gray-600 mt-2">查询和管理异常的消息分发数据</p>
</div>
<!-- 筛选区域 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">筛选条件</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">消息ID</label>
<textarea
v-model="filters.msgId"
rows="3"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"
placeholder="输入消息ID支持多个用空格、换行或逗号分隔"
></textarea>
</div>
<div class="relative">
<label class="block text-sm font-medium text-gray-700 mb-2">国家代码</label>
<div class="relative">
<button
@click="showCountryDropdown = !showCountryDropdown"
type="button"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-left focus:ring-2 focus:ring-blue-500 bg-white flex items-center justify-between"
>
<span class="text-sm text-gray-700">
{{ filters.countryCodes.length > 0 ? `已选 ${filters.countryCodes.length}` : '请选择国家代码' }}
</span>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div
v-if="showCountryDropdown"
class="absolute z-50 mt-1 w-full bg-white border border-gray-300 rounded-lg shadow-lg max-h-80 overflow-y-auto"
>
<div class="sticky top-0 bg-gray-50 border-b border-gray-200 p-2 flex gap-2">
<button
@click="selectAllCountries"
type="button"
class="flex-1 px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
>
全选
</button>
<button
@click="clearAllCountries"
type="button"
class="flex-1 px-3 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700"
>
清空
</button>
</div>
<div class="p-2">
<label class="flex items-center px-2 py-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
value=""
v-model="filters.countryCodes"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="ml-2 text-sm text-gray-500 italic">()</span>
</label>
<label
v-for="code in availableCountryCodes"
:key="code"
class="flex items-center px-2 py-2 hover:bg-gray-50 rounded cursor-pointer"
>
<input
type="checkbox"
:value="code"
v-model="filters.countryCodes"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="ml-2 text-sm text-gray-700">{{ code }}</span>
</label>
</div>
</div>
</div>
</div>
<div class="relative">
<label class="block text-sm font-medium text-gray-700 mb-2">域名</label>
<div class="relative">
<button
@click="showDomainDropdown = !showDomainDropdown"
type="button"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-left focus:ring-2 focus:ring-blue-500 bg-white flex items-center justify-between"
>
<span class="text-sm text-gray-700">
{{ filters.domains.length > 0 ? `已选 ${filters.domains.length}` : '请选择域名' }}
</span>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div
v-if="showDomainDropdown"
class="absolute z-50 mt-1 w-full bg-white border border-gray-300 rounded-lg shadow-lg max-h-80 overflow-y-auto"
>
<div class="sticky top-0 bg-gray-50 border-b border-gray-200 p-2 flex gap-2">
<button
@click="selectAllDomains"
type="button"
class="flex-1 px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
>
全选
</button>
<button
@click="clearAllDomains"
type="button"
class="flex-1 px-3 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700"
>
清空
</button>
</div>
<div class="p-2">
<label class="flex items-center px-2 py-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
value=""
v-model="filters.domains"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="ml-2 text-sm text-gray-500 italic">()</span>
</label>
<label
v-for="domain in availableDomains"
:key="domain"
class="flex items-center px-2 py-2 hover:bg-gray-50 rounded cursor-pointer"
>
<input
type="checkbox"
:value="domain"
v-model="filters.domains"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="ml-2 text-sm text-gray-700">{{ domain }}</span>
</label>
</div>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">请求状态</label>
<select
v-model="filters.requestStatus"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"
>
<option :value="null">全部</option>
<option :value="0">待处理</option>
<option :value="1">成功</option>
<option :value="2">失败</option>
<option :value="3">重试中</option>
<option :value="4">超时</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">回调状态</label>
<select
v-model="filters.businessStatus"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"
>
<option :value="null">全部</option>
<option :value="0">待处理</option>
<option :value="1">成功</option>
<option :value="2">失败</option>
</select>
</div>
</div>
<div class="mt-4 flex justify-between items-center">
<div class="flex gap-2">
<button
@click="quickFilterCN"
class="px-4 py-2 text-sm font-medium rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
>
快速筛选 CN
</button>
<button
@click="quickFilterUS"
class="px-4 py-2 text-sm font-medium rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
>
快速筛选 US
</button>
</div>
<button
@click="loadData"
:disabled="loading"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
查询
</button>
</div>
</div>
<!-- 错误信息 -->
<div v-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex">
<svg class="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="text-sm font-medium text-red-800">错误</h3>
<p class="text-sm text-red-700 mt-1">{{ error }}</p>
</div>
</div>
</div>
<!-- 数据表格 -->
<div v-if="dispatches.length > 0" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-gray-700">异常数据列表 ({{ dispatches.length }})</h2>
<div class="flex gap-2">
<button
@click="copySelectedMsgIds"
:disabled="selectedIds.length === 0"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
复制消息ID ({{ selectedIds.length }})
</button>
<button
@click="showBatchUpdateModal"
:disabled="selectedIds.length === 0"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
批量更新 ({{ selectedIds.length }})
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200" style="min-width: 1600px;">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-3 text-left sticky left-0 bg-gray-50 z-10">
<input
type="checkbox"
@change="toggleSelectAll"
:checked="selectedIds.length === dispatches.length"
class="rounded"
/>
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">消息ID</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">国家代码</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">域名</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">事件名称</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">实体代码</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">请求状态</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">回调状态</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">Agent状态</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">重试次数</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">请求错误</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">回调错误</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">创建时间</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">更新时间</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="item in dispatches" :key="item.id" class="hover:bg-gray-50">
<td class="px-3 py-3 sticky left-0 bg-white z-10">
<input
type="checkbox"
:value="item.id"
v-model="selectedIds"
class="rounded"
/>
</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.msg_id }}</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.country_code || '-' }}</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.domain || '-' }}</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.event_name }}</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.entity_code }}</td>
<td class="px-3 py-3 whitespace-nowrap">
<span :class="getStatusClass(item.request_status)">
{{ getStatusText(item.request_status) }}
</span>
</td>
<td class="px-3 py-3 whitespace-nowrap">
<span :class="getBusinessStatusClass(item.business_status)">
{{ getBusinessStatusText(item.business_status) }}
</span>
</td>
<td class="px-3 py-3 whitespace-nowrap">
<span v-if="!isUsDomain(item.domain)" :class="getConsumerStatusClass(item.consumer_status)">
{{ getConsumerStatusText(item.consumer_status) }}
</span>
<span v-else class="text-sm text-gray-400">-</span>
</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.retry_count }}</td>
<td class="px-3 py-3 text-sm text-gray-900 max-w-xs truncate" :title="item.request_error_message">
{{ item.request_error_message || '-' }}
</td>
<td class="px-3 py-3 text-sm text-gray-900 max-w-xs truncate" :title="item.business_error_message">
{{ item.business_error_message || '-' }}
</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.created || '-' }}</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.updated || '-' }}</td>
<td class="px-3 py-3 whitespace-nowrap">
<button
@click="showDetail(item)"
class="text-blue-600 hover:text-blue-800 text-sm"
>
详情
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 详情弹窗 -->
<div v-if="detailItem" class="fixed inset-0 flex items-center justify-center z-50" @click.self="detailItem = null">
<div class="bg-white rounded-lg p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto border border-gray-200 shadow-xl">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold">消息详情</h3>
<button @click="detailItem = null" class="text-gray-500 hover:text-gray-700">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-gray-700">消息ID</label>
<p class="text-sm text-gray-900">{{ detailItem.msg_id }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-700">服务名称</label>
<p class="text-sm text-gray-900">{{ detailItem.service_name }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-700">请求错误信息</label>
<p class="text-sm text-red-600">{{ detailItem.request_error_message || '-' }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-700">业务错误信息</label>
<p class="text-sm text-red-600">{{ detailItem.business_error_message || '-' }}</p>
</div>
</div>
<div>
<label class="text-sm font-medium text-gray-700 mb-2 block">消息体</label>
<pre class="bg-gray-50 p-4 rounded-lg text-xs overflow-x-auto">{{ formatJson(detailItem.msg_body) }}</pre>
</div>
</div>
</div>
</div>
<!-- 批量更新弹窗 -->
<div v-if="showBatchUpdate" class="fixed inset-0 flex items-center justify-center z-50" @click.self="showBatchUpdate = false">
<div class="bg-white rounded-lg p-6 max-w-2xl w-full border border-gray-200 shadow-xl">
<h3 class="text-xl font-semibold mb-4">批量更新 ({{ selectedIds.length }} )</h3>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">请求状态</label>
<select v-model="batchUpdate.request_status" class="w-full border border-gray-300 rounded-lg px-3 py-2">
<option :value="null">不修改</option>
<option :value="0">待处理</option>
<option :value="1">成功</option>
<option :value="2">失败</option>
<option :value="3">重试中</option>
<option :value="4">超时</option>
<option :value="5">已取消</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">回调状态</label>
<select v-model="batchUpdate.business_status" class="w-full border border-gray-300 rounded-lg px-3 py-2">
<option :value="null">不修改</option>
<option :value="0">待处理</option>
<option :value="1">成功</option>
<option :value="2">失败</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">重试次数</label>
<input v-model.number="batchUpdate.retry_count" type="number" min="0" class="w-full border border-gray-300 rounded-lg px-3 py-2" placeholder="不修改" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">目标服务</label>
<select v-model="batchUpdate.target_service" class="w-full border border-gray-300 rounded-lg px-3 py-2">
<option :value="null">不修改</option>
<option v-for="route in serviceRoutes" :key="route.id" :value="route.id">
{{ route.display_name }}
</option>
</select>
</div>
</div>
<div class="flex justify-end space-x-4 mt-6">
<button @click="showBatchUpdate = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
取消
</button>
<button @click="executeBatchUpdate" :disabled="updating" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
确认更新
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'MessageDispatch',
data() {
return {
loading: false,
updating: false,
error: null,
availableCountryCodes: [],
availableDomains: [],
serviceRoutes: [],
showCountryDropdown: false,
showDomainDropdown: false,
filters: {
msgId: null,
countryCodes: [],
domains: [],
requestStatus: null,
businessStatus: null,
},
dispatches: [],
selectedIds: [],
detailItem: null,
showBatchUpdate: false,
batchUpdate: {
request_status: null,
business_status: null,
retry_count: null,
target_service: null,
},
};
},
mounted() {
this.loadCountryCodes();
this.loadDomains();
this.loadServiceRoutes();
document.addEventListener('click', this.handleClickOutside);
},
beforeUnmount() {
document.removeEventListener('click', this.handleClickOutside);
},
methods: {
async loadCountryCodes() {
try {
const response = await axios.get('/api/message-dispatch/country-codes');
this.availableCountryCodes = response.data.data;
} catch (err) {
console.error('加载国家代码列表失败:', err);
}
},
async loadDomains() {
try {
const response = await axios.get('/api/message-dispatch/domains');
this.availableDomains = response.data.data;
} catch (err) {
console.error('加载域名列表失败:', err);
}
},
async loadServiceRoutes() {
try {
const response = await axios.get('/api/message-dispatch/service-routes');
this.serviceRoutes = response.data.data;
} catch (err) {
console.error('加载服务路由列表失败:', err);
}
},
async loadData() {
this.loading = true;
this.error = null;
try {
const params = {};
// 处理消息ID按空格、换行、逗号分割
if (this.filters.msgId) {
const msgIds = this.filters.msgId
.split(/[\s,]+/)
.map(id => id.trim())
.filter(id => id.length > 0);
if (msgIds.length > 0) {
params.msg_ids = msgIds;
}
}
if (this.filters.countryCodes.length > 0) params.country_codes = this.filters.countryCodes;
if (this.filters.domains.length > 0) params.domains = this.filters.domains;
if (this.filters.requestStatus !== null) params.request_status = this.filters.requestStatus;
if (this.filters.businessStatus !== null) params.business_status = this.filters.businessStatus;
const response = await axios.get('/api/message-dispatch/abnormal', { params });
this.dispatches = response.data.data;
this.selectedIds = [];
} catch (err) {
this.error = err.response?.data?.message || err.message;
} finally {
this.loading = false;
}
},
selectAllCountries() {
this.filters.countryCodes = ['', ...this.availableCountryCodes];
},
clearAllCountries() {
this.filters.countryCodes = [];
},
selectAllDomains() {
this.filters.domains = ['', ...this.availableDomains];
},
clearAllDomains() {
this.filters.domains = [];
},
quickFilterCN() {
// CN筛选选择空和cnsha域名
const cnDomains = this.availableDomains.filter(domain =>
domain && domain.includes('cnsha')
);
this.filters.domains = ['', ...cnDomains];
this.loadData();
},
quickFilterUS() {
// US筛选选择us域名
const usDomains = this.availableDomains.filter(domain =>
domain && (domain.includes('partner-us') || domain.includes('.us.'))
);
this.filters.domains = usDomains;
this.loadData();
},
handleClickOutside(event) {
const dropdown = event.target.closest('.relative');
if (!dropdown || !dropdown.querySelector('button')) {
this.showCountryDropdown = false;
this.showDomainDropdown = false;
}
},
toggleSelectAll(event) {
if (event.target.checked) {
this.selectedIds = this.dispatches.map(item => item.id);
} else {
this.selectedIds = [];
}
},
copySelectedMsgIds() {
const selectedItems = this.dispatches.filter(item => this.selectedIds.includes(item.id));
const msgIds = selectedItems.map(item => `'${item.msg_id}'`).join(',');
// 使用降级方案兼容HTTP环境
const textArea = document.createElement('textarea');
textArea.value = msgIds;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
this.showToast(`已复制 ${selectedItems.length} 个消息ID到剪贴板`, 'success');
} else {
this.showToast('复制失败,请重试', 'error');
}
} catch (err) {
document.body.removeChild(textArea);
console.error('复制失败:', err);
this.showToast('复制失败,请重试', 'error');
}
},
showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white z-50 ${
type === 'success' ? 'bg-green-500' : 'bg-red-500'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(() => {
document.body.removeChild(toast);
}, 300);
}, 2000);
},
showDetail(item) {
this.detailItem = item;
},
showBatchUpdateModal() {
this.showBatchUpdate = true;
this.batchUpdate = {
request_status: null,
business_status: null,
retry_count: null,
target_service: null,
};
},
async executeBatchUpdate() {
this.updating = true;
this.error = null;
try {
const selectedRoute = this.batchUpdate.target_service !== null
? this.serviceRoutes.find(route => String(route.id) === String(this.batchUpdate.target_service))
: null;
const updates = this.selectedIds.map(id => {
const update = { id: String(id) };
if (this.batchUpdate.request_status !== null) update.request_status = String(this.batchUpdate.request_status);
if (this.batchUpdate.business_status !== null) update.business_status = String(this.batchUpdate.business_status);
if (this.batchUpdate.retry_count !== null) update.retry_count = String(this.batchUpdate.retry_count);
if (this.batchUpdate.target_service !== null) {
update.target_service = String(this.batchUpdate.target_service);
if (selectedRoute && selectedRoute.country_code) {
update.country_code = selectedRoute.country_code;
}
}
return update;
});
const response = await axios.post('/api/message-dispatch/batch-update', { updates });
alert(`更新完成!成功: ${response.data.data.summary.success}, 失败: ${response.data.data.summary.failure}`);
this.showBatchUpdate = false;
this.loadData();
} catch (err) {
this.error = err.response?.data?.message || err.message;
} finally {
this.updating = false;
}
},
isUsDomain(domain) {
return domain === 'partner-us.eainc.com';
},
formatJson(json) {
try {
const obj = typeof json === 'string' ? JSON.parse(json) : json;
return JSON.stringify(obj, null, 2);
} catch {
return json;
}
},
getStatusText(status) {
const map = { 0: '待处理', 1: '成功', 2: '失败', 3: '重试中', 4: '超时', 5: '已取消' };
return map[status] || '未知';
},
getStatusClass(status) {
const map = {
0: 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800',
1: 'px-2 py-1 text-xs rounded-full bg-green-100 text-green-800',
2: 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800',
3: 'px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800',
4: 'px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-800',
5: 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800',
};
return map[status] || 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800';
},
getBusinessStatusText(status) {
const map = { 0: '待处理', 1: '成功', 2: '失败' };
return map[status] || '未知';
},
getBusinessStatusClass(status) {
const map = {
0: 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800',
1: 'px-2 py-1 text-xs rounded-full bg-green-100 text-green-800',
2: 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800',
};
return map[status] || 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800';
},
getConsumerStatusText(status) {
const map = {
0: '待处理',
1: '处理成功',
2: '处理中',
3: '处理失败',
4: '等待重试',
5: '处理忽略',
};
return map[status] ?? '未知';
},
getConsumerStatusClass(status) {
const map = {
0: 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800',
1: 'px-2 py-1 text-xs rounded-full bg-green-100 text-green-800',
2: 'px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800',
3: 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800',
4: 'px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800',
5: 'px-2 py-1 text-xs rounded-full bg-purple-100 text-purple-800',
};
return map[status] ?? 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800';
},
},
};
</script>

View File

@@ -0,0 +1,358 @@
<template>
<div class="p-6">
<!-- 页面标题 -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">消息同步</h1>
<p class="text-gray-600 mt-2">批量输入消息ID从crmslave数据库查询并同步到agent服务</p>
</div>
<!-- 输入区域 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">消息ID输入</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
消息ID列表 (每行一个ID)
</label>
<textarea
v-model="messageIdsText"
rows="8"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入消息ID每行一个&#10;例如:&#10;af7e5ca7-2779-0e9e-93d1-68c79ceffd9033&#10;bf8f6db8-3880-1f0f-a4e2-79d8adf00144"
></textarea>
<div class="text-sm text-gray-500 mt-1">
{{ messageIdsList.length }} 个消息ID
</div>
</div>
<div class="flex space-x-4">
<button
@click="queryMessages"
:disabled="loading.query || messageIdsList.length === 0"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="loading.query" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
查询消息
</button>
<button
@click="syncMessages"
:disabled="loading.sync || !queryResults || messageIdsList.length === 0"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="loading.sync" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
执行同步
</button>
<button
@click="testConnection"
:disabled="loading.test"
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="loading.test" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
测试连接
</button>
</div>
</div>
</div>
<!-- 错误信息 -->
<div v-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex">
<svg class="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="text-sm font-medium text-red-800">错误</h3>
<p class="text-sm text-red-700 mt-1">{{ error }}</p>
</div>
</div>
</div>
<!-- 查询结果 -->
<div v-if="queryResults" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">查询结果</h2>
<!-- 统计信息 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-2xl font-bold text-blue-600">{{ queryResults.stats.total_requested }}</div>
<div class="text-sm text-blue-600">请求总数</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600">{{ queryResults.stats.total_found }}</div>
<div class="text-sm text-green-600">找到记录</div>
</div>
<div class="bg-red-50 rounded-lg p-4">
<div class="text-2xl font-bold text-red-600">{{ queryResults.stats.total_missing }}</div>
<div class="text-sm text-red-600">缺失记录</div>
</div>
<div class="bg-purple-50 rounded-lg p-4">
<div class="text-2xl font-bold text-purple-600">{{ Object.keys(queryResults.stats.event_types).length }}</div>
<div class="text-sm text-purple-600">事件类型</div>
</div>
</div>
<!-- 消息列表 -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">事件类型</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">跟踪ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间戳</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="message in queryResults.messages" :key="message.msg_id">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">{{ message.msg_id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ message.event_type }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-500">{{ message.trace_id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatTimestamp(message.timestamp) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<button
@click="showMessageDetail(message)"
class="text-blue-600 hover:text-blue-900"
>
查看详情
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 同步结果 -->
<div v-if="syncResults" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">同步结果</h2>
<!-- 同步统计 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-2xl font-bold text-blue-600">{{ syncResults.summary.total }}</div>
<div class="text-sm text-blue-600">总计</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600">{{ syncResults.summary.success }}</div>
<div class="text-sm text-green-600">成功</div>
</div>
<div class="bg-red-50 rounded-lg p-4">
<div class="text-2xl font-bold text-red-600">{{ syncResults.summary.failure }}</div>
<div class="text-sm text-red-600">失败</div>
</div>
</div>
<!-- 同步结果列表 -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">响应</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="result in syncResults.results" :key="result.msg_id">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">{{ result.msg_id }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span v-if="result.success" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
成功
</span>
<span v-else class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
失败
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">
{{ result.success ? '同步成功' : result.error }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<button
@click="showSyncDetail(result)"
class="text-blue-600 hover:text-blue-900"
>
查看详情
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 详情模态框 -->
<div v-if="showDetailModal" class="fixed inset-0 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">详细信息</h3>
<button @click="closeDetailModal" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="max-h-96 overflow-y-auto">
<pre class="bg-gray-100 p-4 rounded-lg text-sm overflow-x-auto">{{ JSON.stringify(selectedDetail, null, 2) }}</pre>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'MessageSync',
data() {
return {
messageIdsText: '',
queryResults: null,
syncResults: null,
loading: {
query: false,
sync: false,
test: false
},
error: '',
showDetailModal: false,
selectedDetail: null
}
},
computed: {
messageIdsList() {
return this.messageIdsText
.split('\n')
.map(id => id.trim())
.filter(id => id.length > 0);
}
},
methods: {
async queryMessages() {
if (this.messageIdsList.length === 0) {
this.error = '请输入至少一个消息ID';
return;
}
this.loading.query = true;
this.error = '';
this.queryResults = null;
try {
const response = await fetch('/api/message-sync/query', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
message_ids: this.messageIdsList
})
});
const data = await response.json();
if (data.success) {
this.queryResults = data.data;
} else {
this.error = data.message;
}
} catch (error) {
this.error = '网络请求失败: ' + error.message;
} finally {
this.loading.query = false;
}
},
async syncMessages() {
if (this.messageIdsList.length === 0) {
this.error = '请输入至少一个消息ID';
return;
}
this.loading.sync = true;
this.error = '';
this.syncResults = null;
try {
const response = await fetch('/api/message-sync/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
message_ids: this.messageIdsList
})
});
const data = await response.json();
if (data.success) {
this.syncResults = data.data;
} else {
this.error = data.message;
}
} catch (error) {
this.error = '网络请求失败: ' + error.message;
} finally {
this.loading.sync = false;
}
},
async testConnection() {
this.loading.test = true;
this.error = '';
try {
const response = await fetch('/api/message-sync/test-connection');
const data = await response.json();
if (data.success) {
alert('数据库连接测试成功!');
} else {
this.error = data.message;
}
} catch (error) {
this.error = '网络请求失败: ' + error.message;
} finally {
this.loading.test = false;
}
},
showMessageDetail(message) {
this.selectedDetail = message;
this.showDetailModal = true;
},
showSyncDetail(result) {
this.selectedDetail = result;
this.showDetailModal = true;
},
closeDetailModal() {
this.showDetailModal = false;
this.selectedDetail = null;
},
formatTimestamp(timestamp) {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString();
}
}
}
</script>

44
resources/js/lang-env.js Normal file
View File

@@ -0,0 +1,44 @@
import { StreamLanguage } from '@codemirror/language'
// 简单的.env语法高亮
const envLanguage = StreamLanguage.define({
token(stream) {
// 注释
if (stream.match(/^\s*#.*/)) {
return 'comment'
}
// 变量名
if (stream.match(/^[A-Z_][A-Z0-9_]*/)) {
return 'variableName'
}
// 等号
if (stream.match(/=/)) {
return 'operator'
}
// 字符串
if (stream.match(/^"[^"]*"/) || stream.match(/^'[^']*'/)) {
return 'string'
}
// 布尔值
if (stream.match(/^(true|false)\b/i)) {
return 'keyword'
}
// 数字
if (stream.match(/^\d+(\.\d+)?/)) {
return 'number'
}
// 跳过其他字符
stream.next()
return null
}
})
export function env() {
return envLanguage
}

View File

@@ -0,0 +1,9 @@
@extends('layouts.app')
@section('title', 'Tradewind Toolbox')
@section('content')
<div id="app">
<admin-dashboard></admin-dashboard>
</div>
@endsection

View File

@@ -4,12 +4,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>环境文件管理系统</title>
<title>@yield('title', 'Tradewind Toolbox')</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
<div id="app">
<env-manager></env-manager>
</div>
@yield('content')
@stack('scripts')
</body>
</html>