Files
ai_mip/static/app.html
2026-02-24 12:46:35 +08:00

2796 lines
125 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MIP广告自动化管理系统</title>
<link rel="stylesheet" href="/lib/element-ui.min.css">
<script src="/lib/vue.min.js"></script>
<script src="/lib/element-ui.min.js"></script>
<script src="/lib/axios.min.js"></script>
<script src="/lib/echarts.min.js"></script>
<style>
:root {
--ios-blue: #007AFF;
--ios-green: #34C759;
--ios-orange: #FF9500;
--ios-red: #FF3B30;
--ios-purple: #AF52DE;
--ios-gray: #8E8E93;
--ios-gray-2: #AEAEB2;
--ios-gray-3: #C7C7CC;
--ios-gray-4: #D1D1D6;
--ios-gray-5: #E5E5EA;
--ios-gray-6: #F2F2F7;
--ios-bg: linear-gradient(180deg, #F2F2F7 0%, #E5E5EA 100%);
--card-bg: rgba(255, 255, 255, 0.72);
--glass-bg: rgba(255, 255, 255, 0.6);
--text-primary: #1C1C1E;
--text-secondary: #3C3C43;
--text-tertiary: #48484A;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08), 0 2px 6px rgba(0,0,0,0.04);
--shadow-lg: 0 8px 30px rgba(0,0,0,0.12), 0 4px 12px rgba(0,0,0,0.06);
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 22px;
--radius-xl: 28px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', 'PingFang SC', sans-serif;
background: var(--ios-bg);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app { min-height: 100vh; }
/* iOS风格顶部导航栏 */
.header {
height: 64px;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.1);
}
.header-left {
display: flex;
align-items: center;
gap: 14px;
}
.logo {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.25);
}
.app-title {
color: var(--text-primary);
font-size: 20px;
font-weight: 600;
letter-spacing: -0.3px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.status-pill {
display: flex;
align-items: center;
gap: 8px;
background: var(--ios-gray-6);
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
animation: breathe 2.5s ease-in-out infinite;
}
.status-indicator.running { background: var(--ios-green); box-shadow: 0 0 8px var(--ios-green); }
.status-indicator.stopped { background: var(--ios-red); }
@keyframes breathe {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.85); }
}
.refresh-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--ios-gray-6);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.refresh-btn:hover { background: var(--ios-gray-5); transform: scale(1.05); }
.refresh-btn:active { transform: scale(0.95); }
/* iOS风格主体布局 */
.main-layout {
display: flex;
padding-top: 64px;
min-height: 100vh;
}
/* iOS风格侧边栏 */
.sidebar {
width: 240px;
background: rgba(255, 255, 255, 0.65);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
min-height: calc(100vh - 64px);
position: fixed;
left: 0;
top: 64px;
bottom: 0;
overflow-y: auto;
padding: 20px 12px;
border-right: 0.5px solid rgba(0, 0, 0, 0.08);
}
.sidebar .el-menu {
border: none;
background: transparent;
}
.sidebar .el-menu-item {
height: 50px;
line-height: 50px;
font-size: 15px;
font-weight: 500;
border-radius: var(--radius-md);
margin-bottom: 4px;
color: var(--text-secondary) !important;
transition: all 0.2s ease;
}
.sidebar .el-menu-item:hover {
background: rgba(0, 122, 255, 0.08);
color: var(--ios-blue) !important;
}
.sidebar .el-menu-item.is-active,
.sidebar .el-menu--horizontal > .el-menu-item.is-active {
background: var(--ios-blue) !important;
color: #ffffff !important;
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
}
.sidebar .el-menu-item.is-active i,
.sidebar .el-menu-item.is-active span,
.sidebar .el-menu-item.is-active .el-menu-item__icon {
color: #ffffff !important;
}
.sidebar .el-menu-item i {
margin-right: 12px;
font-size: 18px;
color: inherit;
}
/* iOS风格内容区 */
.content-wrapper {
flex: 1;
margin-left: 240px;
padding: 24px 28px;
background: transparent;
}
/* iOS风格统计卡片 */
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 24px;
}
.stat-card {
background: var(--card-bg);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-radius: var(--radius-lg);
padding: 24px;
position: relative;
overflow: hidden;
box-shadow: var(--shadow-md);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 0.5px solid rgba(255, 255, 255, 0.5);
}
.stat-card:hover {
transform: translateY(-6px) scale(1.02);
box-shadow: var(--shadow-lg);
}
.stat-icon {
width: 52px;
height: 52px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-bottom: 16px;
}
.stat-icon i {
font-size: 24px;
}
.stat-card.primary .stat-icon { background: rgba(0, 122, 255, 0.12); }
.stat-card.primary .stat-icon i { color: var(--ios-blue); }
.stat-card.success .stat-icon { background: rgba(52, 199, 89, 0.12); }
.stat-card.success .stat-icon i { color: var(--ios-green); }
.stat-card.warning .stat-icon { background: rgba(255, 149, 0, 0.12); }
.stat-card.warning .stat-icon i { color: var(--ios-orange); }
.stat-card.danger .stat-icon { background: rgba(255, 59, 48, 0.12); }
.stat-card.danger .stat-icon i { color: var(--ios-red); }
.stat-label {
font-size: 14px;
font-weight: 500;
color: var(--text-tertiary);
margin-bottom: 8px;
letter-spacing: -0.2px;
}
.stat-value {
font-size: 34px;
font-weight: 700;
letter-spacing: -1px;
}
.stat-card.primary .stat-value { color: var(--ios-blue); }
.stat-card.success .stat-value { color: var(--ios-green); }
.stat-card.warning .stat-value { color: var(--ios-orange); }
.stat-card.danger .stat-value { color: var(--ios-red); }
/* iOS风格卡片 */
.card {
background: var(--card-bg);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
margin-bottom: 24px;
border: 0.5px solid rgba(255, 255, 255, 0.5);
overflow: hidden;
}
.card-header {
padding: 20px 24px;
border-bottom: 0.5px solid var(--ios-gray-5);
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.4);
}
.card-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.card-body { padding: 24px; }
/* iOS风格图表网格 */
.chart-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-bottom: 24px;
}
.chart-card {
background: var(--card-bg);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-md);
border: 0.5px solid rgba(255, 255, 255, 0.5);
}
.chart-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
padding-left: 12px;
border-left: 3px solid var(--ios-blue);
letter-spacing: -0.2px;
}
.chart-container { height: 280px; }
/* iOS风格工具栏 */
.toolbar {
display: flex;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
align-items: center;
}
.toolbar > * {
flex-shrink: 0;
}
.toolbar .el-input {
width: 220px !important;
}
.toolbar .el-select {
width: 130px !important;
}
.toolbar .el-input-group {
width: 220px !important;
}
/* iOS风格输入框覆盖 */
.el-input__inner {
border-radius: var(--radius-sm) !important;
border-color: var(--ios-gray-4) !important;
background: rgba(255, 255, 255, 0.8) !important;
transition: all 0.2s ease !important;
}
.el-input__inner:focus {
border-color: var(--ios-blue) !important;
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.15) !important;
}
/* iOS风格按钮覆盖 */
.el-button {
border-radius: var(--radius-sm) !important;
font-weight: 500 !important;
letter-spacing: -0.2px !important;
transition: all 0.2s ease !important;
}
.el-button--primary {
background: var(--ios-blue) !important;
border-color: var(--ios-blue) !important;
}
.el-button--primary:hover {
background: #0066D6 !important;
transform: translateY(-1px);
}
.el-button--success {
background: var(--ios-green) !important;
border-color: var(--ios-green) !important;
}
.el-button--danger {
background: var(--ios-red) !important;
border-color: var(--ios-red) !important;
}
.el-button--warning {
background: var(--ios-orange) !important;
border-color: var(--ios-orange) !important;
}
/* iOS风格表格 */
.el-table {
border-radius: var(--radius-md);
overflow: hidden;
background: transparent !important;
}
.el-table th {
background: rgba(242, 242, 247, 0.8) !important;
font-weight: 600;
color: var(--text-secondary) !important;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.el-table td, .el-table th { padding: 14px 0; }
.el-table--striped .el-table__body tr.el-table__row--striped td {
background: rgba(242, 242, 247, 0.5) !important;
}
/* 表格行悬停效果增强 */
.el-table__body tr {
transition: all 0.2s ease;
}
.el-table__body tr:hover > td {
background: rgba(0, 122, 255, 0.06) !important;
}
.el-table__body tr:hover {
transform: scale(1.002);
box-shadow: 0 2px 12px rgba(0, 122, 255, 0.1);
}
/* 表格行选中状态 */
.el-table__body tr.current-row > td {
background: rgba(0, 122, 255, 0.1) !important;
}
.el-table__body tr.selection-row > td,
.el-table .el-table__body tr.el-table__row.hover-row > td {
background: rgba(0, 122, 255, 0.08) !important;
}
/* 表格复选框样式 */
.el-checkbox__inner {
border-radius: 6px;
width: 18px;
height: 18px;
transition: all 0.2s ease;
}
.el-checkbox__input.is-checked .el-checkbox__inner {
background-color: var(--ios-blue);
border-color: var(--ios-blue);
}
.el-checkbox__inner:hover {
border-color: var(--ios-blue);
}
.el-checkbox__inner::after {
left: 6px;
top: 2px;
}
/* 操作列按钮优化 */
.table-actions {
display: flex;
gap: 8px;
justify-content: center;
}
.table-actions .el-button--text {
padding: 6px 12px !important;
border-radius: 8px !important;
transition: all 0.2s ease !important;
font-weight: 500 !important;
}
.table-actions .el-button--text:hover {
background: rgba(0, 122, 255, 0.1) !important;
}
.table-actions .el-button--text.danger:hover {
background: rgba(255, 59, 48, 0.1) !important;
}
/* 操作按钮图标 */
.action-btn {
padding: 6px 10px !important;
border-radius: 8px !important;
font-size: 13px !important;
font-weight: 500 !important;
transition: all 0.2s ease !important;
display: inline-flex;
align-items: center;
gap: 4px;
}
.action-btn:hover {
transform: translateY(-1px);
}
.action-btn.primary {
color: var(--ios-blue) !important;
}
.action-btn.primary:hover {
background: rgba(0, 122, 255, 0.1) !important;
}
.action-btn.danger {
color: var(--ios-red) !important;
}
.action-btn.danger:hover {
background: rgba(255, 59, 48, 0.1) !important;
}
.action-btn.success {
color: var(--ios-green) !important;
}
.action-btn.success:hover {
background: rgba(52, 199, 89, 0.1) !important;
}
/* 表格内标签优化 */
.el-table .el-tag {
font-size: 12px;
padding: 4px 10px;
line-height: 1;
}
/* 表格内开关优化 */
.el-table .el-switch {
height: 24px;
}
.el-table .el-switch__core {
height: 24px !important;
border-radius: 12px !important;
}
.el-table .el-switch__core::after {
width: 20px;
height: 20px;
top: 2px;
}
.el-table .el-switch.is-checked .el-switch__core::after {
margin-left: -22px;
}
.url-cell {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.url-link {
color: var(--ios-blue);
text-decoration: none;
font-weight: 500;
transition: opacity 0.2s;
}
.url-link:hover { opacity: 0.7; }
/* iOS风格开关 */
.el-switch.is-checked .el-switch__core {
background-color: var(--ios-green) !important;
border-color: var(--ios-green) !important;
}
/* iOS风格标签页 */
.el-tabs__item {
font-weight: 500 !important;
font-size: 15px !important;
}
.el-tabs__item.is-active {
color: var(--ios-blue) !important;
}
.el-tabs__active-bar {
background-color: var(--ios-blue) !important;
}
/* iOS风格日志查看器 */
.log-viewer {
background: #1C1C1E;
border-radius: var(--radius-md);
padding: 20px;
min-height: 500px;
max-height: calc(100vh - 280px);
overflow-y: auto;
font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
font-size: 13px;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.2);
}
.log-entry {
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
gap: 14px;
align-items: flex-start;
}
.log-time { color: #98989D; min-width: 150px; font-size: 12px; }
.log-level {
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
min-width: 70px;
text-align: center;
}
.log-level.INFO { background: var(--ios-blue); color: white; }
.log-level.WARNING { background: var(--ios-orange); color: white; }
.log-level.ERROR { background: var(--ios-red); color: white; }
.log-level.DEBUG { background: var(--ios-gray); color: white; }
.log-message { color: #E5E5EA; flex: 1; word-break: break-all; line-height: 1.5; }
/* iOS风格配置组 */
.config-group {
margin-bottom: 32px;
}
.config-group-title {
font-size: 13px;
font-weight: 600;
color: var(--text-tertiary);
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--ios-gray-5);
text-transform: uppercase;
letter-spacing: 0.8px;
}
/* iOS风格上传区域 */
.upload-area {
border: 2px dashed var(--ios-gray-4);
border-radius: var(--radius-lg);
padding: 48px;
text-align: center;
transition: all 0.3s ease;
cursor: pointer;
background: rgba(255, 255, 255, 0.5);
}
.upload-area:hover {
border-color: var(--ios-blue);
background: rgba(0, 122, 255, 0.04);
transform: scale(1.01);
}
.upload-icon {
font-size: 52px;
color: var(--ios-blue);
margin-bottom: 16px;
}
.upload-text {
color: var(--text-secondary);
font-size: 16px;
font-weight: 500;
}
.upload-hint {
font-size: 13px;
color: var(--ios-gray);
margin-top: 10px;
}
/* iOS风格分页 */
.pagination-wrapper {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.el-pagination {
font-weight: 500;
}
.el-pagination .el-pager li {
border-radius: 8px !important;
}
.el-pagination .el-pager li.active {
background: var(--ios-blue) !important;
}
/* iOS风格标签 */
.el-tag {
border-radius: 8px !important;
font-weight: 500 !important;
}
.el-tag--success {
background: rgba(52, 199, 89, 0.12) !important;
border-color: rgba(52, 199, 89, 0.2) !important;
color: var(--ios-green) !important;
}
.el-tag--danger {
background: rgba(255, 59, 48, 0.12) !important;
border-color: rgba(255, 59, 48, 0.2) !important;
color: var(--ios-red) !important;
}
/* iOS风格描述列表 */
.el-descriptions {
background: rgba(255, 255, 255, 0.5);
border-radius: var(--radius-md);
overflow: hidden;
}
.el-descriptions__header {
font-weight: 600;
color: var(--text-primary);
}
/* 响应式设计 */
@media (max-width: 1400px) {
.stat-cards { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 1024px) {
.chart-grid { grid-template-columns: 1fr; }
.toolbar { flex-direction: column; align-items: stretch; }
.toolbar .el-input, .toolbar .el-select { width: 100%; }
}
@media (max-width: 768px) {
.sidebar {
width: 72px;
padding: 16px 8px;
}
.sidebar .el-menu-item span { display: none; }
.sidebar .el-menu-item { justify-content: center; padding: 0; }
.sidebar .el-menu-item i { margin: 0; font-size: 22px; }
.content-wrapper { margin-left: 72px; padding: 16px; }
.stat-cards { grid-template-columns: repeat(2, 1fr); gap: 12px; }
.stat-card { padding: 16px; }
.stat-value { font-size: 26px; }
.stat-icon { width: 44px; height: 44px; }
.stat-icon i { font-size: 20px; }
.header { padding: 0 16px; }
.app-title { font-size: 16px; }
.chart-container { height: 220px; }
.card-header { padding: 16px; flex-wrap: wrap; gap: 12px; }
.card-body { padding: 16px; }
/* 移动端表格优化 */
.el-table { font-size: 13px; }
.url-cell { max-width: 160px; }
.action-btn { padding: 8px !important; font-size: 12px !important; }
.action-btn span { display: none; }
}
@media (max-width: 480px) {
.sidebar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: auto;
width: 100%;
height: 72px;
min-height: auto;
z-index: 99;
border-right: none;
border-top: 0.5px solid rgba(0, 0, 0, 0.1);
padding: 8px 4px;
display: flex;
align-items: center;
}
.sidebar .el-menu {
display: flex !important;
flex-direction: row !important;
width: 100%;
justify-content: space-around;
}
.sidebar .el-menu-item {
flex: 1;
height: 56px;
line-height: 56px;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0;
border-radius: var(--radius-sm);
}
.sidebar .el-menu-item i {
font-size: 20px;
margin: 0 0 2px 0;
}
.sidebar .el-menu-item span {
display: block;
font-size: 10px;
line-height: 1;
}
.sidebar .el-menu-item.is-active {
box-shadow: none;
}
.content-wrapper {
margin-left: 0;
margin-bottom: 72px;
padding: 12px;
}
.main-layout {
flex-direction: column;
}
.header {
padding: 0 12px;
}
.status-pill {
padding: 6px 10px;
font-size: 12px;
}
.stat-cards {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.stat-card {
padding: 14px;
}
.stat-icon {
width: 36px;
height: 36px;
margin-bottom: 10px;
}
.stat-icon i { font-size: 18px; }
.stat-label { font-size: 12px; margin-bottom: 4px; }
.stat-value { font-size: 22px; }
.chart-card { padding: 14px; }
.chart-title { font-size: 14px; margin-bottom: 12px; }
.chart-container { height: 200px; }
.card { margin-bottom: 16px; }
.card-header { padding: 14px; }
.card-title { font-size: 16px; }
.card-body { padding: 14px; }
/* 移动端表格 */
.el-table { font-size: 12px; }
.el-table th, .el-table td { padding: 10px 0; }
.url-cell { max-width: 120px; font-size: 12px; }
/* 隐藏部分表格列 */
.hide-on-mobile { display: none !important; }
/* 移动端按钮 */
.el-button { padding: 10px 16px !important; }
.action-btn { padding: 10px 8px !important; }
/* 移动端分页 */
.pagination-wrapper {
justify-content: center;
}
.el-pagination {
font-size: 12px;
}
.el-pagination .el-pager li {
min-width: 28px;
height: 28px;
line-height: 28px;
}
/* 移动端表单 */
.el-form-item { margin-bottom: 16px; }
.el-form-item__label { font-size: 13px; }
/* 移动端上传区域 */
.upload-area { padding: 24px 16px; }
.upload-icon { font-size: 36px; }
.upload-text { font-size: 14px; }
/* 移动端日志 */
.log-viewer {
max-height: 350px;
font-size: 11px;
padding: 14px;
}
.log-entry { flex-wrap: wrap; gap: 8px; }
.log-time { min-width: auto; font-size: 11px; }
.log-level { font-size: 10px; padding: 2px 8px; min-width: 55px; }
/* 移动端tabs */
.el-tabs__item {
font-size: 14px !important;
padding: 0 12px !important;
}
}
/* 触摸优化 */
@media (hover: none) and (pointer: coarse) {
.el-button, .action-btn, .el-menu-item, .refresh-btn {
min-height: 44px;
min-width: 44px;
}
.el-switch {
transform: scale(1.1);
}
.el-checkbox__inner {
width: 22px;
height: 22px;
}
}
/* 滚动条美化 */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--ios-gray-4);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover { background: var(--ios-gray-3); }
/* 动画效果 */
.card, .stat-card, .chart-card {
animation: fadeInUp 0.4s ease-out backwards;
}
/* 交错动画延迟 */
.stat-card:nth-child(1) { animation-delay: 0.05s; }
.stat-card:nth-child(2) { animation-delay: 0.1s; }
.stat-card:nth-child(3) { animation-delay: 0.15s; }
.stat-card:nth-child(4) { animation-delay: 0.2s; }
.chart-card:nth-child(1) { animation-delay: 0.25s; }
.chart-card:nth-child(2) { animation-delay: 0.3s; }
.chart-card:nth-child(3) { animation-delay: 0.35s; }
.chart-card:nth-child(4) { animation-delay: 0.4s; }
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 骨架屏动画 */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg,
var(--ios-gray-5) 25%,
var(--ios-gray-6) 37%,
var(--ios-gray-5) 63%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-sm);
}
.skeleton-text {
height: 14px;
margin-bottom: 8px;
border-radius: 7px;
}
.skeleton-text.lg { height: 32px; width: 80px; border-radius: 8px; }
.skeleton-text.sm { height: 12px; width: 60%; }
.skeleton-text.xs { height: 10px; width: 40%; }
.skeleton-icon {
width: 52px;
height: 52px;
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.skeleton-chart {
height: 280px;
border-radius: var(--radius-md);
}
.skeleton-row {
display: flex;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid var(--ios-gray-5);
}
.skeleton-row .skeleton-cell {
margin-right: 12px;
flex-shrink: 0;
}
.skeleton-row .skeleton-cell.flex { flex: 1; }
/* 统计卡片骨架屏 */
.stat-card-skeleton {
background: var(--card-bg);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow-md);
border: 0.5px solid rgba(255, 255, 255, 0.5);
}
/* 表格骨架屏 */
.table-skeleton {
padding: 0 20px;
}
/* 加载遮罩优化 */
.el-loading-mask {
background-color: rgba(255, 255, 255, 0.85) !important;
backdrop-filter: blur(8px);
}
.el-loading-spinner .circular {
width: 36px !important;
height: 36px !important;
}
.el-loading-spinner .path {
stroke: var(--ios-blue) !important;
stroke-width: 3;
}
.el-loading-text {
color: var(--ios-blue) !important;
font-weight: 500;
margin-top: 8px;
}
/* 按钮加载状态 */
.el-button.is-loading {
position: relative;
pointer-events: none;
}
.el-button.is-loading:before {
content: '';
position: absolute;
left: -1px;
top: -1px;
right: -1px;
bottom: -1px;
border-radius: inherit;
background-color: rgba(255,255,255,0.35);
}
/* 脉冲动画 - 数据更新提示 */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.data-updating {
animation: pulse 1s ease-in-out infinite;
}
/* 渐显效果 - 内容区域 */
.content-fade-enter-active {
transition: all 0.3s ease-out;
}
.content-fade-leave-active {
transition: all 0.2s ease-in;
}
.content-fade-enter, .content-fade-leave-to {
opacity: 0;
transform: translateY(10px);
}
/* 刷新按钮旋转动画 */
.refresh-btn.refreshing i {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 表格行淡入动画 */
.el-table__body-wrapper {
transition: opacity 0.25s ease;
}
.table-loading .el-table__body-wrapper {
opacity: 0.4;
}
/* 空状态优化 */
.empty-state {
padding: 60px 20px;
text-align: center;
color: var(--ios-gray);
}
.empty-state i {
font-size: 48px;
color: var(--ios-gray-3);
margin-bottom: 16px;
}
.empty-state p {
font-size: 15px;
font-weight: 500;
}
/* 登录页面样式 */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--ios-bg);
padding: 20px;
}
.login-card {
width: 100%;
max-width: 400px;
background: var(--card-bg);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
padding: 48px 40px;
border: 0.5px solid rgba(255, 255, 255, 0.5);
animation: fadeInUp 0.5s ease-out;
}
.login-header {
text-align: center;
margin-bottom: 40px;
}
.login-logo {
width: 72px;
height: 72px;
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
box-shadow: 0 8px 24px rgba(0, 122, 255, 0.3);
}
.login-logo i {
font-size: 36px;
color: white;
}
.login-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.5px;
margin-bottom: 8px;
}
.login-subtitle {
font-size: 14px;
color: var(--text-tertiary);
}
.login-form .el-form-item {
margin-bottom: 24px;
}
.login-form .el-input__inner {
height: 48px !important;
font-size: 15px !important;
padding-left: 44px !important;
}
.login-form .el-input__prefix {
left: 12px;
display: flex;
align-items: center;
height: 100%;
}
.login-form .el-input__prefix i {
font-size: 18px;
color: var(--ios-gray);
line-height: 1;
}
.login-form .el-input__suffix {
display: flex;
align-items: center;
height: 100%;
}
.login-btn {
width: 100%;
height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: var(--radius-md) !important;
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%) !important;
border: none !important;
box-shadow: 0 4px 16px rgba(0, 122, 255, 0.3);
transition: all 0.3s ease !important;
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 122, 255, 0.4);
}
.login-btn:active {
transform: translateY(0);
}
/* 用户信息下拉 */
.user-dropdown {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px 12px;
border-radius: 20px;
background: var(--ios-gray-6);
transition: all 0.2s ease;
}
.user-dropdown:hover {
background: var(--ios-gray-5);
}
.user-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
display: flex;
align-items: center;
justify-content: center;
}
.user-avatar i {
font-size: 14px;
color: white;
}
.user-name {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
/* 聊天记录对话框样式 */
.chat-log-dialog .el-dialog__body {
padding: 0;
}
.chat-log-content {
max-height: 65vh;
overflow-y: auto;
background: #1C1C1E;
border-radius: 0;
}
.chat-log-content pre {
margin: 0;
padding: 20px 24px;
font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
font-size: 13px;
line-height: 1.7;
color: #E5E5EA;
white-space: pre-wrap;
word-wrap: break-word;
}
</style>
</head>
<body>
<div id="app" v-loading.fullscreen.lock="fullLoading">
<!-- ==================== 登录页面 ==================== -->
<div class="login-container" v-if="!isLoggedIn">
<div class="login-card">
<div class="login-header">
<div class="login-logo">
<i class="el-icon-data-board"></i>
</div>
<h1 class="login-title">MIP 广告自动化</h1>
<p class="login-subtitle">请登录以继续使用系统</p>
</div>
<el-form :model="loginForm" :rules="loginRules" ref="loginForm" class="login-form" @submit.native.prevent="handleLogin">
<el-form-item prop="username">
<el-input v-model="loginForm.username" placeholder="请输入用户名" prefix-icon="el-icon-user" clearable></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码" prefix-icon="el-icon-lock" show-password @keyup.enter.native="handleLogin"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" class="login-btn" :loading="loginLoading" @click="handleLogin">登 录</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- ==================== 主应用界面 ==================== -->
<template v-else>
<!-- 顶部导航 -->
<header class="header">
<div class="header-left">
<div class="logo">
<i class="el-icon-data-board" style="font-size: 20px; color: white;"></i>
</div>
<span class="app-title">MIP 广告自动化</span>
</div>
<div class="header-right">
<div class="status-pill">
<span class="status-indicator" :class="schedulerRunning ? 'running' : 'stopped'"></span>
<span>{{ schedulerStatus }}</span>
</div>
<button class="refresh-btn" :class="{ refreshing: isRefreshing }" @click="refreshAll" title="刷新">
<i class="el-icon-refresh" style="font-size: 16px; color: var(--text-secondary);"></i>
</button>
<el-dropdown @command="handleUserCommand" trigger="click">
<div class="user-dropdown">
<div class="user-avatar"><i class="el-icon-user"></i></div>
<span class="user-name">{{ username }}</span>
<i class="el-icon-arrow-down" style="font-size: 12px; color: var(--ios-gray);"></i>
</div>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="logout" icon="el-icon-switch-button">退出登录</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</header>
<!-- 主体布局 -->
<div class="main-layout">
<!-- 侧边栏 -->
<aside class="sidebar">
<el-menu :default-active="activeMenu" background-color="transparent" text-color="#3C3C43" active-text-color="#ffffff" @select="handleMenuSelect">
<el-menu-item index="dashboard"><i class="el-icon-data-analysis"></i><span>数据概览</span></el-menu-item>
<el-menu-item index="scheduler"><i class="el-icon-time"></i><span>调度管理</span></el-menu-item>
<el-menu-item index="sites"><i class="el-icon-link"></i><span>链接管理</span></el-menu-item>
<el-menu-item index="database"><i class="el-icon-folder"></i><span>数据记录</span></el-menu-item>
<el-menu-item index="logs"><i class="el-icon-document"></i><span>系统日志</span></el-menu-item>
<el-menu-item index="config"><i class="el-icon-setting"></i><span>系统配置</span></el-menu-item>
<el-menu-item index="query-mining"><i class="el-icon-search"></i><span>Query挖掘</span></el-menu-item>
</el-menu>
</aside>
<!-- 内容区 -->
<main class="content-wrapper">
<!-- ==================== 数据概览 ==================== -->
<section v-show="activeMenu === 'dashboard'">
<!-- 统计卡片 - 骨架屏状态 -->
<div class="stat-cards" v-if="statsLoading">
<div class="stat-card-skeleton" v-for="i in 4" :key="'skeleton-'+i">
<div class="skeleton skeleton-icon"></div>
<div class="skeleton skeleton-text sm"></div>
<div class="skeleton skeleton-text lg"></div>
</div>
</div>
<!-- 统计卡片 - 正常状态 -->
<div class="stat-cards" v-else>
<div class="stat-card primary">
<div class="stat-icon"><i class="el-icon-files"></i></div>
<div class="stat-label">总站点数</div>
<div class="stat-value">{{ stats.total_sites || 0 }}</div>
</div>
<div class="stat-card success">
<div class="stat-icon"><i class="el-icon-mouse"></i></div>
<div class="stat-label">总点击次数</div>
<div class="stat-value">{{ stats.total_clicks || 0 }}</div>
</div>
<div class="stat-card warning">
<div class="stat-icon"><i class="el-icon-chat-dot-round"></i></div>
<div class="stat-label">获得回复</div>
<div class="stat-value">{{ stats.total_replies || 0 }}</div>
</div>
<div class="stat-card danger">
<div class="stat-icon"><i class="el-icon-data-line"></i></div>
<div class="stat-label">回复率</div>
<div class="stat-value">{{ stats.reply_rate || '0%' }}</div>
</div>
</div>
<!-- 图表区 - 骨架屏状态 -->
<div class="chart-grid" v-if="chartsLoading">
<div class="chart-card" v-for="i in 4" :key="'chart-skeleton-'+i">
<div class="skeleton skeleton-text" style="width: 120px; margin-bottom: 16px;"></div>
<div class="skeleton skeleton-chart"></div>
</div>
</div>
<!-- 图表区 - 正常状态 -->
<div class="chart-grid" v-else>
<div class="chart-card">
<div class="chart-title">点击趋势近7天</div>
<div id="trendChart" class="chart-container"></div>
</div>
<div class="chart-card">
<div class="chart-title">回复率分布</div>
<div id="pieChart" class="chart-container"></div>
</div>
<div class="chart-card">
<div class="chart-title">时段分布近7天</div>
<div id="hourlyChart" class="chart-container"></div>
</div>
<div class="chart-card">
<div class="chart-title">Top10活跃站点</div>
<div id="topSitesChart" class="chart-container"></div>
</div>
</div>
</section>
<!-- ==================== 调度管理 ==================== -->
<section v-show="activeMenu === 'scheduler'">
<el-card shadow="never">
<div slot="header" style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 16px; font-weight: 600;">调度器控制</span>
<el-tag :type="schedulerRunning ? 'success' : 'danger'" effect="dark">
{{ schedulerRunning ? '运行中' : '已停止' }}
</el-tag>
</div>
<el-row :gutter="20">
<el-col :span="24">
<el-button type="success" icon="el-icon-video-play" @click="startScheduler" :disabled="schedulerRunning">启动调度器</el-button>
<el-button type="danger" icon="el-icon-video-pause" @click="stopScheduler" :disabled="!schedulerRunning">停止调度器</el-button>
</el-col>
</el-row>
<el-divider></el-divider>
<el-descriptions title="调度规则" :column="2" border>
<el-descriptions-item label="工作时间">{{ config.work_time?.start_hour || 9 }}:00 - {{ config.work_time?.end_hour || 21 }}:00</el-descriptions-item>
<el-descriptions-item label="点击间隔">{{ config.click_strategy?.click_interval_minutes || 30 }} 分钟</el-descriptions-item>
<el-descriptions-item label="每日点击">{{ config.click_strategy?.min_click_count || 1 }} - {{ config.click_strategy?.max_click_count || 3 }} 次/站点</el-descriptions-item>
<el-descriptions-item label="回复等待">{{ config.task_config?.reply_wait_timeout || 30 }} 秒</el-descriptions-item>
</el-descriptions>
</el-card>
</section>
<!-- ==================== 链接管理 ==================== -->
<section v-show="activeMenu === 'sites'">
<!-- 站点列表 -->
<el-card shadow="never">
<div slot="header" style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 16px; font-weight: 600;">站点列表</span>
<div>
<el-upload
ref="csvUpload"
:action="apiBase + '/api/sites/import'"
:data="importParams"
:headers="uploadHeaders"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:before-upload="beforeUpload"
:show-file-list="false"
:disabled="uploadLoading"
with-credentials
accept=".xlsx,.xls,.csv"
style="display: inline-block; margin-right: 8px;">
<el-button size="small" type="success" icon="el-icon-upload2" :loading="uploadLoading">
{{ uploadLoading ? (importTask ? `导入中 ${importTask.current || 0}/${importTask.total || 0}` : '解析中...') : '导入' }}
</el-button>
</el-upload>
<el-button size="small" type="primary" plain icon="el-icon-download" @click="exportSites">导出</el-button>
<el-button size="small" icon="el-icon-refresh" @click="loadSites">刷新</el-button>
</div>
</div>
<el-row :gutter="16" style="margin-bottom: 16px;">
<el-col :span="6">
<el-input v-model="siteFilter.keyword" placeholder="搜索URL/名称" clearable @clear="loadSites" @keyup.enter.native="loadSites">
<el-button slot="append" icon="el-icon-search" @click="loadSites"></el-button>
</el-input>
</el-col>
<el-col :span="4">
<el-select v-model="siteFilter.status" placeholder="状态" clearable @change="loadSites" style="width: 100%;">
<el-option label="启用点击" value="active"></el-option>
<el-option label="暂停点击" value="inactive"></el-option>
</el-select>
</el-col>
<el-col :span="4">
<el-select v-model="siteFilter.sort_by" placeholder="排序" @change="loadSites" style="width: 100%;">
<el-option label="创建时间" value="created_at"></el-option>
<el-option label="点击次数" value="click_count"></el-option>
<el-option label="回复次数" value="reply_count"></el-option>
</el-select>
</el-col>
<el-col :span="10" v-if="selectedSites.length">
<el-button-group>
<el-button size="small" type="success" @click="batchEnableSites">启用选中</el-button>
<el-button size="small" type="warning" @click="batchDisableSites">暂停选中</el-button>
<el-button size="small" type="danger" @click="batchDeleteSites">删除选中</el-button>
</el-button-group>
</el-col>
</el-row>
<el-table :data="siteList" v-loading="sitesLoading" @selection-change="handleSiteSelection" stripe border>
<el-table-column type="selection" width="50"></el-table-column>
<el-table-column prop="site_url" label="链接地址" min-width="300">
<template slot-scope="scope">
<el-link :href="scope.row.site_url" target="_blank" type="primary" :underline="false">
{{ scope.row.site_url }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template slot-scope="scope">
<el-switch
v-model="scope.row.status"
active-value="active"
inactive-value="inactive"
active-color="#67C23A"
@change="toggleSiteStatus(scope.row)">
</el-switch>
</template>
</el-table-column>
<el-table-column prop="click_count" label="点击" width="80" align="center"></el-table-column>
<el-table-column prop="reply_count" label="回复" width="80" align="center"></el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170"></el-table-column>
<el-table-column label="操作" width="150" align="center">
<template slot-scope="scope">
<el-button type="text" size="small" @click="resetSite(scope.row)">
<i class="el-icon-refresh-right"></i> 重置
</el-button>
<el-button type="text" size="small" style="color: #F56C6C;" @click="deleteSite(scope.row)">
<i class="el-icon-delete"></i> 删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top: 16px; text-align: right;"
@size-change="handleSizeChange"
@current-change="handlePageChange"
:current-page="pagination.page"
:page-sizes="[10, 20, 50, 100]"
:page-size="pagination.page_size"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total">
</el-pagination>
</el-card>
</section>
<!-- ==================== 数据记录 ==================== -->
<section v-show="activeMenu === 'database'">
<el-card shadow="never">
<el-tabs v-model="dbTab" @tab-click="handleDbTabClick">
<el-tab-pane label="点击记录" name="clicks">
<el-row :gutter="16" style="margin-bottom: 16px;">
<el-col :span="8">
<el-date-picker v-model="clickFilter.dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="yyyy-MM-dd" @change="loadClicks" style="width: 100%;"></el-date-picker>
</el-col>
<el-col :span="16" style="text-align: right;">
<el-button type="primary" size="small" plain icon="el-icon-download" @click="exportClicks">导出</el-button>
<el-button size="small" icon="el-icon-refresh" @click="loadClicks">刷新</el-button>
</el-col>
</el-row>
<el-table :data="clickList" v-loading="clicksLoading" stripe border>
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="site_name" label="站点" width="150"></el-table-column>
<el-table-column prop="site_url" label="URL" min-width="280">
<template slot-scope="scope">
<el-link :href="scope.row.site_url" target="_blank" type="primary" :underline="false">{{ scope.row.site_url }}</el-link>
</template>
</el-table-column>
<el-table-column prop="click_time" label="点击时间" width="170"></el-table-column>
<el-table-column prop="user_ip" label="代理IP" width="140"></el-table-column>
<el-table-column prop="device_type" label="设备" width="80" align="center"></el-table-column>
</el-table>
<el-pagination
style="margin-top: 16px; text-align: right;"
@current-change="handleClickPageChange"
:current-page="clickPagination.page"
:page-size="clickPagination.page_size"
layout="total, prev, pager, next"
:total="clickPagination.total">
</el-pagination>
</el-tab-pane>
<el-tab-pane label="互动记录" name="interactions">
<el-row :gutter="16" style="margin-bottom: 16px;">
<el-col :span="8">
<el-date-picker v-model="interactionFilter.dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="yyyy-MM-dd" @change="loadInteractions" style="width: 100%;"></el-date-picker>
</el-col>
<el-col :span="4">
<el-select v-model="interactionFilter.status" placeholder="状态" clearable @change="loadInteractions" style="width: 100%;">
<el-option label="成功" value="success"></el-option>
<el-option label="失败" value="failed"></el-option>
</el-select>
</el-col>
<el-col :span="12" style="text-align: right;">
<el-button type="primary" size="small" plain icon="el-icon-download" @click="exportInteractions">导出</el-button>
</el-col>
</el-row>
<el-table :data="interactionList" v-loading="interactionsLoading" stripe border>
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="site_name" label="站点" width="150"></el-table-column>
<el-table-column prop="interaction_time" label="时间" width="170"></el-table-column>
<el-table-column prop="reply_content" label="发送内容" min-width="200">
<template slot-scope="scope">
<span>{{ scope.row.reply_content || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="response_received" label="收到回复" width="100" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.response_received ? 'success' : 'info'" size="mini">{{ scope.row.response_received ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="interaction_status" label="状态" width="90" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.interaction_status === 'success' ? 'success' : 'danger'" size="mini">{{ scope.row.interaction_status || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="showChatLog(scope.row)" :disabled="!scope.row.response_content">聊天记录</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top: 16px; text-align: right;"
@current-change="handleInteractionPageChange"
:current-page="interactionPagination.page"
:page-size="interactionPagination.page_size"
layout="total, prev, pager, next"
:total="interactionPagination.total">
</el-pagination>
</el-tab-pane>
</el-tabs>
</el-card>
</section>
<!-- ==================== 系统日志 ==================== -->
<section v-show="activeMenu === 'logs'">
<el-card shadow="never">
<div slot="header" style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 16px; font-weight: 600;">实时日志</span>
<div>
<el-select v-model="logFilter.level" size="small" style="width: 100px; margin-right: 8px;" @change="loadLogs">
<el-option label="全部" value="ALL"></el-option>
<el-option label="INFO" value="INFO"></el-option>
<el-option label="WARNING" value="WARNING"></el-option>
<el-option label="ERROR" value="ERROR"></el-option>
</el-select>
<el-switch v-model="logAutoScroll" active-text="自动滚动" style="margin-right: 12px;"></el-switch>
<el-button size="small" icon="el-icon-refresh" @click="loadLogs">刷新</el-button>
</div>
</div>
<div class="log-viewer" ref="logViewer">
<div v-for="(log, i) in logs" :key="i" class="log-entry">
<span class="log-time">{{ log.time }}</span>
<span class="log-level" :class="log.level">{{ log.level }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
<el-empty v-if="!logs.length" description="暂无日志"></el-empty>
</div>
</el-card>
</section>
<!-- ==================== 系统配置 ==================== -->
<section v-show="activeMenu === 'config'">
<el-card shadow="never">
<div slot="header" style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 16px; font-weight: 600;">系统配置</span>
<el-button type="primary" size="small" @click="saveConfig" :loading="configSaving">保存配置</el-button>
</div>
<el-form :model="configForm" label-width="130px">
<el-divider content-position="left">点击策略</el-divider>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="最小点击次数">
<el-input-number v-model="configForm.min_click_count" :min="1" :max="100"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="最大点击次数">
<el-input-number v-model="configForm.max_click_count" :min="1" :max="100"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="点击间隔(分钟)">
<el-input-number v-model="configForm.click_interval_minutes" :min="1" :max="1440"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">工作时间</el-divider>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="开始时间(时)">
<el-input-number v-model="configForm.start_hour" :min="0" :max="23"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="结束时间(时)">
<el-input-number v-model="configForm.end_hour" :min="0" :max="23"></el-input-number>
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">任务配置</el-divider>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="任务最小间隔">
<el-input-number v-model="configForm.min_interval_minutes" :min="1" :max="60"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="任务最大间隔">
<el-input-number v-model="configForm.max_interval_minutes" :min="1" :max="60"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="回复等待(秒)">
<el-input-number v-model="configForm.reply_wait_timeout" :min="5" :max="300"></el-input-number>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
</section>
<!-- ==================== Query挖掘 ==================== -->
<section v-show="activeMenu === 'query-mining'">
<!-- 上传区域 -->
<el-card shadow="never" style="margin-bottom: 20px;">
<div slot="header" style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 16px; font-weight: 600;">上传关键词文件</span>
<el-button size="small" type="warning" icon="el-icon-video-play" @click="triggerQueryScan" :loading="queryScanLoading">手动检测</el-button>
</div>
<!-- 导入模式选择 -->
<el-radio-group v-model="queryImportMode" style="margin-bottom: 16px;">
<el-radio-button label="query_only">仅导入Query</el-radio-button>
<el-radio-button label="full_import">完整导入(科室+关键字+Query</el-radio-button>
</el-radio-group>
<!-- 模式1仅导入query -->
<div v-if="queryImportMode === 'query_only'">
<el-alert type="info" :closable="false" style="margin-bottom: 12px;">
<div slot="title">
<b>仅导入Query</b> - Excel必须只有 <b>1列</b>query多余列会导致报错
</div>
<div style="margin-top: 6px; font-size: 12px; color: #909399;">
导入后 query_status = draft所有字段使用默认值
</div>
</el-alert>
<el-table :data="queryOnlyExample" border size="mini" style="width: 220px; margin-bottom: 12px;">
<el-table-column prop="query" label="query" width="220"></el-table-column>
</el-table>
<el-upload
ref="queryUploadMode1"
:action="apiBase + '/api/query/upload'"
:data="{import_mode: 'query_only'}"
:headers="uploadHeaders"
:on-success="handleQueryUploadSuccess"
:on-error="handleQueryUploadError"
:before-upload="beforeQueryUploadMode1"
:show-file-list="false"
:disabled="queryUploadLoading"
with-credentials
drag
accept=".xlsx,.xls"
style="width: 100%;">
<i class="el-icon-upload" style="font-size: 48px; color: #C0C4CC; margin-top: 20px;"></i>
<div class="el-upload__text" v-if="!queryUploadLoading">将Excel文件拖到此处<em>点击上传</em></div>
<div class="el-upload__text" v-else>上传中,请稍候...</div>
<div class="el-upload__tip" slot="tip" style="color: #F56C6C;">
Excel只能有1列query超过1列将报错
</div>
</el-upload>
</div>
<!-- 模式2完整导入 -->
<div v-if="queryImportMode === 'full_import'">
<el-alert type="info" :closable="false" style="margin-bottom: 12px;">
<div slot="title">
<b>完整导入</b> - Excel必须恰好 <b>3列</b>第1列 科室第2列 关键字第3列 query
</div>
<div style="margin-top: 6px; font-size: 12px; color: #909399;">
导入后 query_status = manual_review科室信息写入 department 字段
</div>
</el-alert>
<el-table :data="queryFullExample" border size="mini" style="width: 500px; margin-bottom: 12px;">
<el-table-column prop="dept" label="科室" width="120"></el-table-column>
<el-table-column prop="keyword" label="关键字" width="160"></el-table-column>
<el-table-column prop="query" label="query" width="220"></el-table-column>
</el-table>
<el-upload
ref="queryUploadMode2"
:action="apiBase + '/api/query/upload'"
:data="{import_mode: 'full_import'}"
:headers="uploadHeaders"
:on-success="handleQueryUploadSuccess"
:on-error="handleQueryUploadError"
:before-upload="beforeQueryUploadMode2"
:show-file-list="false"
:disabled="queryUploadLoading"
with-credentials
drag
accept=".xlsx,.xls"
style="width: 100%;">
<i class="el-icon-upload" style="font-size: 48px; color: #C0C4CC; margin-top: 20px;"></i>
<div class="el-upload__text" v-if="!queryUploadLoading">将Excel文件拖到此处<em>点击上传</em></div>
<div class="el-upload__text" v-else>上传中,请稍候...</div>
<div class="el-upload__tip" slot="tip" style="color: #F56C6C;">
Excel必须恰好3列科室、关键字、query列数不符将报错
</div>
</el-upload>
</div>
</el-card>
<!-- 导入记录 -->
<el-card shadow="never">
<div slot="header" style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 16px; font-weight: 600;">导入记录</span>
<el-button size="small" icon="el-icon-refresh" @click="loadQueryImportRecords">刷新</el-button>
</div>
<el-table :data="queryImportRecords" v-loading="queryRecordsLoading" stripe border>
<el-table-column prop="filename" label="文件名" min-width="200"></el-table-column>
<el-table-column prop="upload_time" label="上传时间" width="170"></el-table-column>
<el-table-column prop="import_time" label="导入时间" width="170"></el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template slot-scope="scope">
<el-tag size="small" :type="{'pending':'info','running':'','completed':'success','failed':'danger'}[scope.row.status] || 'info'">
{{ {'pending':'待处理','running':'导入中','completed':'已完成','failed':'失败'}[scope.row.status] || scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_count" label="总数" width="80" align="center"></el-table-column>
<el-table-column prop="success_count" label="成功" width="80" align="center">
<template slot-scope="scope">
<span style="color: #67C23A;">{{ scope.row.success_count }}</span>
</template>
</el-table-column>
<el-table-column prop="skip_count" label="跳过" width="80" align="center">
<template slot-scope="scope">
<span style="color: #E6A23C;">{{ scope.row.skip_count }}</span>
</template>
</el-table-column>
<el-table-column prop="fail_count" label="失败" width="80" align="center">
<template slot-scope="scope">
<span style="color: #F56C6C;">{{ scope.row.fail_count }}</span>
</template>
</el-table-column>
<el-table-column label="错误信息" min-width="150">
<template slot-scope="scope">
<span v-if="scope.row.error_message" style="color: #F56C6C; font-size: 12px;">{{ scope.row.error_message }}</span>
<span v-else style="color: #C0C4CC;">-</span>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top: 16px; text-align: right;"
@current-change="handleQueryPageChange"
:current-page="queryPagination.page"
:page-size="queryPagination.page_size"
:total="queryPagination.total"
layout="total, prev, pager, next">
</el-pagination>
</el-card>
</section>
</main>
<el-dialog :visible.sync="chatLogDialog" :title="chatLogTitle" width="65%" top="8vh" custom-class="chat-log-dialog">
<div class="chat-log-content">
<pre>{{ chatLogContent }}</pre>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="chatLogDialog = false">关闭</el-button>
<el-button type="primary" @click="copyChatLog">复制内容</el-button>
</span>
</el-dialog>
</template>
</div>
<script>
new Vue({
el: '#app',
data() {
return {
// 登录相关
isLoggedIn: false,
username: '',
loginLoading: false,
loginForm: {
username: '',
password: ''
},
loginRules: {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
},
activeMenu: 'dashboard',
fullLoading: false,
isRefreshing: false,
statsLoading: true,
chartsLoading: true,
schedulerStatus: '检测中...',
schedulerRunning: false,
stats: {},
config: {},
// 链接管理
siteList: [],
sitesLoading: false,
selectedSites: [],
siteFilter: { keyword: '', status: '', sort_by: 'created_at' },
pagination: { page: 1, page_size: 20, total: 0 },
importParams: {},
uploadLoading: false,
uploadHeaders: {},
importTask: null,
importProgress: 0,
importTimer: null,
// 数据记录
dbTab: 'clicks',
clickList: [],
clicksLoading: false,
clickFilter: { dateRange: [] },
clickPagination: { page: 1, page_size: 20, total: 0 },
interactionList: [],
interactionsLoading: false,
interactionFilter: { dateRange: [], status: '' },
interactionPagination: { page: 1, page_size: 20, total: 0 },
chatLogDialog: false,
chatLogContent: '',
chatLogTitle: '',
// 日志
logs: [],
logFilter: { level: 'ALL' },
logAutoScroll: true,
logTimer: null,
// 配置
configForm: {},
configSaving: false,
// Query挖掘
queryImportMode: 'query_only',
queryImportRecords: [],
queryRecordsLoading: false,
queryUploadLoading: false,
queryScanLoading: false,
queryPagination: { page: 1, page_size: 20, total: 0 },
queryPollTimer: null,
queryOnlyExample: [
{ query: '胃癌早期症状有哪些' },
{ query: '高血压吃什么药好' }
],
queryFullExample: [
{ dept: '消化内科', keyword: '胃癌', query: '胃癌早期症状有哪些' },
{ dept: '心血管内科', keyword: '高血压', query: '高血压吃什么药好' }
],
// 图表
charts: {},
apiBase: ''
}
},
mounted() {
this.apiBase = window.location.origin;
this.checkAuth();
},
beforeDestroy() {
if (this.logTimer) clearInterval(this.logTimer);
if (this.queryPollTimer) clearInterval(this.queryPollTimer);
Object.values(this.charts).forEach(c => c && c.dispose());
},
methods: {
// 检查登录状态
async checkAuth() {
try {
const { data } = await axios.get(`${this.apiBase}/api/auth/check`, { withCredentials: true });
if (data.success && data.data.logged_in) {
this.isLoggedIn = true;
this.username = data.data.username;
this.init();
}
} catch (e) {
console.error('检查登录状态失败', e);
}
},
// 登录
handleLogin() {
this.$refs.loginForm.validate(async (valid) => {
if (!valid) return;
this.loginLoading = true;
try {
const { data } = await axios.post(`${this.apiBase}/api/auth/login`, this.loginForm, { withCredentials: true });
if (data.success) {
this.$message.success('登录成功');
this.isLoggedIn = true;
this.username = data.data.username;
this.loginForm = { username: '', password: '' };
this.init();
} else {
this.$message.error(data.message || '登录失败');
}
} catch (e) {
this.$message.error(e.response?.data?.message || '登录失败');
}
this.loginLoading = false;
});
},
// 登出
async handleLogout() {
try {
await axios.post(`${this.apiBase}/api/auth/logout`, {}, { withCredentials: true });
this.isLoggedIn = false;
this.username = '';
this.$message.success('已退出登录');
} catch (e) {
console.error('登出失败', e);
}
},
// 用户下拉菜单
handleUserCommand(command) {
if (command === 'logout') {
this.$confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.handleLogout();
}).catch(() => {});
}
},
async init() {
this.statsLoading = true;
this.chartsLoading = true;
await Promise.all([this.getSchedulerStatus(), this.getStatistics(), this.getConfig()]);
this.loadSites();
this.$nextTick(() => { this.initCharts(); this.loadChartData(); });
setInterval(() => { this.getSchedulerStatus(); this.getStatistics(); }, 5000);
// 检查是否有进行中的导入任务
this.checkPendingImportTasks();
},
async checkPendingImportTasks() {
try {
const { data } = await axios.get(`${this.apiBase}/api/sites/import/tasks`);
if (data.success && data.data.length > 0) {
const runningTask = data.data.find(t => t.status === 'running' || t.status === 'pending');
if (runningTask) {
this.uploadLoading = true;
this.importTask = runningTask;
this.importProgress = runningTask.progress || 0;
this.startImportPolling(runningTask.id);
this.$message.info(`检测到正在进行的导入任务:${runningTask.filename}`);
}
}
} catch (e) {
console.error('检查导入任务失败', e);
}
},
handleMenuSelect(index) {
this.activeMenu = index;
if (index === 'dashboard') this.$nextTick(() => this.resizeCharts());
else if (index === 'database') this.loadClicks();
else if (index === 'logs') { this.loadLogs(); this.startLogPolling(); }
else if (index === 'config') this.getConfig();
else if (index === 'query-mining') this.loadQueryImportRecords();
},
async refreshAll() {
this.isRefreshing = true;
await Promise.all([this.getStatistics(), this.loadChartData()]);
setTimeout(() => { this.isRefreshing = false; }, 600);
this.$message.success('已刷新');
},
// API调用
async getSchedulerStatus() {
try {
const { data } = await axios.get(`${this.apiBase}/api/scheduler/status`);
if (data.success) {
this.schedulerRunning = data.data.status === 'running';
const statusMap = {
'running': '运行中',
'stopped': '已停止',
'manual': '手动管理'
};
this.schedulerStatus = statusMap[data.data.status] || '手动管理';
}
} catch (e) { console.error(e); }
},
async getStatistics() {
try {
const { data } = await axios.get(`${this.apiBase}/api/statistics`);
if (data.success) this.stats = data.data;
} catch (e) { console.error(e); }
this.statsLoading = false;
},
async getConfig() {
try {
const { data } = await axios.get(`${this.apiBase}/api/config`);
if (data.success) {
this.config = data.data;
this.configForm = {
min_click_count: data.data.click_strategy?.min_click_count || 1,
max_click_count: data.data.click_strategy?.max_click_count || 3,
click_interval_minutes: data.data.click_strategy?.click_interval_minutes || 30,
start_hour: data.data.work_time?.start_hour || 9,
end_hour: data.data.work_time?.end_hour || 21,
min_interval_minutes: data.data.task_config?.min_interval_minutes || 3,
max_interval_minutes: data.data.task_config?.max_interval_minutes || 5,
reply_wait_timeout: data.data.task_config?.reply_wait_timeout || 30
};
}
} catch (e) { console.error(e); }
},
async startScheduler() {
try {
const { data } = await axios.post(`${this.apiBase}/api/scheduler/start`);
data.success ? this.$message.success('调度器已启动') : this.$message.error(data.message);
this.getSchedulerStatus();
} catch (e) { this.$message.error('启动失败'); }
},
async stopScheduler() {
try {
const { data } = await axios.post(`${this.apiBase}/api/scheduler/stop`);
data.success ? this.$message.success('调度器已停止') : this.$message.error(data.message);
this.getSchedulerStatus();
} catch (e) { this.$message.error('停止失败'); }
},
// 链接管理
beforeUpload(file) {
const validExts = ['.xlsx', '.xls', '.csv'];
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!validExts.includes(ext)) {
this.$message.error('请上传Excel(.xlsx/.xls)或CSV(.csv)文件');
return false;
}
this.uploadLoading = true;
this.importTask = null;
this.importProgress = 0;
return true;
},
handleUploadSuccess(res) {
if (res.success && res.task_id) {
// 异步导入:开始轮询进度
this.$message.info(`开始导入 ${res.total} 条数据...`);
this.startImportPolling(res.task_id);
} else if (res.success) {
// 同步导入完成(兼容旧接口)
this.uploadLoading = false;
this.$message.success(res.message);
this.loadSites();
this.getStatistics();
} else {
this.uploadLoading = false;
this.$message.error(res.message || '导入失败');
}
},
handleUploadError(err) {
this.uploadLoading = false;
this.importTask = null;
this.$message.error('上传失败,请检查网络连接');
console.error('上传错误:', err);
},
startImportPolling(taskId) {
// 轮询导入进度
this.importTimer = setInterval(async () => {
try {
const { data } = await axios.get(`${this.apiBase}/api/sites/import/progress/${taskId}`);
if (data.success) {
this.importTask = data.data;
this.importProgress = data.data.progress || 0;
if (data.data.status === 'completed') {
this.stopImportPolling();
this.uploadLoading = false;
const s = data.data.stats;
this.$notify({
title: '导入完成',
message: `成功: ${s.success} | 重复: ${s.duplicate} | 跳过: ${s.skipped} | 失败: ${s.failed}`,
type: 'success',
duration: 5000
});
this.loadSites();
this.getStatistics();
this.importTask = null;
} else if (data.data.status === 'error') {
this.stopImportPolling();
this.uploadLoading = false;
this.$message.error(data.data.error || '导入异常');
this.importTask = null;
} else if (data.data.status === 'cancelled') {
this.stopImportPolling();
this.uploadLoading = false;
this.$message.warning('导入已取消');
this.loadSites();
this.importTask = null;
}
}
} catch (e) {
console.error('获取导入进度失败', e);
}
}, 500);
},
stopImportPolling() {
if (this.importTimer) {
clearInterval(this.importTimer);
this.importTimer = null;
}
},
async cancelImport() {
if (!this.importTask) return;
try {
await axios.post(`${this.apiBase}/api/sites/import/cancel/${this.importTask.id}`, {}, { withCredentials: true });
this.$message.info('正在取消导入...');
} catch (e) {
this.$message.error('取消失败');
}
},
async loadSites() {
this.sitesLoading = true;
try {
const params = { page: this.pagination.page, page_size: this.pagination.page_size, sort_by: this.siteFilter.sort_by, sort_order: 'desc' };
if (this.siteFilter.keyword) params.keyword = this.siteFilter.keyword;
if (this.siteFilter.status) params.status = this.siteFilter.status;
const { data } = await axios.get(`${this.apiBase}/api/sites/paginated`, { params });
if (data.success) { this.siteList = data.data.items; this.pagination.total = data.data.total; }
} catch (e) { this.$message.error('加载失败'); }
this.sitesLoading = false;
},
handleSiteSelection(sel) { this.selectedSites = sel; },
handlePageChange(page) { this.pagination.page = page; this.loadSites(); },
handleSizeChange(size) { this.pagination.page_size = size; this.pagination.page = 1; this.loadSites(); },
async toggleSiteStatus(site) {
try {
const { data } = await axios.put(`${this.apiBase}/api/sites/${site.id}/status`, { status: site.status });
data.success ? this.$message.success(site.status === 'active' ? '已启用点击' : '已暂停点击') : (this.$message.error(data.message), this.loadSites());
} catch (e) { this.$message.error('操作失败'); this.loadSites(); }
},
async batchEnableSites() {
if (!this.selectedSites.length) return;
try {
const { data } = await axios.post(`${this.apiBase}/api/sites/batch/status`, { site_ids: this.selectedSites.map(s => s.id), status: 'active' });
data.success ? (this.$message.success(data.message), this.loadSites()) : this.$message.error(data.message);
} catch (e) { this.$message.error('操作失败'); }
},
async batchDisableSites() {
if (!this.selectedSites.length) return;
try {
const { data } = await axios.post(`${this.apiBase}/api/sites/batch/status`, { site_ids: this.selectedSites.map(s => s.id), status: 'inactive' });
data.success ? (this.$message.success(data.message), this.loadSites()) : this.$message.error(data.message);
} catch (e) { this.$message.error('操作失败'); }
},
async batchDeleteSites() {
if (!this.selectedSites.length) return;
try {
await this.$confirm(`确定删除选中的 ${this.selectedSites.length} 个站点?`, '确认删除', { type: 'warning' });
const { data } = await axios.post(`${this.apiBase}/api/sites/batch/delete`, { site_ids: this.selectedSites.map(s => s.id) });
data.success ? (this.$message.success(data.message), this.selectedSites = [], this.loadSites(), this.getStatistics()) : this.$message.error(data.message);
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败'); }
},
async resetSite(site) {
try {
await this.$confirm('确定重置该链接?', '确认', { type: 'warning' });
const { data } = await axios.post(`${this.apiBase}/api/urls/${encodeURIComponent(site.site_url)}/reset`);
data.success ? (this.$message.success('重置成功'), this.loadSites()) : this.$message.error(data.message);
} catch (e) { if (e !== 'cancel') this.$message.error('重置失败'); }
},
async deleteSite(site) {
try {
await this.$confirm('确定删除该链接?', '确认', { type: 'warning' });
const { data } = await axios.delete(`${this.apiBase}/api/urls/${encodeURIComponent(site.site_url)}`);
data.success ? (this.$message.success('删除成功'), this.loadSites(), this.getStatistics()) : this.$message.error(data.message);
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败'); }
},
exportSites() {
const params = new URLSearchParams();
if (this.siteFilter.status) params.append('status', this.siteFilter.status);
if (this.siteFilter.keyword) params.append('keyword', this.siteFilter.keyword);
window.open(`${this.apiBase}/api/export/sites?${params}`);
},
// 数据记录
handleDbTabClick() { this.dbTab === 'clicks' ? this.loadClicks() : this.loadInteractions(); },
async loadClicks() {
this.clicksLoading = true;
try {
const params = { page: this.clickPagination.page, page_size: this.clickPagination.page_size };
if (this.clickFilter.dateRange?.length === 2) { params.start_date = this.clickFilter.dateRange[0]; params.end_date = this.clickFilter.dateRange[1]; }
const { data } = await axios.get(`${this.apiBase}/api/clicks/paginated`, { params });
if (data.success) { this.clickList = data.data.items; this.clickPagination.total = data.data.total; }
} catch (e) { console.error(e); }
this.clicksLoading = false;
},
handleClickPageChange(page) { this.clickPagination.page = page; this.loadClicks(); },
async loadInteractions() {
this.interactionsLoading = true;
try {
const params = { page: this.interactionPagination.page, page_size: this.interactionPagination.page_size };
if (this.interactionFilter.dateRange?.length === 2) { params.start_date = this.interactionFilter.dateRange[0]; params.end_date = this.interactionFilter.dateRange[1]; }
if (this.interactionFilter.status) params.status = this.interactionFilter.status;
const { data } = await axios.get(`${this.apiBase}/api/interactions/paginated`, { params });
if (data.success) { this.interactionList = data.data.items; this.interactionPagination.total = data.data.total; }
} catch (e) { console.error(e); }
this.interactionsLoading = false;
},
handleInteractionPageChange(page) { this.interactionPagination.page = page; this.loadInteractions(); },
showChatLog(row) {
if (!row.response_content) return;
this.chatLogTitle = `聊天记录 #${row.id} - ${row.site_name || '未知站点'}`;
this.chatLogContent = row.response_content;
this.chatLogDialog = true;
},
copyChatLog() {
if (!this.chatLogContent) return;
const textarea = document.createElement('textarea');
textarea.value = this.chatLogContent;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
this.$message.success('已复制到剪贴板');
} catch (e) {
this.$message.error('复制失败');
}
document.body.removeChild(textarea);
},
exportClicks() {
const params = new URLSearchParams();
if (this.clickFilter.dateRange?.length === 2) { params.append('start_date', this.clickFilter.dateRange[0]); params.append('end_date', this.clickFilter.dateRange[1]); }
window.open(`${this.apiBase}/api/export/clicks?${params}`);
},
exportInteractions() {
const params = new URLSearchParams();
if (this.interactionFilter.dateRange?.length === 2) { params.append('start_date', this.interactionFilter.dateRange[0]); params.append('end_date', this.interactionFilter.dateRange[1]); }
window.open(`${this.apiBase}/api/export/interactions?${params}`);
},
// 日志
async loadLogs() {
try {
const { data } = await axios.get(`${this.apiBase}/api/logs/stream`, { params: { limit: 100, level: this.logFilter.level } });
if (data.success) {
this.logs = data.data;
if (this.logAutoScroll) this.$nextTick(() => { const v = this.$refs.logViewer; if (v) v.scrollTop = v.scrollHeight; });
}
} catch (e) { console.error(e); }
},
startLogPolling() {
if (this.logTimer) clearInterval(this.logTimer);
this.logTimer = setInterval(() => { if (this.activeMenu === 'logs') this.loadLogs(); }, 2000);
},
// 配置
async saveConfig() {
this.configSaving = true;
try {
const { data } = await axios.post(`${this.apiBase}/api/config/update`, this.configForm);
data.success ? (this.$message.success(data.message), data.requires_restart && this.$message.warning('部分配置需重启生效'), this.getConfig()) : this.$message.error(data.message);
} catch (e) { this.$message.error('保存失败'); }
this.configSaving = false;
},
// Query挖掘
async loadQueryImportRecords() {
this.queryRecordsLoading = true;
try {
const { data } = await axios.get(`${this.apiBase}/api/query/import/records`, {
params: { page: this.queryPagination.page, page_size: this.queryPagination.page_size },
withCredentials: true
});
if (data.success) {
this.queryImportRecords = data.data.items || [];
this.queryPagination.total = data.data.total || 0;
// 如果有running状态的记录启动轮询
const hasRunning = this.queryImportRecords.some(r => r.status === 'running' || r.status === 'pending');
if (hasRunning && !this.queryPollTimer) {
this.startQueryPoll();
} else if (!hasRunning && this.queryPollTimer) {
this.stopQueryPoll();
}
}
} catch (e) { console.error('加载导入记录失败', e); }
this.queryRecordsLoading = false;
},
handleQueryPageChange(page) {
this.queryPagination.page = page;
this.loadQueryImportRecords();
},
beforeQueryUploadMode1(file) {
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!['.xlsx', '.xls'].includes(ext)) {
this.$message.error('请上传Excel文件(.xlsx/.xls)');
return false;
}
this.queryUploadLoading = true;
return true;
},
beforeQueryUploadMode2(file) {
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!['.xlsx', '.xls'].includes(ext)) {
this.$message.error('请上传Excel文件(.xlsx/.xls)');
return false;
}
this.queryUploadLoading = true;
return true;
},
handleQueryUploadSuccess(res) {
this.queryUploadLoading = false;
if (res.success) {
this.$message.success(`文件 ${res.filename} 上传成功,导入任务已启动`);
this.loadQueryImportRecords();
this.startQueryPoll();
} else {
this.$message.error(res.message || '上传失败');
}
},
handleQueryUploadError(err) {
this.queryUploadLoading = false;
this.$message.error('上传失败: ' + (err.message || '未知错误'));
},
async triggerQueryScan() {
this.queryScanLoading = true;
try {
const { data } = await axios.post(`${this.apiBase}/api/query/import/trigger`, {}, { withCredentials: true });
if (data.success) {
this.$message.success('目录扫描任务已启动');
setTimeout(() => this.loadQueryImportRecords(), 2000);
} else {
this.$message.error(data.message || '触发失败');
}
} catch (e) { this.$message.error('触发失败'); }
this.queryScanLoading = false;
},
startQueryPoll() {
if (this.queryPollTimer) return;
this.queryPollTimer = setInterval(() => {
if (this.activeMenu === 'query-mining') {
this.loadQueryImportRecords();
}
}, 3000);
},
stopQueryPoll() {
if (this.queryPollTimer) {
clearInterval(this.queryPollTimer);
this.queryPollTimer = null;
}
},
// 图表
initCharts() {
window.addEventListener('resize', () => this.resizeCharts());
},
ensureChartsInit() {
// 确保图表实例存在
const trendEl = document.getElementById('trendChart');
const pieEl = document.getElementById('pieChart');
const hourlyEl = document.getElementById('hourlyChart');
const topSitesEl = document.getElementById('topSitesChart');
if (trendEl && !this.charts.trend) {
this.charts.trend = echarts.init(trendEl);
}
if (pieEl && !this.charts.pie) {
this.charts.pie = echarts.init(pieEl);
}
if (hourlyEl && !this.charts.hourly) {
this.charts.hourly = echarts.init(hourlyEl);
}
if (topSitesEl && !this.charts.topSites) {
this.charts.topSites = echarts.init(topSitesEl);
}
},
resizeCharts() { Object.values(this.charts).forEach(c => c && c.resize()); },
async loadChartData() {
try {
const [t, p, h, s] = await Promise.all([
axios.get(`${this.apiBase}/api/statistics/trend?days=7`),
axios.get(`${this.apiBase}/api/statistics/reply-rate`),
axios.get(`${this.apiBase}/api/statistics/hourly`),
axios.get(`${this.apiBase}/api/statistics/top-sites?limit=10`)
]);
this.chartsLoading = false;
// 等待DOM更新后再初始化和渲染图表
await this.$nextTick();
await new Promise(r => setTimeout(r, 50));
// 确保图表已初始化
this.ensureChartsInit();
// iOS风格图表配置
const iosBlue = '#007AFF';
const iosGreen = '#34C759';
const iosGray = '#8E8E93';
const iosGray5 = '#E5E5EA';
if (t.data.success && this.charts.trend) {
const d = t.data.data;
this.charts.trend.setOption({
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#E5E5EA',
borderWidth: 1,
textStyle: { color: '#1C1C1E', fontSize: 13 },
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
},
legend: {
data: ['点击', '成功'],
bottom: 0,
textStyle: { color: '#8E8E93', fontSize: 12 },
itemWidth: 12,
itemHeight: 12,
itemGap: 20
},
grid: { left: '3%', right: '4%', bottom: '15%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: d.dates,
axisLabel: { fontSize: 11, color: '#8E8E93' },
axisLine: { lineStyle: { color: '#E5E5EA' } },
axisTick: { show: false }
},
yAxis: {
type: 'value',
axisLabel: { fontSize: 11, color: '#8E8E93' },
axisLine: { show: false },
splitLine: { lineStyle: { color: '#F2F2F7', type: 'dashed' } }
},
series: [
{
name: '点击',
type: 'line',
data: d.clicks,
smooth: 0.4,
symbol: 'circle',
symbolSize: 6,
lineStyle: { width: 3, color: iosBlue },
areaStyle: {
color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [
{ offset: 0, color: 'rgba(0,122,255,0.25)' },
{ offset: 1, color: 'rgba(0,122,255,0.02)' }
]}
},
itemStyle: { color: iosBlue, borderWidth: 2, borderColor: '#fff' }
},
{
name: '成功',
type: 'line',
data: d.successes,
smooth: 0.4,
symbol: 'circle',
symbolSize: 6,
lineStyle: { width: 3, color: iosGreen },
areaStyle: {
color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [
{ offset: 0, color: 'rgba(52,199,89,0.25)' },
{ offset: 1, color: 'rgba(52,199,89,0.02)' }
]}
},
itemStyle: { color: iosGreen, borderWidth: 2, borderColor: '#fff' }
}
]
});
}
if (p.data.success && this.charts.pie) {
const d = p.data.data;
this.charts.pie.setOption({
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)',
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#E5E5EA',
borderWidth: 1,
textStyle: { color: '#1C1C1E', fontSize: 13 }
},
legend: {
bottom: 0,
textStyle: { color: '#8E8E93', fontSize: 12 },
itemWidth: 12,
itemHeight: 12
},
series: [{
type: 'pie',
radius: ['50%', '75%'],
center: ['50%', '45%'],
avoidLabelOverlap: false,
label: {
show: true,
position: 'center',
formatter: d.values[0] ? '{d}%' : '0%',
fontSize: 24,
fontWeight: 600,
color: iosGreen
},
emphasis: {
label: { show: true, fontSize: 26, fontWeight: 700 },
itemStyle: { shadowBlur: 20, shadowColor: 'rgba(0,0,0,0.15)' }
},
data: d.labels.map((l, i) => ({
name: l,
value: d.values[i],
itemStyle: {
color: i === 0 ? iosGreen : iosGray5,
borderRadius: 4,
borderColor: '#fff',
borderWidth: 2
}
}))
}]
});
}
if (h.data.success && this.charts.hourly) {
const d = h.data.data;
this.charts.hourly.setOption({
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#E5E5EA',
borderWidth: 1,
textStyle: { color: '#1C1C1E', fontSize: 13 },
axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(0,122,255,0.08)' } }
},
grid: { left: '3%', right: '4%', bottom: '8%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: d.hours.map(h => `${h}:00`),
axisLabel: { fontSize: 10, color: '#8E8E93', interval: 2 },
axisLine: { lineStyle: { color: '#E5E5EA' } },
axisTick: { show: false }
},
yAxis: {
type: 'value',
axisLabel: { fontSize: 11, color: '#8E8E93' },
axisLine: { show: false },
splitLine: { lineStyle: { color: '#F2F2F7', type: 'dashed' } }
},
series: [{
type: 'bar',
data: d.clicks,
barWidth: '60%',
itemStyle: {
color: p => (p.dataIndex >= 9 && p.dataIndex < 21) ?
{ type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [
{ offset: 0, color: '#007AFF' },
{ offset: 1, color: '#5856D6' }
]} : '#E5E5EA',
borderRadius: [6, 6, 0, 0]
}
}]
});
}
if (s.data.success && this.charts.topSites) {
const d = s.data.data;
this.charts.topSites.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(0,122,255,0.08)' } },
backgroundColor: 'rgba(255,255,255,0.95)',
borderColor: '#E5E5EA',
borderWidth: 1,
textStyle: { color: '#1C1C1E', fontSize: 13 }
},
grid: { left: '3%', right: '8%', bottom: '3%', top: '3%', containLabel: true },
xAxis: {
type: 'value',
axisLabel: { fontSize: 11, color: '#8E8E93' },
axisLine: { show: false },
splitLine: { lineStyle: { color: '#F2F2F7', type: 'dashed' } }
},
yAxis: {
type: 'category',
data: d.map(s => (s.site_name || s.site_url || '').substring(0, 12)).reverse(),
axisLabel: { fontSize: 11, color: '#3C3C43' },
axisLine: { show: false },
axisTick: { show: false }
},
series: [{
type: 'bar',
data: d.map(s => s.click_count).reverse(),
barWidth: '65%',
itemStyle: {
color: { type: 'linear', x: 0, y: 0, x2: 1, y2: 0, colorStops: [
{ offset: 0, color: '#007AFF' },
{ offset: 1, color: '#5856D6' }
]},
borderRadius: [0, 6, 6, 0]
},
label: {
show: true,
position: 'right',
fontSize: 11,
color: '#8E8E93'
}
}]
});
}
} catch (e) { console.error(e); }
}
}
});
</script>
</body>
</html>