Files
ai_mip/static/app.html

2796 lines
125 KiB
HTML
Raw Permalink Normal View History

2026-01-16 22:06:46 +08:00
<!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>
2026-02-24 12:46:35 +08:00
<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>
2026-01-16 22:06:46 +08:00
<style>
2026-02-24 12:46:35 +08:00
: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;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
* { margin: 0; padding: 0; box-sizing: border-box; }
2026-01-16 22:06:46 +08:00
body {
2026-02-24 12:46:35 +08:00
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;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
#app { min-height: 100vh; }
2026-01-16 22:06:46 +08:00
2026-02-24 12:46:35 +08:00
/* iOS风格顶部导航栏 */
.header {
height: 64px;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
2026-01-16 22:06:46 +08:00
display: flex;
align-items: center;
justify-content: space-between;
2026-02-24 12:46:35 +08:00
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);
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.header-left {
2026-01-16 22:06:46 +08:00
display: flex;
align-items: center;
2026-02-24 12:46:35 +08:00
gap: 14px;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.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);
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.app-title {
color: var(--text-primary);
2026-01-16 22:06:46 +08:00
font-size: 20px;
font-weight: 600;
2026-02-24 12:46:35 +08:00
letter-spacing: -0.3px;
2026-01-16 22:06:46 +08:00
}
.header-right {
display: flex;
align-items: center;
2026-02-24 12:46:35 +08:00
gap: 16px;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.status-pill {
2026-01-16 22:06:46 +08:00
display: flex;
align-items: center;
gap: 8px;
2026-02-24 12:46:35 +08:00
background: var(--ios-gray-6);
2026-01-16 22:06:46 +08:00
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
2026-02-24 12:46:35 +08:00
font-weight: 500;
color: var(--text-secondary);
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.status-indicator {
2026-01-16 22:06:46 +08:00
width: 8px;
height: 8px;
border-radius: 50%;
2026-02-24 12:46:35 +08:00
animation: breathe 2.5s ease-in-out infinite;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.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;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.refresh-btn:hover { background: var(--ios-gray-5); transform: scale(1.05); }
.refresh-btn:active { transform: scale(0.95); }
/* iOS风格主体布局 */
.main-layout {
2026-01-16 22:06:46 +08:00
display: flex;
2026-02-24 12:46:35 +08:00
padding-top: 64px;
min-height: 100vh;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
/* iOS风格侧边栏 */
2026-01-16 22:06:46 +08:00
.sidebar {
2026-02-24 12:46:35 +08:00
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;
2026-01-16 22:06:46 +08:00
overflow-y: auto;
2026-02-24 12:46:35 +08:00
padding: 20px 12px;
border-right: 0.5px solid rgba(0, 0, 0, 0.08);
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.sidebar .el-menu {
2026-01-16 22:06:46 +08:00
border: none;
2026-02-24 12:46:35 +08:00
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;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.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 {
2026-01-16 22:06:46 +08:00
flex: 1;
2026-02-24 12:46:35 +08:00
margin-left: 240px;
padding: 24px 28px;
background: transparent;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
/* iOS风格统计卡片 */
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
2026-01-16 22:06:46 +08:00
margin-bottom: 24px;
}
.stat-card {
2026-02-24 12:46:35 +08:00
background: var(--card-bg);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border-radius: var(--radius-lg);
2026-01-16 22:06:46 +08:00
padding: 24px;
2026-02-24 12:46:35 +08:00
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);
2026-01-16 22:06:46 +08:00
}
.stat-card:hover {
2026-02-24 12:46:35 +08:00
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;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.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); }
2026-01-16 22:06:46 +08:00
.stat-label {
font-size: 14px;
2026-02-24 12:46:35 +08:00
font-weight: 500;
color: var(--text-tertiary);
margin-bottom: 8px;
letter-spacing: -0.2px;
2026-01-16 22:06:46 +08:00
}
.stat-value {
2026-02-24 12:46:35 +08:00
font-size: 34px;
font-weight: 700;
letter-spacing: -1px;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.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); }
2026-01-16 22:06:46 +08:00
2026-02-24 12:46:35 +08:00
/* 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);
2026-01-16 22:06:46 +08:00
margin-bottom: 24px;
2026-02-24 12:46:35 +08:00
border: 0.5px solid rgba(255, 255, 255, 0.5);
overflow: hidden;
2026-01-16 22:06:46 +08:00
}
.card-header {
2026-02-24 12:46:35 +08:00
padding: 20px 24px;
border-bottom: 0.5px solid var(--ios-gray-5);
2026-01-16 22:06:46 +08:00
display: flex;
justify-content: space-between;
align-items: center;
2026-02-24 12:46:35 +08:00
background: rgba(255, 255, 255, 0.4);
2026-01-16 22:06:46 +08:00
}
.card-title {
2026-02-24 12:46:35 +08:00
font-size: 18px;
2026-01-16 22:06:46 +08:00
font-weight: 600;
2026-02-24 12:46:35 +08:00
color: var(--text-primary);
letter-spacing: -0.3px;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.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);
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.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风格表格 */
2026-01-16 22:06:46 +08:00
.el-table {
2026-02-24 12:46:35 +08:00
border-radius: var(--radius-md);
overflow: hidden;
background: transparent !important;
2026-01-16 22:06:46 +08:00
}
.el-table th {
2026-02-24 12:46:35 +08:00
background: rgba(242, 242, 247, 0.8) !important;
2026-01-16 22:06:46 +08:00
font-weight: 600;
2026-02-24 12:46:35 +08:00
color: var(--text-secondary) !important;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.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;
2026-01-16 22:06:46 +08:00
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
2026-02-24 12:46:35 +08:00
.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);
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.login-btn:active {
transform: translateY(0);
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
/* 用户信息下拉 */
.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;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.user-dropdown:hover {
background: var(--ios-gray-5);
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.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;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.user-avatar i {
font-size: 14px;
color: white;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.user-name {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
/* 聊天记录对话框样式 */
.chat-log-dialog .el-dialog__body {
padding: 0;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.chat-log-content {
max-height: 65vh;
overflow-y: auto;
background: #1C1C1E;
border-radius: 0;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
.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;
2026-01-16 22:06:46 +08:00
}
</style>
</head>
<body>
2026-02-24 12:46:35 +08:00
<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>
2026-01-16 22:06:46 +08:00
<!-- 顶部导航 -->
2026-02-24 12:46:35 +08:00
<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>
2026-01-16 22:06:46 +08:00
</div>
<div class="header-right">
2026-02-24 12:46:35 +08:00
<div class="status-pill">
<span class="status-indicator" :class="schedulerRunning ? 'running' : 'stopped'"></span>
2026-01-16 22:06:46 +08:00
<span>{{ schedulerStatus }}</span>
</div>
2026-02-24 12:46:35 +08:00
<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>
2026-01-16 22:06:46 +08:00
</div>
2026-02-24 12:46:35 +08:00
</header>
2026-01-16 22:06:46 +08:00
2026-02-24 12:46:35 +08:00
<!-- 主体布局 -->
<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>
2026-01-16 22:06:46 +08:00
</el-menu>
2026-02-24 12:46:35 +08:00
</aside>
2026-01-16 22:06:46 +08:00
2026-02-24 12:46:35 +08:00
<!-- 内容区 -->
<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>
2026-01-16 22:06:46 +08:00
</div>
</div>
2026-02-24 12:46:35 +08:00
<!-- 统计卡片 - 正常状态 -->
<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>
2026-01-16 22:06:46 +08:00
</div>
2026-02-24 12:46:35 +08:00
<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>
2026-01-16 22:06:46 +08:00
</div>
2026-02-24 12:46:35 +08:00
<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>
2026-01-16 22:06:46 +08:00
</div>
2026-02-24 12:46:35 +08:00
<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>
2026-01-16 22:06:46 +08:00
</div>
</div>
2026-02-24 12:46:35 +08:00
<!-- 图表区 - 骨架屏状态 -->
<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>
2026-01-16 22:06:46 +08:00
</div>
2026-02-24 12:46:35 +08:00
<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>
2026-01-16 22:06:46 +08:00
</div>
</div>
2026-02-24 12:46:35 +08:00
</section>
2026-01-16 22:06:46 +08:00
2026-02-24 12:46:35 +08:00
<!-- ==================== 调度管理 ==================== -->
<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>
2026-01-16 22:06:46 +08:00
</div>
2026-02-24 12:46:35 +08:00
</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>
2026-01-16 22:06:46 +08:00
2026-02-24 12:46:35 +08:00
<!-- ==================== 系统日志 ==================== -->
<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>
2026-01-16 22:06:46 +08:00
</div>
2026-02-24 12:46:35 +08:00
<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>
2026-01-16 22:06:46 +08:00
</div>
2026-02-24 12:46:35 +08:00
</el-card>
</section>
2026-01-16 22:06:46 +08:00
2026-02-24 12:46:35 +08:00
<!-- ==================== 系统配置 ==================== -->
<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>
2026-01-16 22:06:46 +08:00
</div>
2026-02-24 12:46:35 +08:00
<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>
2026-01-16 22:06:46 +08:00
2026-02-24 12:46:35 +08:00
<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>
2026-01-16 22:06:46 +08:00
2026-02-24 12:46:35 +08:00
<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>
2026-01-16 22:06:46 +08:00
</div>
2026-02-24 12:46:35 +08:00
<!-- 导入模式选择 -->
<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>
2026-01-16 22:06:46 +08:00
</div>
2026-02-24 12:46:35 +08:00
<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>
2026-01-16 22:06:46 +08:00
</div>
<script>
new Vue({
el: '#app',
data() {
return {
2026-02-24 12:46:35 +08:00
// 登录相关
isLoggedIn: false,
username: '',
loginLoading: false,
loginForm: {
username: '',
password: ''
},
loginRules: {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
2026-01-16 22:06:46 +08:00
},
2026-02-24 12:46:35 +08:00
activeMenu: 'dashboard',
fullLoading: false,
isRefreshing: false,
statsLoading: true,
chartsLoading: true,
schedulerStatus: '检测中...',
schedulerRunning: false,
stats: {},
config: {},
2026-01-16 22:06:46 +08:00
2026-02-24 12:46:35 +08:00
// 链接管理
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,
2026-01-16 22:06:46 +08:00
2026-02-24 12:46:35 +08:00
// 数据记录
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: '',
2026-01-16 22:06:46 +08:00
2026-02-24 12:46:35 +08:00
// 日志
logs: [],
logFilter: { level: 'ALL' },
logAutoScroll: true,
logTimer: null,
2026-01-16 22:06:46 +08:00
2026-02-24 12:46:35 +08:00
// 配置
configForm: {},
configSaving: false,
2026-01-16 22:06:46 +08:00
2026-02-24 12:46:35 +08:00
// 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: ''
2026-01-16 22:06:46 +08:00
}
},
mounted() {
2026-02-24 12:46:35 +08:00
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());
2026-01-16 22:06:46 +08:00
},
methods: {
2026-02-24 12:46:35 +08:00
// 检查登录状态
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(() => {});
}
},
2026-01-16 22:06:46 +08:00
async init() {
2026-02-24 12:46:35 +08:00
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}`);
}
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
} catch (e) {
console.error('检查导入任务失败', e);
}
2026-01-16 22:06:46 +08:00
},
handleMenuSelect(index) {
this.activeMenu = index;
2026-02-24 12:46:35 +08:00
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();
2026-01-16 22:06:46 +08:00
},
2026-02-24 12:46:35 +08:00
async refreshAll() {
this.isRefreshing = true;
await Promise.all([this.getStatistics(), this.loadChartData()]);
setTimeout(() => { this.isRefreshing = false; }, 600);
this.$message.success('已刷新');
},
// API调用
2026-01-16 22:06:46 +08:00
async getSchedulerStatus() {
try {
const { data } = await axios.get(`${this.apiBase}/api/scheduler/status`);
if (data.success) {
2026-02-24 12:46:35 +08:00
this.schedulerRunning = data.data.status === 'running';
const statusMap = {
'running': '运行中',
'stopped': '已停止',
'manual': '手动管理'
};
this.schedulerStatus = statusMap[data.data.status] || '手动管理';
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
} catch (e) { console.error(e); }
2026-01-16 22:06:46 +08:00
},
async getStatistics() {
try {
const { data } = await axios.get(`${this.apiBase}/api/statistics`);
2026-02-24 12:46:35 +08:00
if (data.success) this.stats = data.data;
} catch (e) { console.error(e); }
this.statsLoading = false;
2026-01-16 22:06:46 +08:00
},
2026-02-24 12:46:35 +08:00
async getConfig() {
2026-01-16 22:06:46 +08:00
try {
2026-02-24 12:46:35 +08:00
const { data } = await axios.get(`${this.apiBase}/api/config`);
2026-01-16 22:06:46 +08:00
if (data.success) {
2026-02-24 12:46:35 +08:00
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
};
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
} catch (e) { console.error(e); }
2026-01-16 22:06:46 +08:00
},
async startScheduler() {
try {
const { data } = await axios.post(`${this.apiBase}/api/scheduler/start`);
2026-02-24 12:46:35 +08:00
data.success ? this.$message.success('调度器已启动') : this.$message.error(data.message);
this.getSchedulerStatus();
} catch (e) { this.$message.error('启动失败'); }
2026-01-16 22:06:46 +08:00
},
async stopScheduler() {
try {
const { data } = await axios.post(`${this.apiBase}/api/scheduler/stop`);
2026-02-24 12:46:35 +08:00
data.success ? this.$message.success('调度器已停止') : this.$message.error(data.message);
this.getSchedulerStatus();
} catch (e) { this.$message.error('停止失败'); }
2026-01-16 22:06:46 +08:00
},
2026-02-24 12:46:35 +08:00
// 链接管理
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;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
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 || '导入失败');
2026-01-16 22:06:46 +08:00
}
},
2026-02-24 12:46:35 +08:00
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;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
},
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('取消失败');
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
},
async loadSites() {
this.sitesLoading = true;
2026-01-16 22:06:46 +08:00
try {
2026-02-24 12:46:35 +08:00
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('复制失败');
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
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}`);
2026-01-16 22:06:46 +08:00
},
2026-02-24 12:46:35 +08:00
// 日志
async loadLogs() {
2026-01-16 22:06:46 +08:00
try {
2026-02-24 12:46:35 +08:00
const { data } = await axios.get(`${this.apiBase}/api/logs/stream`, { params: { limit: 100, level: this.logFilter.level } });
2026-01-16 22:06:46 +08:00
if (data.success) {
2026-02-24 12:46:35 +08:00
this.logs = data.data;
if (this.logAutoScroll) this.$nextTick(() => { const v = this.$refs.logViewer; if (v) v.scrollTop = v.scrollHeight; });
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
} catch (e) { console.error(e); }
},
startLogPolling() {
if (this.logTimer) clearInterval(this.logTimer);
this.logTimer = setInterval(() => { if (this.activeMenu === 'logs') this.loadLogs(); }, 2000);
2026-01-16 22:06:46 +08:00
},
2026-02-24 12:46:35 +08:00
// 配置
async saveConfig() {
this.configSaving = true;
2026-01-16 22:06:46 +08:00
try {
2026-02-24 12:46:35 +08:00
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
2026-01-16 22:06:46 +08:00
});
if (data.success) {
2026-02-24 12:46:35 +08:00
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();
}
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
} 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;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
this.queryUploadLoading = true;
return true;
2026-01-16 22:06:46 +08:00
},
2026-02-24 12:46:35 +08:00
beforeQueryUploadMode2(file) {
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!['.xlsx', '.xls'].includes(ext)) {
this.$message.error('请上传Excel文件(.xlsx/.xls)');
return false;
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
this.queryUploadLoading = true;
return true;
2026-01-16 22:06:46 +08:00
},
2026-02-24 12:46:35 +08:00
handleQueryUploadSuccess(res) {
this.queryUploadLoading = false;
if (res.success) {
this.$message.success(`文件 ${res.filename} 上传成功,导入任务已启动`);
this.loadQueryImportRecords();
this.startQueryPoll();
} else {
this.$message.error(res.message || '上传失败');
2026-01-16 22:06:46 +08:00
}
},
2026-02-24 12:46:35 +08:00
handleQueryUploadError(err) {
this.queryUploadLoading = false;
this.$message.error('上传失败: ' + (err.message || '未知错误'));
},
async triggerQueryScan() {
this.queryScanLoading = true;
2026-01-16 22:06:46 +08:00
try {
2026-02-24 12:46:35 +08:00
const { data } = await axios.post(`${this.apiBase}/api/query/import/trigger`, {}, { withCredentials: true });
2026-01-16 22:06:46 +08:00
if (data.success) {
2026-02-24 12:46:35 +08:00
this.$message.success('目录扫描任务已启动');
setTimeout(() => this.loadQueryImportRecords(), 2000);
} else {
this.$message.error(data.message || '触发失败');
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
} catch (e) { this.$message.error('触发失败'); }
this.queryScanLoading = false;
2026-01-16 22:06:46 +08:00
},
2026-02-24 12:46:35 +08:00
startQueryPoll() {
if (this.queryPollTimer) return;
this.queryPollTimer = setInterval(() => {
if (this.activeMenu === 'query-mining') {
this.loadQueryImportRecords();
2026-01-16 22:06:46 +08:00
}
2026-02-24 12:46:35 +08:00
}, 3000);
},
stopQueryPoll() {
if (this.queryPollTimer) {
clearInterval(this.queryPollTimer);
this.queryPollTimer = null;
2026-01-16 22:06:46 +08:00
}
},
2026-02-24 12:46:35 +08:00
// 图表
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);
}
2026-01-16 22:06:46 +08:00
},
2026-02-24 12:46:35 +08:00
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); }
2026-01-16 22:06:46 +08:00
}
}
});
</script>
</body>
</html>