feat: enhance agent orchestration, knowledge flow and UI refinements
This commit is contained in:
505
frontend/src/pages/agents/agentsPage.css
Normal file
505
frontend/src/pages/agents/agentsPage.css
Normal file
@@ -0,0 +1,505 @@
|
||||
.agent-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--bg-void);
|
||||
}
|
||||
.bg-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,245,212,0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,245,212,0.04) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.bg-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0,245,212,0.05) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.bg-particles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bg-particle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 4px rgba(0,245,212,0.6), 0 0 8px rgba(0,245,212,0.2);
|
||||
animation: star-twinkle var(--d, 4s) ease-in-out infinite var(--delay, 0s);
|
||||
}
|
||||
@keyframes star-twinkle {
|
||||
0%, 100% { opacity: var(--o, 0.4); transform: scale(1); }
|
||||
50% { opacity: calc(var(--o, 0.4) * 0.3); transform: scale(0.5); }
|
||||
}
|
||||
.view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 24px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background: rgba(5,8,16,0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.header-title { font-family: var(--font-display); font-size: 13px; letter-spacing: 0.2em; color: var(--text-primary); }
|
||||
.title-bracket { color: var(--accent-cyan); opacity: 0.6; }
|
||||
.header-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.btn-icon {
|
||||
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg-card); border: 1px solid var(--border-mid); border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary); cursor: pointer; transition: all var(--transition-fast);
|
||||
}
|
||||
.btn-icon:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); box-shadow: var(--glow-cyan); }
|
||||
.btn-icon.spinning svg { animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.status-bar { display: flex; align-items: center; gap: 6px; font-size: 10px; color: var(--text-dim); letter-spacing: 0.1em; }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.status-dot.connected { background: var(--accent-cyan); box-shadow: 0 0 6px var(--accent-cyan); animation: status-pulse-soft 2.6s ease-in-out infinite; }
|
||||
.status-dot.disconnected { background: var(--text-dim); }
|
||||
@keyframes status-pulse-soft {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.55; transform: scale(0.82); }
|
||||
}
|
||||
.nodes-canvas {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
cursor: grab;
|
||||
}
|
||||
.nodes-canvas.panning {
|
||||
cursor: grabbing;
|
||||
user-select: none;
|
||||
}
|
||||
.hud-panels {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 20px;
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 260px;
|
||||
}
|
||||
.hud-panel {
|
||||
border: 1px solid rgba(0,245,212,0.12);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, rgba(8, 13, 22, 0.92), rgba(5, 9, 18, 0.86));
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
padding: 14px;
|
||||
}
|
||||
.hud-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.route-main {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.route-child {
|
||||
margin-top: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.canvas-controls {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 18px;
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(0,245,212,0.12);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(8, 13, 22, 0.92), rgba(5, 9, 18, 0.86));
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
}
|
||||
.control-chip {
|
||||
height: 36px;
|
||||
border: 1px solid rgba(0,245,212,0.12);
|
||||
background: rgba(9, 16, 28, 0.9);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
.control-chip:hover {
|
||||
border-color: rgba(0,245,212,0.3);
|
||||
color: var(--accent-cyan);
|
||||
box-shadow: 0 0 18px rgba(0,245,212,0.08);
|
||||
}
|
||||
.zoom-chip { width: 36px; flex-shrink: 0; }
|
||||
.chip-symbol { font-family: var(--font-display); font-size: 18px; line-height: 1; }
|
||||
.zoom-readout { min-width: 72px; padding: 0 14px; }
|
||||
.chip-value { font-family: var(--font-display); font-size: 11px; letter-spacing: 0.08em; color: var(--text-primary); }
|
||||
.nodes-viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
will-change: transform;
|
||||
}
|
||||
.nodes-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
will-change: auto;
|
||||
}
|
||||
.canvas-aura,
|
||||
.canvas-scan {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.canvas-aura {
|
||||
background:
|
||||
radial-gradient(circle at 50% 18%, rgba(0,245,212,0.1) 0%, rgba(0,245,212,0.05) 20%, transparent 46%),
|
||||
radial-gradient(circle at 50% 62%, rgba(0,245,212,0.035) 0%, transparent 54%);
|
||||
filter: blur(12px);
|
||||
opacity: 0.72;
|
||||
}
|
||||
.canvas-scan {
|
||||
inset: -20% 0;
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(0,245,212,0.018) 42%, rgba(0,245,212,0.045) 50%, rgba(0,245,212,0.018) 58%, transparent 100%);
|
||||
animation: canvas-scan 11s linear infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@keyframes canvas-scan {
|
||||
from { transform: translateY(-18%); }
|
||||
to { transform: translateY(18%); }
|
||||
}
|
||||
.conn-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
.conn-path {
|
||||
fill: none;
|
||||
stroke: rgba(0,245,212,0.22);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 5 7;
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 0 6px rgba(0,245,212,0.06));
|
||||
animation: dash-flow 5.5s linear infinite;
|
||||
}
|
||||
.conn-path-sub {
|
||||
stroke-width: 1.2;
|
||||
stroke-opacity: 0.7;
|
||||
}
|
||||
@keyframes dash-flow { to { stroke-dashoffset: -48; } }
|
||||
.conn-path.energized {
|
||||
stroke: color-mix(in srgb, var(--accent-cyan) 72%, var(--accent-amber) 28%);
|
||||
stroke-opacity: 0.62;
|
||||
stroke-width: 1.9;
|
||||
stroke-dasharray: none;
|
||||
filter: url(#lineGlow) drop-shadow(0 0 8px rgba(0,245,212,0.16));
|
||||
animation: line-flare 2.2s ease-in-out infinite alternate;
|
||||
}
|
||||
.conn-current {
|
||||
fill: none;
|
||||
stroke: rgba(232, 255, 255, 0.98);
|
||||
stroke-width: 3.2;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 18 220;
|
||||
filter: drop-shadow(0 0 10px rgba(0,245,212,0.36)) drop-shadow(0 0 18px rgba(255,255,255,0.24));
|
||||
animation: current-flow 1.2s linear infinite;
|
||||
}
|
||||
.conn-current-sub {
|
||||
stroke-width: 2.6;
|
||||
stroke-dasharray: 14 180;
|
||||
animation-duration: 1s;
|
||||
}
|
||||
@keyframes line-flare {
|
||||
from { stroke-opacity: 0.38; }
|
||||
to { stroke-opacity: 0.72; }
|
||||
}
|
||||
@keyframes current-flow {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: -238; }
|
||||
}
|
||||
.node-card {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
.node-sub.disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.node-child { z-index: 2; }
|
||||
.node-child .node-name,
|
||||
.node-child .node-role,
|
||||
.node-child .node-label {
|
||||
word-break: break-word;
|
||||
}
|
||||
.node-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(13,21,37,0.92);
|
||||
border: 1px solid rgba(0,245,212,0.2);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--node-padding-y, 14px) var(--node-padding-x, 16px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(3px * var(--node-scale, 1));
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(12px);
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.25s ease;
|
||||
}
|
||||
.node-inner::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(115deg, transparent 20%, rgba(255,255,255,0.045) 32%, transparent 44%);
|
||||
transform: translateX(-150%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.node-master .node-inner::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -18%;
|
||||
background: radial-gradient(circle, rgba(0,245,212,0.1) 0%, rgba(0,245,212,0.045) 28%, transparent 62%);
|
||||
opacity: 0.72;
|
||||
animation: core-breathe 5.6s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.node-master .node-inner {
|
||||
background: linear-gradient(135deg, rgba(0,245,212,0.06) 0%, rgba(13,21,37,0.95) 100%);
|
||||
border-color: rgba(0,245,212,0.3);
|
||||
}
|
||||
.node-card:hover .node-inner {
|
||||
border-color: rgba(0,245,212,0.42);
|
||||
box-shadow: 0 8px 28px rgba(0,245,212,0.11), 0 0 0 1px rgba(0,245,212,0.08);
|
||||
}
|
||||
.node-card:hover .node-inner::before {
|
||||
opacity: 0.9;
|
||||
animation: node-sheen 1.45s ease;
|
||||
}
|
||||
.node-card.selected .node-inner {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 1px rgba(0,245,212,0.26), 0 0 18px rgba(0,245,212,0.14);
|
||||
}
|
||||
@keyframes node-sheen {
|
||||
0% { transform: translateX(-150%); }
|
||||
100% { transform: translateX(150%); }
|
||||
}
|
||||
@keyframes core-breathe {
|
||||
0%, 100% { opacity: 0.46; transform: scale(0.98); }
|
||||
50% { opacity: 0.8; transform: scale(1.01); }
|
||||
}
|
||||
.node-corner { position: absolute; width: var(--node-corner-size, 10px); height: var(--node-corner-size, 10px); opacity: 0.6; }
|
||||
.node-corner.tl { top: calc(6px * var(--node-scale, 1)); left: calc(6px * var(--node-scale, 1)); border-top: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); border-left: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); }
|
||||
.node-corner.tr { top: calc(6px * var(--node-scale, 1)); right: calc(6px * var(--node-scale, 1)); border-top: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); border-right: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); }
|
||||
.node-corner.bl { bottom: calc(6px * var(--node-scale, 1)); left: calc(6px * var(--node-scale, 1)); border-bottom: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); border-left: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); }
|
||||
.node-corner.br { bottom: calc(6px * var(--node-scale, 1)); right: calc(6px * var(--node-scale, 1)); border-bottom: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); border-right: calc(1.5px * var(--node-scale, 1)) solid var(--accent-cyan); }
|
||||
.node-status { position: absolute; top: calc(10px * var(--node-scale, 1)); right: calc(10px * var(--node-scale, 1)); width: var(--node-status-size, 10px); height: var(--node-status-size, 10px); border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
||||
.status-ring { width: var(--node-status-ring-size, 8px); height: var(--node-status-ring-size, 8px); border-radius: 50%; }
|
||||
.node-status.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
border: 1px solid rgba(0,245,212,0.22);
|
||||
border-radius: 999px;
|
||||
animation: status-orbit 2.3s ease-out infinite;
|
||||
}
|
||||
.node-status.active .status-ring { background: var(--accent-cyan); box-shadow: 0 0 8px var(--accent-cyan); animation: status-pulse 1.8s ease-in-out infinite; }
|
||||
.node-status.idle .status-ring { background: var(--text-secondary); }
|
||||
.node-status.disabled .status-ring { background: var(--text-dim); opacity: 0.4; }
|
||||
@keyframes status-pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||
@keyframes status-orbit {
|
||||
0% { transform: scale(0.5); opacity: 0.65; }
|
||||
100% { transform: scale(1.3); opacity: 0; }
|
||||
}
|
||||
.node-label { font-family: var(--font-display); font-size: calc(8px * var(--node-scale, 1)); letter-spacing: 0.2em; color: var(--text-dim); margin-bottom: 1px; }
|
||||
.node-master .node-label { color: rgba(0,245,212,0.5); }
|
||||
.node-name { font-family: var(--font-display); font-size: calc(15px * var(--node-scale, 1)); font-weight: 700; letter-spacing: 0.08em; color: var(--accent-cyan); line-height: 1.2; }
|
||||
.node-master .node-name { font-size: calc(18px * var(--node-scale, 1)); }
|
||||
.node-role { font-family: var(--font-mono); font-size: calc(10px * var(--node-scale, 1)); color: var(--accent-amber); letter-spacing: 0.05em; }
|
||||
.node-desc {
|
||||
font-family: var(--font-mono); font-size: calc(10px * var(--node-scale, 1)); color: var(--text-secondary);
|
||||
line-height: 1.5; flex: 1; overflow: hidden; display: -webkit-box;
|
||||
-webkit-line-clamp: 3; -webkit-box-orient: vertical; text-overflow: ellipsis;
|
||||
}
|
||||
.node-child .node-desc { -webkit-line-clamp: 2; }
|
||||
.node-footer { display: flex; align-items: center; gap: calc(8px * var(--node-scale, 1)); flex-wrap: wrap; margin-top: 2px; }
|
||||
.node-stat { display: flex; align-items: center; gap: calc(4px * var(--node-scale, 1)); font-family: var(--font-mono); font-size: calc(9px * var(--node-scale, 1)); }
|
||||
.stat-label { color: var(--text-dim); }
|
||||
.stat-val { color: var(--accent-cyan); font-weight: 600; }
|
||||
.node-task-tag {
|
||||
font-family: var(--font-mono); font-size: calc(9px * var(--node-scale, 1)); color: var(--accent-amber);
|
||||
background: rgba(249,168,37,0.08); border: 1px solid rgba(249,168,37,0.18);
|
||||
border-radius: 3px; padding: calc(1px * var(--node-scale, 1)) calc(6px * var(--node-scale, 1)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: calc(120px * var(--node-scale, 1));
|
||||
box-shadow: 0 0 10px rgba(249,168,37,0.05);
|
||||
animation: task-tag-glow 3.4s ease-in-out infinite;
|
||||
}
|
||||
.node-idle { font-family: var(--font-mono); font-size: calc(9px * var(--node-scale, 1)); color: var(--text-dim); font-style: italic; }
|
||||
.rel-label {
|
||||
position: absolute; font-family: var(--font-mono); font-size: calc(8px * var(--node-scale, 1)); color: var(--text-dim);
|
||||
letter-spacing: 0.05em; pointer-events: none; left: 50%; transform: translateX(-50%);
|
||||
bottom: var(--node-rel-offset, -20px); white-space: nowrap;
|
||||
}
|
||||
@keyframes task-tag-glow {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(249,168,37,0.04); }
|
||||
50% { box-shadow: 0 0 14px rgba(249,168,37,0.1); }
|
||||
}
|
||||
.config-drawer {
|
||||
position: fixed; top: 0; right: 0; width: 420px; height: 100%;
|
||||
background: rgba(5,8,16,0.97); border-left: 1px solid var(--border-mid);
|
||||
backdrop-filter: blur(20px); z-index: 100; display: flex; flex-direction: column;
|
||||
box-shadow: -10px 0 40px rgba(0,0,0,0.5);
|
||||
}
|
||||
.drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 99; }
|
||||
.drawer-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border-dim); }
|
||||
.drawer-title { font-family: var(--font-display); font-size: 11px; letter-spacing: 0.15em; color: var(--accent-cyan); }
|
||||
.btn-close {
|
||||
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
|
||||
background: transparent; border: 1px solid var(--border-dim); border-radius: var(--radius-sm);
|
||||
color: var(--text-dim); cursor: pointer; transition: all var(--transition-fast);
|
||||
}
|
||||
.btn-close:hover { border-color: var(--accent-red); color: var(--accent-red); }
|
||||
.drawer-body { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.drawer-body::-webkit-scrollbar { width: 4px; }
|
||||
.drawer-body::-webkit-scrollbar-thumb { background: var(--border-mid); border-radius: 2px; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-group.flex-1 { flex: 1; display: flex; flex-direction: column; }
|
||||
.form-label { font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.15em; color: var(--text-dim); }
|
||||
.form-input {
|
||||
background: var(--bg-card); border: 1px solid var(--border-mid); border-radius: var(--radius-sm);
|
||||
padding: 10px 12px; color: var(--text-primary); font-family: var(--font-mono); font-size: 12px; outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
.form-input:focus { border-color: var(--accent-cyan); box-shadow: 0 0 0 1px rgba(0,245,212,.1); }
|
||||
.form-textarea {
|
||||
background: var(--bg-card); border: 1px solid var(--border-mid); border-radius: var(--radius-sm);
|
||||
padding: 10px 12px; color: var(--text-primary); font-family: var(--font-mono); font-size: 11px;
|
||||
outline: none; resize: none; line-height: 1.5; transition: border-color var(--transition-fast);
|
||||
}
|
||||
.form-textarea:focus { border-color: var(--accent-cyan); box-shadow: 0 0 0 1px rgba(0,245,212,.1); }
|
||||
.code-textarea { font-size: 10px; flex: 1; }
|
||||
.toggle-row { display: flex; align-items: center; gap: 12px; }
|
||||
.toggle-label { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em; color: var(--accent-cyan); transition: color .2s; }
|
||||
.toggle-label.dim { color: var(--text-dim); }
|
||||
.toggle-btn { width: 44px; height: 22px; background: var(--bg-card); border: 1px solid var(--border-mid); border-radius: 11px; padding: 2px; cursor: pointer; transition: all .25s; }
|
||||
.toggle-btn.active { background: rgba(0,245,212,.15); border-color: var(--accent-cyan); }
|
||||
.toggle-knob { display: block; width: 16px; height: 16px; border-radius: 50%; background: var(--text-dim); transition: all .25s; }
|
||||
.toggle-btn.active .toggle-knob { background: var(--accent-cyan); box-shadow: 0 0 8px var(--accent-cyan); transform: translateX(22px); }
|
||||
.drawer-actions { display: flex; gap: 12px; padding-top: 8px; }
|
||||
.linked-skills-group {
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(0,245,212,0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 18, 30, 0.6);
|
||||
}
|
||||
.linked-skill-packages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.linked-skill-package {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0,245,212,0.12);
|
||||
background: rgba(0,245,212,0.06);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
.linked-skill-package strong { color: var(--accent-cyan); font-weight: 600; }
|
||||
.linked-skills-state {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.linked-skills-error { color: var(--accent-red); }
|
||||
.linked-skill-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.linked-skill-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(0,245,212,0.1);
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
.linked-skill-checkbox {
|
||||
margin-top: 2px;
|
||||
accent-color: var(--accent-cyan);
|
||||
}
|
||||
.linked-skill-copy { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.linked-skill-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.linked-skill-name { color: var(--text-primary); font-family: var(--font-display); font-size: 12px; letter-spacing: 0.05em; }
|
||||
.linked-skill-agent-type {
|
||||
color: var(--accent-amber);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.linked-skill-desc { color: var(--text-secondary); font-family: var(--font-mono); font-size: 11px; line-height: 1.5; }
|
||||
.linked-skill-tools { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.linked-skill-tool {
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(249,168,37,0.08);
|
||||
border: 1px solid rgba(249,168,37,0.16);
|
||||
color: var(--accent-amber);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
.btn-secondary,.btn-primary {
|
||||
flex: 1; padding: 10px 16px; border-radius: var(--radius-sm); font-family: var(--font-mono);
|
||||
font-size: 11px; letter-spacing: 0.1em; cursor: pointer; transition: all var(--transition-fast);
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.btn-secondary { background: transparent; border: 1px solid var(--border-mid); color: var(--text-secondary); }
|
||||
.btn-secondary:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); }
|
||||
.btn-primary { background: rgba(0,245,212,.1); border: 1px solid var(--accent-cyan); color: var(--accent-cyan); }
|
||||
.btn-primary:hover { background: rgba(0,245,212,.2); box-shadow: var(--glow-cyan); }
|
||||
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-loader { width: 12px; height: 12px; border: 1.5px solid transparent; border-top-color: var(--accent-cyan); border-radius: 50%; animation: spin .6s linear infinite; }
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.7); backdrop-filter: blur(4px);
|
||||
z-index: 200; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-card {
|
||||
width: 480px; max-height: 80vh; background: rgba(10,15,26,.98); border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-lg); display: flex; flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.6), 0 0 0 1px rgba(0,245,212,.05);
|
||||
}
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border-dim); }
|
||||
.modal-title { font-family: var(--font-display); font-size: 11px; letter-spacing: 0.15em; color: var(--accent-cyan); }
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.modal-body::-webkit-scrollbar { width: 4px; }
|
||||
.modal-body::-webkit-scrollbar-thumb { background: var(--border-mid); border-radius: 2px; }
|
||||
.modal-footer { display: flex; gap: 12px; padding: 16px 20px; border-top: 1px solid var(--border-dim); }
|
||||
@@ -3,28 +3,37 @@ import { mount } from '@vue/test-utils'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getHierarchyStats: vi.fn(),
|
||||
getConfig: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
listSkills: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/agent', () => ({
|
||||
agentApi: {
|
||||
getHierarchyStats: mocks.getHierarchyStats,
|
||||
getConfig: mocks.getConfig,
|
||||
updateConfig: mocks.updateConfig,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/skill', () => ({
|
||||
skillApi: {
|
||||
list: mocks.listSkills,
|
||||
},
|
||||
}))
|
||||
|
||||
import AgentsPage from './index.vue'
|
||||
|
||||
const hierarchyStats = {
|
||||
main_agents: [
|
||||
{
|
||||
agent_id: 'planner',
|
||||
agent_id: 'schedule_planner',
|
||||
call_count: 12,
|
||||
current_task: null,
|
||||
status: 'idle',
|
||||
sub_commanders: [
|
||||
{ agent_id: 'planner_scope', call_count: 4, current_task: null, status: 'idle' },
|
||||
{ agent_id: 'planner_steps', call_count: 9, current_task: '拆解执行步骤', status: 'active' },
|
||||
{ agent_id: 'schedule_analysis', call_count: 4, current_task: null, status: 'idle' },
|
||||
{ agent_id: 'schedule_planning', call_count: 9, current_task: '生成今日排期建议', status: 'active' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -60,6 +69,57 @@ const hierarchyStats = {
|
||||
],
|
||||
}
|
||||
|
||||
const skillFixtures = [
|
||||
{
|
||||
id: 'skill-schedule-1',
|
||||
name: 'Priority Router',
|
||||
description: 'Aligns planner priorities.',
|
||||
instructions: 'Prioritize schedule risks.',
|
||||
agent_type: 'schedule_planner',
|
||||
tools: ['calendar', 'tasks'],
|
||||
required_context: [],
|
||||
output_format: null,
|
||||
visibility: 'private' as const,
|
||||
team_id: null,
|
||||
is_active: true,
|
||||
owner_id: 'user-1',
|
||||
created_at: '2026-03-26T00:00:00Z',
|
||||
updated_at: '2026-03-26T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'skill-schedule-2',
|
||||
name: 'Planning Synthesizer',
|
||||
description: 'Builds the next schedule plan.',
|
||||
instructions: 'Synthesize a practical plan.',
|
||||
agent_type: 'schedule_planner',
|
||||
tools: ['planning'],
|
||||
required_context: [],
|
||||
output_format: null,
|
||||
visibility: 'private' as const,
|
||||
team_id: null,
|
||||
is_active: true,
|
||||
owner_id: 'user-1',
|
||||
created_at: '2026-03-26T00:00:00Z',
|
||||
updated_at: '2026-03-26T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'skill-executor-1',
|
||||
name: 'Task Runner',
|
||||
description: 'Executes tool actions.',
|
||||
instructions: 'Run execution tasks.',
|
||||
agent_type: 'executor',
|
||||
tools: ['shell'],
|
||||
required_context: [],
|
||||
output_format: null,
|
||||
visibility: 'private' as const,
|
||||
team_id: null,
|
||||
is_active: true,
|
||||
owner_id: 'user-1',
|
||||
created_at: '2026-03-26T00:00:00Z',
|
||||
updated_at: '2026-03-26T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
describe('agents page pcb command center', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -83,31 +143,53 @@ describe('agents page pcb command center', () => {
|
||||
})),
|
||||
})
|
||||
mocks.getHierarchyStats.mockResolvedValue(hierarchyStats)
|
||||
mocks.updateConfig.mockResolvedValue({})
|
||||
mocks.getConfig.mockImplementation(async (id: string) => ({
|
||||
id,
|
||||
name: id === 'schedule_planner' ? 'SCHEDULE PLANNER' : id.toUpperCase(),
|
||||
role: id,
|
||||
description: 'config description',
|
||||
system_prompt: 'config prompt',
|
||||
enabled: true,
|
||||
selected_skill_ids: id === 'schedule_planner' ? ['skill-schedule-1', 'skill-schedule-2'] : [],
|
||||
}))
|
||||
mocks.updateConfig.mockResolvedValue({ selected_skill_ids: ['skill-schedule-1'] })
|
||||
mocks.listSkills.mockResolvedValue({ data: skillFixtures })
|
||||
})
|
||||
|
||||
it('shows commander skills and active route telemetry for an active sub commander path', async () => {
|
||||
it('shows active route telemetry only when hierarchy stats report an active sub commander path', async () => {
|
||||
const wrapper = mount(AgentsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
const skillsPanel = wrapper.get('[data-testid="commander-skills"]')
|
||||
expect(skillsPanel.text()).toContain('指挥官技能')
|
||||
expect(skillsPanel.text()).toContain('路径拆解')
|
||||
expect(wrapper.find('[data-testid="commander-skills"]').exists()).toBe(false)
|
||||
|
||||
const activeSkill = wrapper.get('[data-testid="commander-skill-skill_planner"]')
|
||||
expect(activeSkill.classes()).toContain('active')
|
||||
|
||||
const plannerBus = wrapper.get('[data-testid="bus-link-planner"]')
|
||||
const plannerBus = wrapper.get('[data-testid="bus-link-schedule_planner"]')
|
||||
expect(plannerBus.classes()).toContain('energized')
|
||||
|
||||
const plannerStepsBranch = wrapper.get('[data-testid="sub-link-planner_steps"]')
|
||||
const plannerStepsBranch = wrapper.get('[data-testid="sub-link-schedule_planning"]')
|
||||
expect(plannerStepsBranch.classes()).toContain('energized')
|
||||
|
||||
const routeTelemetry = wrapper.get('[data-testid="route-telemetry"]')
|
||||
expect(routeTelemetry.text()).toContain('ACTIVE ROUTE')
|
||||
expect(routeTelemetry.text()).toContain('PLANNER')
|
||||
expect(routeTelemetry.text()).toContain('STEPS')
|
||||
expect(routeTelemetry.text()).toContain('SCHEDULE PLANNER')
|
||||
expect(routeTelemetry.text()).toContain('PLANNING')
|
||||
})
|
||||
|
||||
it('keeps route telemetry in standby when hierarchy stats contain no active path', async () => {
|
||||
mocks.getHierarchyStats.mockResolvedValue({
|
||||
main_agents: hierarchyStats.main_agents.map((main) => ({
|
||||
...main,
|
||||
status: 'idle',
|
||||
sub_commanders: main.sub_commanders.map((child) => ({ ...child, status: 'idle' })),
|
||||
})),
|
||||
})
|
||||
|
||||
const wrapper = mount(AgentsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(wrapper.get('[data-testid="route-telemetry"]').text()).toContain('STANDBY')
|
||||
expect(wrapper.get('[data-testid="bus-link-schedule_planner"]').classes()).not.toContain('energized')
|
||||
expect(wrapper.get('[data-testid="sub-link-schedule_planning"]').classes()).not.toContain('energized')
|
||||
})
|
||||
|
||||
it('renders child agents beneath the four main roles in the same hierarchy canvas', async () => {
|
||||
@@ -115,8 +197,8 @@ describe('agents page pcb command center', () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(wrapper.get('[data-testid="agent-chip-planner_scope"]').text()).toContain('SCOPE')
|
||||
expect(wrapper.get('[data-testid="agent-chip-planner_steps"]').text()).toContain('STEPS')
|
||||
expect(wrapper.get('[data-testid="agent-chip-schedule_analysis"]').text()).toContain('ANALYSIS')
|
||||
expect(wrapper.get('[data-testid="agent-chip-schedule_planning"]').text()).toContain('PLANNING')
|
||||
expect(wrapper.get('[data-testid="agent-chip-executor_tasks"]').text()).toContain('TASK OPS')
|
||||
expect(wrapper.get('[data-testid="agent-chip-analyst_insights"]').text()).toContain('INSIGHTS')
|
||||
})
|
||||
@@ -126,7 +208,9 @@ describe('agents page pcb command center', () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="agent-chip-planner"]').trigger('click')
|
||||
await wrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(wrapper.text()).toContain('AGENT CONFIGURATION')
|
||||
expect(wrapper.find('input').exists()).toBe(true)
|
||||
@@ -138,13 +222,150 @@ describe('agents page pcb command center', () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(wrapper.get('[data-testid="bus-link-planner"]').classes()).toContain('energized')
|
||||
expect(wrapper.get('[data-testid="sub-link-planner_scope"]').classes()).toContain('energized')
|
||||
expect(wrapper.get('[data-testid="bus-link-schedule_planner"]').classes()).toContain('energized')
|
||||
expect(wrapper.get('[data-testid="sub-link-schedule_analysis"]').classes()).toContain('energized')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1700)
|
||||
|
||||
expect(wrapper.get('[data-testid="sub-link-planner_scope"]').classes()).not.toContain('energized')
|
||||
expect(wrapper.get('[data-testid="sub-link-planner_steps"]').classes()).toContain('energized')
|
||||
expect(wrapper.get('[data-testid="sub-link-schedule_analysis"]').classes()).not.toContain('energized')
|
||||
expect(wrapper.get('[data-testid="sub-link-schedule_planning"]').classes()).toContain('energized')
|
||||
})
|
||||
|
||||
it('loads related skills into the configuration drawer for the selected node', async () => {
|
||||
const wrapper = mount(AgentsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mocks.listSkills).toHaveBeenCalled()
|
||||
expect(wrapper.get('[data-testid="linked-skills-section"]').text()).toContain('Priority Router')
|
||||
expect(wrapper.get('[data-testid="linked-skills-section"]').text()).toContain('Planning Synthesizer')
|
||||
expect(wrapper.get('[data-testid="linked-skills-package-skill_planner"]').text()).toContain('日程规划')
|
||||
})
|
||||
|
||||
it('keeps linked skill selections after save and restores draft changes on reset', async () => {
|
||||
let persistedSelections = ['skill-schedule-1', 'skill-schedule-2']
|
||||
mocks.getConfig.mockImplementation(async (id: string) => ({
|
||||
id,
|
||||
name: id === 'schedule_planner' ? 'SCHEDULE PLANNER' : id.toUpperCase(),
|
||||
role: id,
|
||||
description: 'config description',
|
||||
system_prompt: 'config prompt',
|
||||
enabled: true,
|
||||
selected_skill_ids: id === 'schedule_planner' ? [...persistedSelections] : [],
|
||||
}))
|
||||
mocks.updateConfig.mockImplementation(async (_id: string, payload: { selected_skill_ids?: string[] }) => {
|
||||
persistedSelections = payload.selected_skill_ids ? [...payload.selected_skill_ids] : persistedSelections
|
||||
return { selected_skill_ids: [...persistedSelections] }
|
||||
})
|
||||
|
||||
const wrapper = mount(AgentsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
const firstCheckbox = wrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-1"]')
|
||||
const secondCheckbox = wrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-2"]')
|
||||
|
||||
expect((firstCheckbox.element as HTMLInputElement).checked).toBe(true)
|
||||
expect((secondCheckbox.element as HTMLInputElement).checked).toBe(true)
|
||||
|
||||
await firstCheckbox.setValue(false)
|
||||
expect((firstCheckbox.element as HTMLInputElement).checked).toBe(false)
|
||||
|
||||
await wrapper.get('[data-testid="linked-skills-reset"]').trigger('click')
|
||||
expect((wrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-1"]').element as HTMLInputElement).checked).toBe(true)
|
||||
|
||||
await wrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-2"]').setValue(false)
|
||||
await wrapper.get('[data-testid="linked-skills-save"]').trigger('click')
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect((wrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-1"]').element as HTMLInputElement).checked).toBe(true)
|
||||
expect((wrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-2"]').element as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
|
||||
it('sends selected skill ids when saving agent config', async () => {
|
||||
const wrapper = mount(AgentsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-2"]').setValue(false)
|
||||
await wrapper.get('[data-testid="linked-skills-save"]').trigger('click')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mocks.updateConfig).toHaveBeenCalledWith(
|
||||
'schedule_planner',
|
||||
expect.objectContaining({ selected_skill_ids: ['skill-schedule-1'] }),
|
||||
)
|
||||
})
|
||||
|
||||
it('restores persisted selected skill ids after remount', async () => {
|
||||
let persistedSelections = ['skill-schedule-1', 'skill-schedule-2']
|
||||
mocks.getConfig.mockImplementation(async (id: string) => ({
|
||||
id,
|
||||
name: id === 'schedule_planner' ? 'SCHEDULE PLANNER' : id.toUpperCase(),
|
||||
role: id,
|
||||
description: 'config description',
|
||||
system_prompt: 'config prompt',
|
||||
enabled: true,
|
||||
selected_skill_ids: id === 'schedule_planner' ? [...persistedSelections] : [],
|
||||
}))
|
||||
mocks.updateConfig.mockImplementation(async (_id: string, payload: { selected_skill_ids?: string[] }) => {
|
||||
persistedSelections = payload.selected_skill_ids ? [...payload.selected_skill_ids] : persistedSelections
|
||||
return { selected_skill_ids: [...persistedSelections] }
|
||||
})
|
||||
|
||||
const firstWrapper = mount(AgentsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await firstWrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await firstWrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-2"]').setValue(false)
|
||||
await firstWrapper.get('[data-testid="linked-skills-save"]').trigger('click')
|
||||
await Promise.resolve()
|
||||
firstWrapper.unmount()
|
||||
|
||||
const secondWrapper = mount(AgentsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await secondWrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect((secondWrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-1"]').element as HTMLInputElement).checked).toBe(true)
|
||||
expect((secondWrapper.get('[data-testid="linked-skill-checkbox-skill-schedule-2"]').element as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
|
||||
it('shows an empty linked skills state when no matching skills are available', async () => {
|
||||
mocks.listSkills.mockResolvedValue({ data: [] })
|
||||
|
||||
const wrapper = mount(AgentsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="agent-chip-schedule_planner"]').trigger('click')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(wrapper.get('[data-testid="linked-skills-empty"]').text()).toContain('暂无可关联技能')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
743
frontend/src/pages/agents/composables/useAgentsPage.ts
Normal file
743
frontend/src/pages/agents/composables/useAgentsPage.ts
Normal file
@@ -0,0 +1,743 @@
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { COMMANDER_SKILLS, DEFAULT_AGENTS, MAIN_AGENT_ORDER, RELATION_LABELS, SUB_COMMANDERS } from '@/data/agents'
|
||||
import type { Agent, CommanderSkill, MainAgentId, SubCommander } from '@/data/agents'
|
||||
import { agentApi, type AgentHierarchyStats, type AgentStats } from '@/api/agent'
|
||||
import { skillApi, type Skill } from '@/api/skill'
|
||||
|
||||
export function useAgentsPage() {
|
||||
|
||||
const NODE_W = 200
|
||||
const NODE_H = 170
|
||||
const CHILD_W = 140
|
||||
const CHILD_H = 150
|
||||
const MASTER_TOP = 48
|
||||
const MAIN_TOP = 350
|
||||
const CHILD_TOP = 640
|
||||
const MAIN_XS: Record<Exclude<MainAgentId, 'master'>, number> = {
|
||||
schedule_planner: 12.5,
|
||||
executor: 37.5,
|
||||
librarian: 62.5,
|
||||
analyst: 87.5,
|
||||
}
|
||||
const CHILD_LANE_OFFSET = 6
|
||||
const motionEnabled = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-reduced-motion: no-preference)').matches
|
||||
: false
|
||||
const MIN_ZOOM = 0.8
|
||||
const MAX_ZOOM = 1.6
|
||||
const ZOOM_STEP = 0.1
|
||||
const CRISP_ZOOM_THRESHOLD = 1.12
|
||||
const OFFLINE_ROUTE_INTERVAL = 1700
|
||||
|
||||
type PlaybackHandle = ReturnType<typeof window.setTimeout>
|
||||
type PollHandle = ReturnType<typeof setInterval>
|
||||
interface AgentRuntimeState {
|
||||
callCount: number
|
||||
currentTask: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
interface AgentDraft {
|
||||
name: string
|
||||
role: string
|
||||
description: string
|
||||
systemPrompt: string
|
||||
enabled: boolean
|
||||
selectedSkillIds: string[]
|
||||
}
|
||||
|
||||
const mainAgents = computed(() => MAIN_AGENT_ORDER.map(id => localAgents[id]))
|
||||
const childAgents = SUB_COMMANDERS
|
||||
const relationLabels = RELATION_LABELS
|
||||
const childLabelMap = Object.fromEntries(childAgents.map(child => [child.id, child.name])) as Record<string, string>
|
||||
const childMetaMap = Object.fromEntries(childAgents.map(child => [child.id, child])) as Record<string, SubCommander>
|
||||
|
||||
const canvasRef = ref<HTMLElement | null>(null)
|
||||
const svgRef = ref<SVGElement | null>(null)
|
||||
const masterCardRef = ref<HTMLElement | null>(null)
|
||||
const nodeRefs: Record<string, HTMLElement> = {}
|
||||
const cleanupFns: Array<() => void> = []
|
||||
const hoverResetTimers: Record<string, PlaybackHandle | null> = {}
|
||||
const bgParticles = Array.from({ length: 60 }, (_, i) => {
|
||||
const d = 3 + Math.random() * 5
|
||||
const delay = Math.random() * 4
|
||||
const o = 0.25 + Math.random() * 0.5
|
||||
const size = 1 + Math.random() * 2.5
|
||||
return {
|
||||
id: i,
|
||||
style: {
|
||||
left: `${Math.random() * 98}%`,
|
||||
top: `${Math.random() * 95}%`,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
'--d': `${d}s`,
|
||||
'--delay': `${delay}s`,
|
||||
'--o': String(o),
|
||||
opacity: o,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let pollInterval: PollHandle | null = null
|
||||
let demoInterval: PollHandle | null = null
|
||||
|
||||
const selectedAgentId = ref<string | null>(null)
|
||||
const drawerOpen = ref(false)
|
||||
const addModalOpen = ref(false)
|
||||
const editAgent = ref<AgentDraft | null>(null)
|
||||
const newAgent = reactive({ name: '', roleKey: '', role: '', description: '', systemPrompt: '' })
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
const skillsLoading = ref(false)
|
||||
const skillsError = ref('')
|
||||
const saveError = ref('')
|
||||
const availableSkillsByNode = reactive<Record<string, Skill[]>>({})
|
||||
const agentSkillSelections = reactive<Record<string, string[]>>({})
|
||||
const activeSkillRequestId = ref(0)
|
||||
const zoom = ref(1)
|
||||
const pan = reactive({ x: 0, y: 0 })
|
||||
const basePan = reactive({ x: 0, y: 0 })
|
||||
const isPanning = ref(false)
|
||||
const panStart = reactive({ x: 0, y: 0 })
|
||||
const panOrigin = reactive({ x: 0, y: 0 })
|
||||
const connectionStatus = ref<'connected' | 'disconnected'>('disconnected')
|
||||
const connectionLabel = computed(() => connectionStatus.value === 'connected' ? '瀹炴椂鍚屾' : '绂荤嚎妯″紡')
|
||||
const zoomPercent = computed(() => `${Math.round(zoom.value * 100)}%`)
|
||||
const activeMainId = ref<string | null>(null)
|
||||
const activeChildId = ref<string | null>(null)
|
||||
const agentData = reactive<Record<string, AgentRuntimeState>>({})
|
||||
|
||||
const localAgents = reactive<Record<string, Agent>>(
|
||||
Object.fromEntries([
|
||||
...DEFAULT_AGENTS,
|
||||
...SUB_COMMANDERS.map((child) => ({
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
role: child.role,
|
||||
roleKey: child.id,
|
||||
description: child.description,
|
||||
systemPrompt: `${child.role}锛?{child.description}`,
|
||||
enabled: true,
|
||||
})),
|
||||
].map(agent => [agent.id, { ...agent }]))
|
||||
)
|
||||
|
||||
const nodePackagesMap = COMMANDER_SKILLS.reduce<Record<string, CommanderSkill[]>>((acc, skillPkg) => {
|
||||
skillPkg.relatedAgentIds.forEach((nodeId) => {
|
||||
acc[nodeId] = [...(acc[nodeId] || []), skillPkg]
|
||||
})
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const layoutZoom = computed(() => Math.min(zoom.value, CRISP_ZOOM_THRESHOLD))
|
||||
const stageScale = computed(() => zoom.value / layoutZoom.value)
|
||||
|
||||
const viewportStyle = computed(() => ({
|
||||
transform: `translate(${roundPx(basePan.x + pan.x)}px, ${roundPx(basePan.y + pan.y)}px)`,
|
||||
}))
|
||||
|
||||
const stageStyle = computed(() => ({
|
||||
width: '100%',
|
||||
minHeight: `${roundPx((CHILD_TOP + CHILD_H + 120) * layoutZoom.value)}px`,
|
||||
left: '0px',
|
||||
transform: `scale(${stageScale.value})`,
|
||||
transformOrigin: '50% 0%',
|
||||
'--node-scale': String(layoutZoom.value),
|
||||
}))
|
||||
|
||||
const activeMainAgents = computed(() => mainAgents.value.filter(agent => agent.id === activeMainId.value))
|
||||
const activeChildAgents = computed(() => childAgents.filter(child => child.id === activeChildId.value))
|
||||
const selectedNodePackages = computed(() => selectedAgentId.value ? nodePackagesMap[selectedAgentId.value] || [] : [])
|
||||
const selectedNodeSkills = computed(() => {
|
||||
if (!selectedAgentId.value) return []
|
||||
return (availableSkillsByNode[selectedAgentId.value] || []).filter(skill => matchesNodeSkill(selectedAgentId.value as string, skill))
|
||||
})
|
||||
const activeMainRouteLabel = computed(() => {
|
||||
if (!activeMainId.value) return 'STANDBY'
|
||||
return getAgentName(activeMainId.value)
|
||||
})
|
||||
const activeChildRouteLabel = computed(() => activeChildId.value ? childLabelMap[activeChildId.value] || 'STANDBY' : 'STANDBY')
|
||||
|
||||
function roundPx(value: number) {
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
function getCanvasMetrics() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return { width: 0, height: 0 }
|
||||
return { width: canvas.clientWidth, height: canvas.clientHeight }
|
||||
}
|
||||
|
||||
function pxToSvg(pctX: number) {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return 0
|
||||
return (pctX / 100) * canvas.clientWidth
|
||||
}
|
||||
|
||||
function updateBasePan() {
|
||||
const { width, height } = getCanvasMetrics()
|
||||
const scaledWidth = width * zoom.value
|
||||
const scaledHeight = height * zoom.value
|
||||
basePan.x = width ? roundPx((width - scaledWidth) / 2) : 0
|
||||
basePan.y = height ? roundPx((height - scaledHeight) / 2) : 0
|
||||
}
|
||||
|
||||
function getNodeMetrics(width = NODE_W, height = NODE_H) {
|
||||
return {
|
||||
width: roundPx(width * layoutZoom.value),
|
||||
height: roundPx(height * layoutZoom.value),
|
||||
paddingX: roundPx(16 * layoutZoom.value),
|
||||
paddingY: roundPx(14 * layoutZoom.value),
|
||||
corner: Math.max(6, roundPx(10 * layoutZoom.value)),
|
||||
status: Math.max(8, roundPx(10 * layoutZoom.value)),
|
||||
statusRing: Math.max(6, roundPx(8 * layoutZoom.value)),
|
||||
relOffset: roundPx(-20 * layoutZoom.value),
|
||||
}
|
||||
}
|
||||
|
||||
function buildNodeStyle(centerPct: number, top: number, width = NODE_W, height = NODE_H) {
|
||||
const x = pxToSvg(centerPct)
|
||||
const metrics = getNodeMetrics(width, height)
|
||||
return {
|
||||
left: `${roundPx(x - metrics.width / 2)}px`,
|
||||
top: `${roundPx(top)}px`,
|
||||
width: `${metrics.width}px`,
|
||||
height: `${metrics.height}px`,
|
||||
'--node-padding-x': `${metrics.paddingX}px`,
|
||||
'--node-padding-y': `${metrics.paddingY}px`,
|
||||
'--node-corner-size': `${metrics.corner}px`,
|
||||
'--node-status-size': `${metrics.status}px`,
|
||||
'--node-status-ring-size': `${metrics.statusRing}px`,
|
||||
'--node-rel-offset': `${metrics.relOffset}px`,
|
||||
}
|
||||
}
|
||||
|
||||
const masterNodeStyle = computed(() => buildNodeStyle(50, MASTER_TOP, NODE_W, NODE_H))
|
||||
function getMainNodeStyle(id: string) {
|
||||
return buildNodeStyle(getMainLaneX(id as Exclude<MainAgentId, 'master'>), MAIN_TOP, NODE_W, NODE_H)
|
||||
}
|
||||
function getChildNodeStyle(id: string) {
|
||||
const child = childMetaMap[id]
|
||||
const parentX = getMainLaneX(child.parentId as Exclude<MainAgentId, 'master'>)
|
||||
const siblingIndex = childAgents.filter(item => item.parentId === child.parentId).findIndex(item => item.id === id)
|
||||
const offset = siblingIndex === 0 ? -CHILD_LANE_OFFSET : CHILD_LANE_OFFSET
|
||||
return buildNodeStyle(parentX + offset, CHILD_TOP, CHILD_W, CHILD_H)
|
||||
}
|
||||
|
||||
function getCurvePath(fromX: number, fromY: number, toX: number, toY: number) {
|
||||
const midY = (fromY + toY) / 2
|
||||
return `M ${roundPx(fromX)},${roundPx(fromY)} C ${roundPx(fromX)},${roundPx(midY)} ${roundPx(toX)},${roundPx(midY)} ${roundPx(toX)},${roundPx(toY)}`
|
||||
}
|
||||
|
||||
function getBusLinePath(mainId: string) {
|
||||
const metrics = getNodeMetrics()
|
||||
return getCurvePath(pxToSvg(50), MASTER_TOP + metrics.height / 2, pxToSvg(getMainLaneX(mainId as Exclude<MainAgentId, 'master'>)), MAIN_TOP + metrics.height / 2)
|
||||
}
|
||||
|
||||
function getSubLinePath(childId: string) {
|
||||
const child = childMetaMap[childId]
|
||||
const mainMetrics = getNodeMetrics()
|
||||
const childMetrics = getNodeMetrics(CHILD_W, CHILD_H)
|
||||
const parentX = pxToSvg(getMainLaneX(child.parentId as Exclude<MainAgentId, 'master'>))
|
||||
const siblingIndex = childAgents.filter(item => item.parentId === child.parentId).findIndex(item => item.id === childId)
|
||||
const childX = pxToSvg(getMainLaneX(child.parentId as Exclude<MainAgentId, 'master'>) + (siblingIndex === 0 ? -CHILD_LANE_OFFSET : CHILD_LANE_OFFSET))
|
||||
return getCurvePath(parentX, MAIN_TOP + mainMetrics.height / 2, childX, CHILD_TOP + childMetrics.height / 2)
|
||||
}
|
||||
|
||||
function updateSvgSize() {
|
||||
const canvas = canvasRef.value
|
||||
const svg = svgRef.value
|
||||
if (!canvas || !svg) return
|
||||
svg.setAttribute('width', String(canvas.clientWidth))
|
||||
svg.setAttribute('height', String(Math.max(canvas.clientHeight, CHILD_TOP + CHILD_H + 120)))
|
||||
}
|
||||
|
||||
function setNodeRef(id: string, el: HTMLElement | null) {
|
||||
if (el) nodeRefs[id] = el
|
||||
}
|
||||
|
||||
function getStatusClass(agentId: string) {
|
||||
const data = agentData[agentId]
|
||||
const agent = localAgents[agentId]
|
||||
if (!agent?.enabled) return 'disabled'
|
||||
if (!data) return 'idle'
|
||||
return data.status === 'active' ? 'active' : 'idle'
|
||||
}
|
||||
function getAgentName(id: string) { return localAgents[id]?.name || id.toUpperCase() }
|
||||
function getAgentRole(id: string) { return localAgents[id]?.role || '' }
|
||||
function getAgentDesc(id: string) { return localAgents[id]?.description || '' }
|
||||
function getMainLaneX(agentId: Exclude<MainAgentId, 'master'>) {
|
||||
return MAIN_XS[agentId]
|
||||
}
|
||||
function getNodeRootAgentType(nodeId: string) {
|
||||
if (nodeId === 'master') return 'general'
|
||||
return childMetaMap[nodeId]?.parentId || nodeId
|
||||
}
|
||||
function matchesNodeSkill(nodeId: string, skill: Skill) {
|
||||
return skill.is_active && skill.agent_type === getNodeRootAgentType(nodeId)
|
||||
}
|
||||
function ensureSkillSelections(nodeId: string, fallbackSkillIds?: string[]) {
|
||||
if (agentSkillSelections[nodeId]) return
|
||||
agentSkillSelections[nodeId] = fallbackSkillIds ? [...fallbackSkillIds] : selectedNodeSkills.value.map(skill => skill.id)
|
||||
}
|
||||
async function loadSkillsForNode(nodeId: string) {
|
||||
const requestId = activeSkillRequestId.value + 1
|
||||
activeSkillRequestId.value = requestId
|
||||
skillsLoading.value = true
|
||||
skillsError.value = ''
|
||||
try {
|
||||
const response = await skillApi.list({ agent_type: getNodeRootAgentType(nodeId) })
|
||||
if (activeSkillRequestId.value !== requestId || selectedAgentId.value !== nodeId) return
|
||||
availableSkillsByNode[nodeId] = response.data
|
||||
if (!agentSkillSelections[nodeId]) {
|
||||
agentSkillSelections[nodeId] = response.data.filter(skill => matchesNodeSkill(nodeId, skill)).map(skill => skill.id)
|
||||
}
|
||||
} catch {
|
||||
if (activeSkillRequestId.value !== requestId || selectedAgentId.value !== nodeId) return
|
||||
availableSkillsByNode[nodeId] = []
|
||||
skillsError.value = '加载技能失败,请稍后重试。'
|
||||
} finally {
|
||||
if (activeSkillRequestId.value === requestId && selectedAgentId.value === nodeId) {
|
||||
skillsLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
function toggleSkillSelection(skillId: string, checked: boolean) {
|
||||
if (!editAgent.value) return
|
||||
editAgent.value = {
|
||||
...editAgent.value,
|
||||
selectedSkillIds: checked
|
||||
? [...editAgent.value.selectedSkillIds, skillId]
|
||||
: editAgent.value.selectedSkillIds.filter(id => id !== skillId),
|
||||
}
|
||||
}
|
||||
function clampZoom(value: number) {
|
||||
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(value.toFixed(2))))
|
||||
}
|
||||
|
||||
function applyZoom(nextZoom: number, anchorX?: number, anchorY?: number) {
|
||||
const clamped = clampZoom(nextZoom)
|
||||
const canvas = canvasRef.value
|
||||
const previousZoom = zoom.value
|
||||
|
||||
if (!canvas || clamped === previousZoom) {
|
||||
zoom.value = clamped
|
||||
updateBasePan()
|
||||
return
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const localX = anchorX ?? rect.width / 2
|
||||
const localY = anchorY ?? rect.height / 2
|
||||
const contentX = (localX - (basePan.x + pan.x)) / previousZoom
|
||||
const contentY = (localY - (basePan.y + pan.y)) / previousZoom
|
||||
|
||||
zoom.value = clamped
|
||||
updateBasePan()
|
||||
|
||||
pan.x = roundPx(localX - basePan.x - contentX * zoom.value)
|
||||
pan.y = roundPx(localY - basePan.y - contentY * zoom.value)
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
applyZoom(zoom.value + ZOOM_STEP)
|
||||
}
|
||||
function zoomOut() {
|
||||
applyZoom(zoom.value - ZOOM_STEP)
|
||||
}
|
||||
function resetView() {
|
||||
zoom.value = 1
|
||||
pan.x = 0
|
||||
pan.y = 0
|
||||
updateBasePan()
|
||||
}
|
||||
function handleWheel(event: WheelEvent) {
|
||||
const delta = event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) {
|
||||
applyZoom(zoom.value + delta)
|
||||
return
|
||||
}
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
applyZoom(zoom.value + delta, event.clientX - rect.left, event.clientY - rect.top)
|
||||
}
|
||||
function startPan(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target || target.closest('.node-card') || target.closest('.canvas-controls') || target.closest('.hud-panel')) return
|
||||
isPanning.value = true
|
||||
panStart.x = event.clientX
|
||||
panStart.y = event.clientY
|
||||
panOrigin.x = pan.x
|
||||
panOrigin.y = pan.y
|
||||
window.addEventListener('mousemove', movePan)
|
||||
window.addEventListener('mouseup', endPan, { once: true })
|
||||
}
|
||||
function movePan(event: MouseEvent) {
|
||||
if (!isPanning.value) return
|
||||
pan.x = panOrigin.x + event.clientX - panStart.x
|
||||
pan.y = panOrigin.y + event.clientY - panStart.y
|
||||
}
|
||||
function endPan() {
|
||||
isPanning.value = false
|
||||
window.removeEventListener('mousemove', movePan)
|
||||
}
|
||||
|
||||
async function selectAgent(id: string) {
|
||||
const agent = localAgents[id]
|
||||
if (!agent) return
|
||||
selectedAgentId.value = id
|
||||
saveError.value = ''
|
||||
let persistedSkillIds: string[] | undefined
|
||||
try {
|
||||
const config = await agentApi.getConfig(id)
|
||||
if (localAgents[id]) {
|
||||
Object.assign(localAgents[id], {
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
systemPrompt: config.system_prompt,
|
||||
enabled: config.enabled,
|
||||
})
|
||||
}
|
||||
persistedSkillIds = config.selected_skill_ids || []
|
||||
agentSkillSelections[id] = [...persistedSkillIds]
|
||||
} catch {
|
||||
persistedSkillIds = agentSkillSelections[id] || []
|
||||
}
|
||||
await loadSkillsForNode(id)
|
||||
ensureSkillSelections(id, persistedSkillIds)
|
||||
editAgent.value = {
|
||||
name: localAgents[id].name,
|
||||
role: localAgents[id].role,
|
||||
description: localAgents[id].description,
|
||||
systemPrompt: localAgents[id].systemPrompt,
|
||||
enabled: localAgents[id].enabled,
|
||||
selectedSkillIds: [...(agentSkillSelections[id] || [])],
|
||||
}
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function resetConfig() {
|
||||
const currentId = selectedAgentId.value || ''
|
||||
const original = localAgents[currentId]
|
||||
if (original && editAgent.value) {
|
||||
editAgent.value = {
|
||||
name: original.name,
|
||||
role: original.role,
|
||||
description: original.description,
|
||||
systemPrompt: original.systemPrompt,
|
||||
enabled: original.enabled,
|
||||
selectedSkillIds: [...(agentSkillSelections[currentId] || [])],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!editAgent.value || !selectedAgentId.value) return
|
||||
saving.value = true
|
||||
saveError.value = ''
|
||||
try {
|
||||
const nextLocalState = {
|
||||
name: editAgent.value.name,
|
||||
role: editAgent.value.role,
|
||||
description: editAgent.value.description,
|
||||
systemPrompt: editAgent.value.systemPrompt,
|
||||
enabled: editAgent.value.enabled,
|
||||
}
|
||||
try {
|
||||
const response = await agentApi.updateConfig(selectedAgentId.value, {
|
||||
name: editAgent.value.name,
|
||||
description: editAgent.value.description,
|
||||
system_prompt: editAgent.value.systemPrompt,
|
||||
enabled: editAgent.value.enabled,
|
||||
selected_skill_ids: [...editAgent.value.selectedSkillIds],
|
||||
})
|
||||
if (localAgents[selectedAgentId.value]) {
|
||||
Object.assign(localAgents[selectedAgentId.value], nextLocalState)
|
||||
}
|
||||
agentSkillSelections[selectedAgentId.value] = [...(response.selected_skill_ids || editAgent.value.selectedSkillIds)]
|
||||
drawerOpen.value = false
|
||||
} catch {
|
||||
saveError.value = '保存失败,请稍后重试。'
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addAgent() {
|
||||
if (!newAgent.name || !newAgent.roleKey) return
|
||||
const id = newAgent.roleKey.toLowerCase().replace(/\s+/g, '_')
|
||||
if (localAgents[id]) return
|
||||
localAgents[id] = { id, name: newAgent.name.toUpperCase(), role: newAgent.role, roleKey: id, description: newAgent.description, systemPrompt: newAgent.systemPrompt, enabled: true }
|
||||
addModalOpen.value = false
|
||||
}
|
||||
|
||||
function setRuntimeState(agentId: string, state: AgentStats) {
|
||||
agentData[agentId] = {
|
||||
callCount: state.call_count,
|
||||
currentTask: state.current_task,
|
||||
status: state.status,
|
||||
}
|
||||
}
|
||||
|
||||
function applyHierarchyStats(stats: AgentHierarchyStats) {
|
||||
agentData.master = { callCount: 47, currentTask: '鍗忚皟缁勭粐閾捐矾', status: 'active' }
|
||||
let nextMain: string | null = null
|
||||
let nextChild: string | null = null
|
||||
|
||||
for (const main of stats.main_agents) {
|
||||
setRuntimeState(main.agent_id, main)
|
||||
if (main.status === 'active') nextMain = main.agent_id
|
||||
|
||||
for (const child of main.sub_commanders) {
|
||||
setRuntimeState(child.agent_id, child)
|
||||
if (child.status === 'active') {
|
||||
nextMain = main.agent_id
|
||||
nextChild = child.agent_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activeMainId.value = nextMain
|
||||
activeChildId.value = nextChild
|
||||
}
|
||||
|
||||
function stopDemoRouteCycle() {
|
||||
if (demoInterval) {
|
||||
clearInterval(demoInterval)
|
||||
demoInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
function startDemoRouteCycle() {
|
||||
stopDemoRouteCycle()
|
||||
const demoRoutes = childAgents.map(child => ({ mainId: child.parentId, childId: child.id }))
|
||||
let index = 0
|
||||
const applyRoute = () => {
|
||||
const route = demoRoutes[index]
|
||||
activeMainId.value = route.mainId
|
||||
activeChildId.value = route.childId
|
||||
index = (index + 1) % demoRoutes.length
|
||||
}
|
||||
applyRoute()
|
||||
demoInterval = setInterval(applyRoute, OFFLINE_ROUTE_INTERVAL)
|
||||
}
|
||||
|
||||
function buildOfflineStats() {
|
||||
return {
|
||||
main_agents: [
|
||||
{
|
||||
agent_id: 'schedule_planner',
|
||||
call_count: 12,
|
||||
current_task: null,
|
||||
status: 'active',
|
||||
sub_commanders: [
|
||||
{ agent_id: 'schedule_analysis', call_count: 4, current_task: '姊崇悊浠婃棩閲嶇偣', status: 'active' },
|
||||
{ agent_id: 'schedule_planning', call_count: 9, current_task: null, status: 'idle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
agent_id: 'executor',
|
||||
call_count: 8,
|
||||
current_task: '鍒涘缓鏂囨。',
|
||||
status: 'idle',
|
||||
sub_commanders: [
|
||||
{ agent_id: 'executor_tasks', call_count: 8, current_task: null, status: 'idle' },
|
||||
{ agent_id: 'executor_forum', call_count: 4, current_task: null, status: 'idle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
agent_id: 'librarian',
|
||||
call_count: 5,
|
||||
current_task: null,
|
||||
status: 'idle',
|
||||
sub_commanders: [
|
||||
{ agent_id: 'librarian_retrieval', call_count: 5, current_task: null, status: 'idle' },
|
||||
{ agent_id: 'librarian_graph', call_count: 2, current_task: null, status: 'idle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
agent_id: 'analyst',
|
||||
call_count: 3,
|
||||
current_task: null,
|
||||
status: 'idle',
|
||||
sub_commanders: [
|
||||
{ agent_id: 'analyst_progress', call_count: 2, current_task: null, status: 'idle' },
|
||||
{ agent_id: 'analyst_insights', call_count: 3, current_task: null, status: 'idle' },
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies AgentHierarchyStats
|
||||
}
|
||||
|
||||
async function refreshStats() {
|
||||
loading.value = true
|
||||
try {
|
||||
const stats = await agentApi.getHierarchyStats()
|
||||
applyHierarchyStats(stats)
|
||||
connectionStatus.value = 'connected'
|
||||
stopDemoRouteCycle()
|
||||
} catch {
|
||||
connectionStatus.value = 'disconnected'
|
||||
applyHierarchyStats(buildOfflineStats())
|
||||
startDemoRouteCycle()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function stopTimer(timer: PlaybackHandle | null) {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
}
|
||||
|
||||
function runTransition(el: Element, keyframes: Keyframe[], options: KeyframeAnimationOptions, done?: () => void) {
|
||||
const target = el as HTMLElement
|
||||
if (typeof target.animate !== 'function') {
|
||||
done?.()
|
||||
return null
|
||||
}
|
||||
const animation = target.animate(keyframes, { fill: 'forwards', ...options })
|
||||
const finish = () => done?.()
|
||||
animation.addEventListener('finish', finish, { once: true })
|
||||
cleanupFns.push(() => animation.cancel())
|
||||
return animation
|
||||
}
|
||||
|
||||
function animateIn(el: Element, done: () => void) {
|
||||
runTransition(el, [{ opacity: 0, transform: 'translateX(80px)' }, { opacity: 1, transform: 'translateX(0)' }], { duration: 350, easing: 'cubic-bezier(0.4, 0, 0.2, 1)' }, done)
|
||||
}
|
||||
function animateOut(el: Element, done: () => void) {
|
||||
runTransition(el, [{ opacity: 1, transform: 'translateX(0)' }, { opacity: 0, transform: 'translateX(80px)' }], { duration: 250, easing: 'cubic-bezier(0.4, 0, 1, 1)' }, done)
|
||||
}
|
||||
function fadeIn(el: Element, done: () => void) {
|
||||
runTransition(el, [{ opacity: 0 }, { opacity: 1 }], { duration: 250 }, done)
|
||||
}
|
||||
function fadeOut(el: Element, done: () => void) {
|
||||
runTransition(el, [{ opacity: 1 }, { opacity: 0 }], { duration: 200 }, done)
|
||||
}
|
||||
|
||||
function playEntranceAnimations() {
|
||||
if (!motionEnabled) return
|
||||
if (masterCardRef.value) {
|
||||
runTransition(masterCardRef.value, [
|
||||
{ opacity: 0, transform: 'translateY(14px) scale(0.99)', filter: 'brightness(0.86)' },
|
||||
{ opacity: 1, transform: 'translateY(0) scale(1)', filter: 'brightness(1)' },
|
||||
], { duration: 680, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' })
|
||||
}
|
||||
|
||||
;[...mainAgents.value.map(agent => agent.id), ...childAgents.map(child => child.id)].forEach((id, idx) => {
|
||||
const el = nodeRefs[id]
|
||||
if (!el) return
|
||||
runTransition(el, [
|
||||
{ opacity: 0, transform: 'translateY(12px) scale(0.99)', filter: 'brightness(0.82)' },
|
||||
{ opacity: 1, transform: 'translateY(0) scale(1)', filter: 'brightness(1)' },
|
||||
], { duration: 500, delay: 210 + idx * 55, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' })
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!localAgents[id]?.enabled) return
|
||||
stopTimer(hoverResetTimers[id] ?? null)
|
||||
el.style.transform = 'translateY(-2px)'
|
||||
}
|
||||
const handleMouseLeave = () => {
|
||||
stopTimer(hoverResetTimers[id] ?? null)
|
||||
hoverResetTimers[id] = window.setTimeout(() => {
|
||||
el.style.transform = ''
|
||||
hoverResetTimers[id] = null
|
||||
}, 180)
|
||||
}
|
||||
|
||||
el.addEventListener('mouseenter', handleMouseEnter)
|
||||
el.addEventListener('mouseleave', handleMouseLeave)
|
||||
cleanupFns.push(() => {
|
||||
stopTimer(hoverResetTimers[id] ?? null)
|
||||
el.removeEventListener('mouseenter', handleMouseEnter)
|
||||
el.removeEventListener('mouseleave', handleMouseLeave)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshStats()
|
||||
pollInterval = setInterval(refreshStats, 5000)
|
||||
requestAnimationFrame(() => {
|
||||
updateBasePan()
|
||||
updateSvgSize()
|
||||
playEntranceAnimations()
|
||||
})
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updateBasePan()
|
||||
updateSvgSize()
|
||||
})
|
||||
if (canvasRef.value) resizeObserver.observe(canvasRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollInterval) clearInterval(pollInterval)
|
||||
stopDemoRouteCycle()
|
||||
resizeObserver?.disconnect()
|
||||
window.removeEventListener('mousemove', movePan)
|
||||
window.removeEventListener('mouseup', endPan)
|
||||
cleanupFns.forEach(cleanup => cleanup())
|
||||
})
|
||||
return {
|
||||
bgParticles,
|
||||
canvasRef,
|
||||
svgRef,
|
||||
masterCardRef,
|
||||
isPanning,
|
||||
connectionStatus,
|
||||
connectionLabel,
|
||||
loading,
|
||||
drawerOpen,
|
||||
addModalOpen,
|
||||
editAgent,
|
||||
newAgent,
|
||||
skillsLoading,
|
||||
skillsError,
|
||||
saveError,
|
||||
saving,
|
||||
mainAgents,
|
||||
childAgents,
|
||||
relationLabels,
|
||||
activeMainId,
|
||||
activeChildId,
|
||||
activeMainAgents,
|
||||
activeChildAgents,
|
||||
activeMainRouteLabel,
|
||||
activeChildRouteLabel,
|
||||
selectedAgentId,
|
||||
selectedNodePackages,
|
||||
selectedNodeSkills,
|
||||
agentData,
|
||||
localAgents,
|
||||
viewportStyle,
|
||||
stageStyle,
|
||||
masterNodeStyle,
|
||||
zoomPercent,
|
||||
refreshStats,
|
||||
startPan,
|
||||
handleWheel,
|
||||
getBusLinePath,
|
||||
getSubLinePath,
|
||||
selectAgent,
|
||||
setNodeRef,
|
||||
getStatusClass,
|
||||
getAgentName,
|
||||
getAgentRole,
|
||||
getAgentDesc,
|
||||
getMainNodeStyle,
|
||||
getChildNodeStyle,
|
||||
zoomOut,
|
||||
zoomIn,
|
||||
resetView,
|
||||
toggleSkillSelection,
|
||||
resetConfig,
|
||||
saveConfig,
|
||||
addAgent,
|
||||
animateIn,
|
||||
animateOut,
|
||||
fadeIn,
|
||||
fadeOut,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import SidebarNav from '@/components/SidebarNav.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -19,9 +18,13 @@ watch(
|
||||
|
||||
<template>
|
||||
<div class="layout scanlines">
|
||||
<SidebarNav />
|
||||
<main class="main-content grid-bg">
|
||||
<RouterView />
|
||||
<div class="grid-drift-layer" aria-hidden="true"></div>
|
||||
<div class="grid-scan-layer" aria-hidden="true"></div>
|
||||
<div class="grid-vertical-scan-layer" aria-hidden="true"></div>
|
||||
<div class="content-layer">
|
||||
<RouterView />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -40,4 +43,63 @@ watch(
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* animated background layers */
|
||||
.grid-drift-layer,
|
||||
.grid-scan-layer,
|
||||
.grid-vertical-scan-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-drift-layer {
|
||||
z-index: 0;
|
||||
opacity: 0.7;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 245, 212, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 245, 212, 0.04) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
animation: grid-drift 18s linear infinite;
|
||||
}
|
||||
|
||||
.grid-scan-layer {
|
||||
z-index: 2;
|
||||
background:
|
||||
linear-gradient(120deg, transparent 0%, rgba(0, 245, 212, 0.01) 46%, rgba(0, 245, 212, 0.38) 50%, rgba(0, 245, 212, 0.01) 54%, transparent 100%);
|
||||
mix-blend-mode: screen;
|
||||
filter: drop-shadow(0 0 18px rgba(0, 245, 212, 0.35));
|
||||
will-change: transform, opacity;
|
||||
opacity: 0;
|
||||
transform: translate3d(-28%, 0, 0);
|
||||
animation: grid-scan-sweep 5.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.grid-vertical-scan-layer {
|
||||
z-index: 2;
|
||||
background:
|
||||
linear-gradient(180deg, transparent 0%, rgba(0, 245, 212, 0.008) 47%, rgba(0, 245, 212, 0.34) 50%, rgba(0, 245, 212, 0.008) 53%, transparent 100%);
|
||||
mix-blend-mode: screen;
|
||||
filter: drop-shadow(0 0 18px rgba(0, 245, 212, 0.3));
|
||||
will-change: transform, opacity;
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -24%, 0);
|
||||
animation: grid-vertical-scan 7.4s ease-in-out infinite 0.8s;
|
||||
}
|
||||
|
||||
.content-layer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.grid-drift-layer,
|
||||
.grid-scan-layer,
|
||||
.grid-vertical-scan-layer {
|
||||
animation: none;
|
||||
transform: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('brain graph embedding', () => {
|
||||
it('renders the reusable graph projection component directly instead of an iframe shell', () => {
|
||||
const brainPage = readFileSync(path.resolve(__dirname, './index.vue'), 'utf-8')
|
||||
|
||||
expect(brainPage).toContain('GraphProjection')
|
||||
expect(brainPage).not.toContain('<iframe')
|
||||
expect(brainPage).not.toContain('src="/graph"')
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { navItems } from '@/app/navigation/nav'
|
||||
import { appChildren } from '@/app/router/routes'
|
||||
|
||||
describe('brain routing', () => {
|
||||
it('points the knowledge brain nav item to /brain', () => {
|
||||
const item = navItems.find((entry) => entry.name === '知识大脑')
|
||||
|
||||
expect(item?.path).toBe('/brain')
|
||||
})
|
||||
|
||||
it('registers a brain page route', () => {
|
||||
const route = appChildren.find((entry) => entry.name === 'brain')
|
||||
|
||||
expect(route?.path).toBe('brain')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import GraphProjection from '@/components/brain/GraphProjection.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GraphProjection fullscreen :show-open-full-view="false" />
|
||||
</template>
|
||||
108
frontend/src/pages/chat/ChatTopbarShortcuts.test.ts
Normal file
108
frontend/src/pages/chat/ChatTopbarShortcuts.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import ChatPage from './index.vue'
|
||||
import { navItems } from '@/app/navigation/nav'
|
||||
|
||||
vi.mock('@/pages/chat/composables/useChatView', async () => {
|
||||
const { ref } = await import('vue')
|
||||
|
||||
return {
|
||||
useChatView: () => ({
|
||||
store: {
|
||||
conversations: [],
|
||||
messages: [],
|
||||
currentConversationId: null,
|
||||
},
|
||||
inputMessage: ref(''),
|
||||
isSending: ref(false),
|
||||
chatContainer: ref(null),
|
||||
inputRef: ref(null),
|
||||
isTyping: ref(false),
|
||||
fileInputRef: ref(null),
|
||||
showEmojiPicker: ref(false),
|
||||
chatModels: ref([]),
|
||||
selectedModelName: ref(''),
|
||||
selectedModel: ref(null),
|
||||
isLoadingModels: ref(false),
|
||||
conversationsError: ref(''),
|
||||
orchestrationStatus: ref('idle'),
|
||||
orchestrationInsight: ref({ statusTitle: '', jarvisNote: '', details: [] }),
|
||||
activeAgent: ref(''),
|
||||
visitedAgents: ref([]),
|
||||
orchestrationEventFeed: ref([]),
|
||||
systemMeta: ref({
|
||||
systemName: '',
|
||||
systemVersion: '',
|
||||
uptimeSeconds: 0,
|
||||
gpuUtilPercent: null,
|
||||
gpuName: '',
|
||||
gpuMemoryUsedMb: null,
|
||||
gpuMemoryTotalMb: null,
|
||||
diskUsedGb: 0,
|
||||
diskTotalGb: 0,
|
||||
}),
|
||||
systemTelemetry: ref({
|
||||
cpu: { online: false, current: null, series: [] },
|
||||
memory: { online: false, current: null, series: [] },
|
||||
disk: { online: false, current: null, series: [] },
|
||||
gpu: { online: false, current: null, series: [] },
|
||||
network: {
|
||||
upload: { online: false, current: null, series: [] },
|
||||
download: { online: false, current: null, series: [] },
|
||||
},
|
||||
}),
|
||||
sessionTelemetry: ref({
|
||||
eventsCount: 0,
|
||||
toolCount: 0,
|
||||
agentCount: 0,
|
||||
activitySeries: [],
|
||||
}),
|
||||
sendMessage: vi.fn(),
|
||||
selectConversation: vi.fn(),
|
||||
newConversation: vi.fn(),
|
||||
deleteConversation: vi.fn(),
|
||||
formatTime: vi.fn(() => ''),
|
||||
formatConvDate: vi.fn(() => ''),
|
||||
autoResize: vi.fn(),
|
||||
handleFileSelect: vi.fn(),
|
||||
insertEmoji: vi.fn(),
|
||||
openFilePicker: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Chat topbar shortcuts', () => {
|
||||
it('replaces READY/heartbeat with shortcut icon row', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: navItems.map((item) => ({
|
||||
path: item.path,
|
||||
name: item.path,
|
||||
component: { template: '<div />' },
|
||||
})),
|
||||
})
|
||||
|
||||
await router.push('/chat')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(ChatPage, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
stubs: {
|
||||
TelemetrySparkline: true,
|
||||
OrchestrationPanel: true,
|
||||
EmojiPicker: true,
|
||||
FileMessage: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.status-text').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('READY')
|
||||
expect(wrapper.find('[data-testid="nav-shortcut-row"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -130,11 +130,11 @@ describe('useChatView orchestration state', () => {
|
||||
handlers.onMetadata?.({ conversation_id: 'conv-1', message_id: 'msg-1' })
|
||||
handlers.onProgress?.({
|
||||
stage: 'planning',
|
||||
label: 'Jarvis 正在拆解步骤',
|
||||
agent: 'planner',
|
||||
label: 'Jarvis 正在编排日程',
|
||||
agent: 'schedule_planner',
|
||||
tool_name: null,
|
||||
step: '正在分配任务',
|
||||
steps: ['理解问题', '分配 planner'],
|
||||
step: '正在生成今日安排',
|
||||
steps: ['理解当前承诺', '分配 schedule_planner'],
|
||||
})
|
||||
handlers.onChunk?.({ content: '最终回复' })
|
||||
})
|
||||
@@ -150,9 +150,9 @@ describe('useChatView orchestration state', () => {
|
||||
expect(view.isTyping.value).toBe(true)
|
||||
expect(view.orchestrationPanelVisible.value).toBe(true)
|
||||
expect(view.orchestrationStatus.value).toBe('active')
|
||||
expect(view.activeAgent.value).toBe('planner')
|
||||
expect(view.visitedAgents.value).toContain('planner')
|
||||
expect(view.orchestrationEventFeed.value.map((item) => item.label)).toContain('正在分配任务')
|
||||
expect(view.activeAgent.value).toBe('schedule_planner')
|
||||
expect(view.visitedAgents.value).toContain('schedule_planner')
|
||||
expect(view.orchestrationEventFeed.value.flatMap((group) => group.items.map((item) => item.label))).toContain('正在生成今日安排')
|
||||
|
||||
await promise
|
||||
|
||||
@@ -161,8 +161,37 @@ describe('useChatView orchestration state', () => {
|
||||
expect(view.store.messages[1].content).toBe('最终回复')
|
||||
expect(view.orchestrationPanelVisible.value).toBe(true)
|
||||
expect(view.orchestrationStatus.value).toBe('complete')
|
||||
expect(view.orchestrationEventFeed.value.at(-1)?.label).toBe('响应已生成')
|
||||
expect(view.activeAgent.value).toBe('planner')
|
||||
expect(view.orchestrationEventFeed.value.at(-1)?.items.at(-1)?.label).toBe('响应已生成')
|
||||
expect(view.activeAgent.value).toBe('schedule_planner')
|
||||
expect(view.store.messages).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('surfaces schedule fulfillment progress when chat creates a reminder', async () => {
|
||||
mocks.chatStream.mockImplementation(async (_message, _conversationId, _fileIds, _modelName, handlers) => {
|
||||
handlers.onMetadata?.({ conversation_id: 'conv-2', message_id: 'msg-2' })
|
||||
handlers.onProgress?.({
|
||||
stage: 'tool',
|
||||
label: 'Jarvis 正在调用工具',
|
||||
agent: 'executor',
|
||||
tool_name: 'create_reminder',
|
||||
step: '提醒创建成功: [abcd1234] 站会 @ 2026-03-28T09:00:00',
|
||||
steps: [],
|
||||
})
|
||||
handlers.onChunk?.({ content: '已经帮你建好提醒。' })
|
||||
})
|
||||
|
||||
const view = useChatView()
|
||||
view.inputMessage.value = '明天9点提醒我开站会'
|
||||
|
||||
const promise = view.sendMessage()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(view.orchestrationInsight.value.statusTitle).toBe('FULFILLMENT')
|
||||
expect(view.orchestrationInsight.value.jarvisNote).toContain('真的落到系统里')
|
||||
expect(view.orchestrationEventFeed.value.flatMap((group) => group.items.map((item) => item.label))).toContain('提醒创建成功: [abcd1234] 站会 @ 2026-03-28T09:00:00')
|
||||
|
||||
await promise
|
||||
|
||||
expect(view.store.messages.at(-1)?.content).toBe('已经帮你建好提醒。')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,10 +32,18 @@ interface ThinkingState {
|
||||
|
||||
interface OrchestrationEventItem {
|
||||
id: string
|
||||
time: string
|
||||
label: string
|
||||
kind: 'info' | 'tool' | 'success' | 'error'
|
||||
}
|
||||
|
||||
interface OrchestrationEventGroup {
|
||||
id: string
|
||||
startedAt: string
|
||||
status: 'active' | 'success' | 'error'
|
||||
items: OrchestrationEventItem[]
|
||||
}
|
||||
|
||||
interface OrchestrationInsight {
|
||||
statusTitle: string
|
||||
systemSummary: string
|
||||
@@ -50,8 +58,27 @@ interface TelemetryMetricState {
|
||||
|
||||
interface SystemTelemetryState {
|
||||
cpu: TelemetryMetricState
|
||||
gpu: TelemetryMetricState
|
||||
memory: TelemetryMetricState
|
||||
disk: TelemetryMetricState
|
||||
network: {
|
||||
upload: TelemetryMetricState
|
||||
download: TelemetryMetricState
|
||||
}
|
||||
}
|
||||
|
||||
interface SystemMetaState {
|
||||
systemName: string
|
||||
systemVersion: string
|
||||
hostname: string
|
||||
timestamp: string
|
||||
uptimeSeconds: number
|
||||
diskUsedGb: number
|
||||
diskTotalGb: number
|
||||
gpuName: string | null
|
||||
gpuMemoryTotalMb: number | null
|
||||
gpuMemoryUsedMb: number | null
|
||||
gpuUtilPercent: number | null
|
||||
}
|
||||
|
||||
interface SessionTelemetryState {
|
||||
@@ -63,6 +90,10 @@ interface SessionTelemetryState {
|
||||
|
||||
type OrchestrationStatus = 'idle' | 'active' | 'complete' | 'error'
|
||||
|
||||
const ORCHESTRATION_EVENT_STORAGE_KEY = 'jarvis.chat.orchestration.events'
|
||||
const ORCHESTRATION_EVENT_GROUP_LIMIT = 40
|
||||
const ORCHESTRATION_EVENT_ITEM_LIMIT = 24
|
||||
|
||||
export function useChatView() {
|
||||
const store = useConversationStore()
|
||||
const auth = useAuthStore()
|
||||
@@ -89,11 +120,29 @@ export function useChatView() {
|
||||
})
|
||||
const activeAgent = ref<string | null>(null)
|
||||
const visitedAgents = ref<string[]>([])
|
||||
const orchestrationEventFeed = ref<OrchestrationEventItem[]>([])
|
||||
const orchestrationEventFeed = ref<OrchestrationEventGroup[]>([])
|
||||
const systemTelemetry = ref<SystemTelemetryState>({
|
||||
cpu: { current: null, series: [], online: false },
|
||||
gpu: { current: null, series: [], online: false },
|
||||
memory: { current: null, series: [], online: false },
|
||||
disk: { current: null, series: [], online: false },
|
||||
network: {
|
||||
upload: { current: null, series: [], online: false },
|
||||
download: { current: null, series: [], online: false },
|
||||
},
|
||||
})
|
||||
const systemMeta = ref<SystemMetaState>({
|
||||
systemName: '--',
|
||||
systemVersion: '--',
|
||||
hostname: '--',
|
||||
timestamp: '',
|
||||
uptimeSeconds: 0,
|
||||
diskUsedGb: 0,
|
||||
diskTotalGb: 0,
|
||||
gpuName: null,
|
||||
gpuMemoryTotalMb: null,
|
||||
gpuMemoryUsedMb: null,
|
||||
gpuUtilPercent: null,
|
||||
})
|
||||
const sessionTelemetry = ref<SessionTelemetryState>({
|
||||
activitySeries: [],
|
||||
@@ -105,6 +154,68 @@ export function useChatView() {
|
||||
let systemTelemetryTimer: ReturnType<typeof setInterval> | null = null
|
||||
let sessionTelemetryTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function loadPersistedOrchestrationEvents() {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ORCHESTRATION_EVENT_STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return
|
||||
orchestrationEventFeed.value = parsed
|
||||
.filter((group): group is OrchestrationEventGroup => (
|
||||
group
|
||||
&& typeof group.id === 'string'
|
||||
&& typeof group.startedAt === 'string'
|
||||
&& ['active', 'success', 'error'].includes(group.status)
|
||||
&& Array.isArray(group.items)
|
||||
))
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item): item is OrchestrationEventItem => (
|
||||
item
|
||||
&& typeof item.id === 'string'
|
||||
&& typeof item.time === 'string'
|
||||
&& typeof item.label === 'string'
|
||||
&& ['info', 'tool', 'success', 'error'].includes(item.kind)
|
||||
)).slice(-ORCHESTRATION_EVENT_ITEM_LIMIT),
|
||||
}))
|
||||
.slice(-ORCHESTRATION_EVENT_GROUP_LIMIT)
|
||||
} catch {
|
||||
window.localStorage.removeItem(ORCHESTRATION_EVENT_STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
function persistOrchestrationEvents() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(
|
||||
ORCHESTRATION_EVENT_STORAGE_KEY,
|
||||
JSON.stringify(orchestrationEventFeed.value.slice(-ORCHESTRATION_EVENT_GROUP_LIMIT)),
|
||||
)
|
||||
}
|
||||
|
||||
function currentEventTime() {
|
||||
return new Date().toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function startOrchestrationEventGroup() {
|
||||
const nextGroup: OrchestrationEventGroup = {
|
||||
id: `group-${Date.now()}`,
|
||||
startedAt: currentEventTime(),
|
||||
status: 'active',
|
||||
items: [],
|
||||
}
|
||||
orchestrationEventFeed.value = [
|
||||
...orchestrationEventFeed.value,
|
||||
nextGroup,
|
||||
].slice(-ORCHESTRATION_EVENT_GROUP_LIMIT)
|
||||
persistOrchestrationEvents()
|
||||
}
|
||||
|
||||
function resetOrchestrationState() {
|
||||
orchestrationPanelVisible.value = true
|
||||
orchestrationStatus.value = 'idle'
|
||||
@@ -115,7 +226,6 @@ export function useChatView() {
|
||||
}
|
||||
activeAgent.value = null
|
||||
visitedAgents.value = []
|
||||
orchestrationEventFeed.value = []
|
||||
sessionTelemetry.value = {
|
||||
activitySeries: [],
|
||||
eventsCount: 0,
|
||||
@@ -139,7 +249,7 @@ export function useChatView() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateSystemTelemetry(metric: keyof SystemTelemetryState, value: number | null, online: boolean) {
|
||||
function updateSystemTelemetry(metric: 'cpu' | 'gpu' | 'memory' | 'disk', value: number | null, online: boolean) {
|
||||
const current = systemTelemetry.value[metric]
|
||||
systemTelemetry.value = {
|
||||
...systemTelemetry.value,
|
||||
@@ -151,17 +261,51 @@ export function useChatView() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateNetworkTelemetry(direction: 'upload' | 'download', value: number | null, online: boolean) {
|
||||
const current = systemTelemetry.value.network[direction]
|
||||
systemTelemetry.value = {
|
||||
...systemTelemetry.value,
|
||||
network: {
|
||||
...systemTelemetry.value.network,
|
||||
[direction]: {
|
||||
current: value,
|
||||
online,
|
||||
series: value === null ? current.series : appendTelemetryPoint(current.series, value),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSystemStatus() {
|
||||
try {
|
||||
const response = await systemApi.getStatus()
|
||||
systemMeta.value = {
|
||||
systemName: response.data.system_name,
|
||||
systemVersion: response.data.system_version,
|
||||
hostname: response.data.hostname,
|
||||
timestamp: response.data.timestamp,
|
||||
uptimeSeconds: response.data.uptime_seconds,
|
||||
diskUsedGb: response.data.disk_used_gb,
|
||||
diskTotalGb: response.data.disk_total_gb,
|
||||
gpuName: response.data.gpu_name,
|
||||
gpuMemoryTotalMb: response.data.gpu_memory_total_mb,
|
||||
gpuMemoryUsedMb: response.data.gpu_memory_used_mb,
|
||||
gpuUtilPercent: response.data.gpu_util_percent,
|
||||
}
|
||||
updateSystemTelemetry('cpu', response.data.cpu_percent, true)
|
||||
updateSystemTelemetry('gpu', response.data.gpu_util_percent, response.data.gpu_util_percent !== null)
|
||||
updateSystemTelemetry('memory', response.data.memory_percent, true)
|
||||
updateSystemTelemetry('disk', response.data.disk_percent, true)
|
||||
updateNetworkTelemetry('upload', response.data.network_upload_bps, true)
|
||||
updateNetworkTelemetry('download', response.data.network_download_bps, true)
|
||||
} catch (error) {
|
||||
console.error('加载系统状态失败:', error)
|
||||
updateSystemTelemetry('cpu', systemTelemetry.value.cpu.current, false)
|
||||
updateSystemTelemetry('gpu', systemTelemetry.value.gpu.current, false)
|
||||
updateSystemTelemetry('memory', systemTelemetry.value.memory.current, false)
|
||||
updateSystemTelemetry('disk', systemTelemetry.value.disk.current, false)
|
||||
updateNetworkTelemetry('upload', systemTelemetry.value.network.upload.current, false)
|
||||
updateNetworkTelemetry('download', systemTelemetry.value.network.download.current, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,15 +331,27 @@ export function useChatView() {
|
||||
function pushOrchestrationEvent(label: string, kind: OrchestrationEventItem['kind']) {
|
||||
const normalized = label.trim()
|
||||
if (!normalized) return
|
||||
if (orchestrationEventFeed.value.at(-1)?.label === normalized) return
|
||||
orchestrationEventFeed.value = [
|
||||
...orchestrationEventFeed.value,
|
||||
{
|
||||
id: `${Date.now()}-${orchestrationEventFeed.value.length}`,
|
||||
label: normalized,
|
||||
kind,
|
||||
},
|
||||
].slice(-5)
|
||||
if (orchestrationEventFeed.value.length === 0) {
|
||||
startOrchestrationEventGroup()
|
||||
}
|
||||
const currentGroup = orchestrationEventFeed.value.at(-1)
|
||||
if (!currentGroup) return
|
||||
if (currentGroup.items.at(-1)?.label === normalized) return
|
||||
const nextItem: OrchestrationEventItem = {
|
||||
id: `${Date.now()}-${currentGroup.items.length}`,
|
||||
time: currentEventTime(),
|
||||
label: normalized,
|
||||
kind,
|
||||
}
|
||||
orchestrationEventFeed.value = orchestrationEventFeed.value.map((group, index) => (
|
||||
index === orchestrationEventFeed.value.length - 1
|
||||
? {
|
||||
...group,
|
||||
items: [...group.items, nextItem].slice(-ORCHESTRATION_EVENT_ITEM_LIMIT),
|
||||
}
|
||||
: group
|
||||
))
|
||||
persistOrchestrationEvents()
|
||||
}
|
||||
|
||||
function buildOrchestrationInsight(payload: ChatProgressEvent): OrchestrationInsight {
|
||||
@@ -212,7 +368,7 @@ export function useChatView() {
|
||||
if (payload.stage === 'planning') {
|
||||
return {
|
||||
statusTitle: 'ROUTING',
|
||||
systemSummary: payload.agent === 'planner' ? '已路由至 planner,正在拆解任务' : '正在规划执行链路',
|
||||
systemSummary: payload.agent === 'schedule_planner' ? '已路由至 schedule_planner,正在编排日程' : '正在规划执行链路',
|
||||
jarvisNote: payload.steps?.length
|
||||
? '问题有几层关系,按顺序拆开会体面很多。'
|
||||
: '这一步需要一点秩序感。',
|
||||
@@ -220,12 +376,17 @@ export function useChatView() {
|
||||
}
|
||||
|
||||
if (payload.stage === 'tool') {
|
||||
const isScheduleAction = payload.tool_name
|
||||
? ['create_reminder', 'create_goal', 'create_todo', 'create_schedule_task', 'create_task'].includes(payload.tool_name)
|
||||
: false
|
||||
return {
|
||||
statusTitle: 'EXECUTION',
|
||||
statusTitle: isScheduleAction ? 'FULFILLMENT' : 'EXECUTION',
|
||||
systemSummary: payload.tool_name ? `正在调用工具 · ${payload.tool_name}` : '正在执行操作',
|
||||
jarvisNote: payload.tool_name
|
||||
? '工具链已接通。希望它今天愿意配合。'
|
||||
: '执行阶段开始了,接下来看看链路表现。',
|
||||
jarvisNote: isScheduleAction
|
||||
? '这次不是只给建议,记录会真的落到系统里。'
|
||||
: payload.tool_name
|
||||
? '工具链已接通。希望它今天愿意配合。'
|
||||
: '执行阶段开始了,接下来看看链路表现。',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +435,12 @@ export function useChatView() {
|
||||
jarvisNote: '很好,问题已经收束。',
|
||||
}
|
||||
pushOrchestrationEvent(finalLabel, status === 'error' ? 'error' : 'success')
|
||||
orchestrationEventFeed.value = orchestrationEventFeed.value.map((group, index) => (
|
||||
index === orchestrationEventFeed.value.length - 1
|
||||
? { ...group, status: status === 'error' ? 'error' : 'success' }
|
||||
: group
|
||||
))
|
||||
persistOrchestrationEvents()
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
@@ -287,6 +454,7 @@ export function useChatView() {
|
||||
const tempMessageId = `temp-${Date.now()}`
|
||||
const previousConversationId = store.currentConversationId
|
||||
inputMessage.value = ''
|
||||
startOrchestrationEventGroup()
|
||||
|
||||
store.addMessage({
|
||||
id: tempMessageId,
|
||||
@@ -392,9 +560,7 @@ export function useChatView() {
|
||||
try {
|
||||
const response = await settingsApi.get()
|
||||
const chatModelsList = (response.data.llm_config?.chat || []).filter((model) => model.enabled)
|
||||
const vlmModels = (response.data.llm_config?.vlm || []).filter((model) => model.enabled)
|
||||
// 合并 chat 和 vlm 模型
|
||||
chatModels.value = [...chatModelsList, ...vlmModels]
|
||||
chatModels.value = chatModelsList
|
||||
if (!selectedModelName.value || !chatModels.value.some((model) => model.name === selectedModelName.value)) {
|
||||
selectedModelName.value = chatModels.value[0]?.name || ''
|
||||
}
|
||||
@@ -518,6 +684,7 @@ export function useChatView() {
|
||||
}
|
||||
|
||||
void loadSystemStatus()
|
||||
loadPersistedOrchestrationEvents()
|
||||
startSystemTelemetryPolling()
|
||||
startSessionTelemetryDecay()
|
||||
|
||||
@@ -556,6 +723,7 @@ export function useChatView() {
|
||||
activeAgent,
|
||||
visitedAgents,
|
||||
orchestrationEventFeed,
|
||||
systemMeta,
|
||||
systemTelemetry,
|
||||
sessionTelemetry,
|
||||
sendMessage,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,478 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { taskApi, type Task, type TaskStatus, type TaskPriority } from '@/api/task'
|
||||
import { Plus, CheckCircle, Circle, Clock, Trash2, Zap } from 'lucide-vue-next'
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const showCreateForm = ref(false)
|
||||
const newTaskTitle = ref('')
|
||||
const newTaskPriority = ref<TaskPriority>('medium')
|
||||
|
||||
const todoTasks = computed(() => tasks.value.filter((t) => t.status === 'todo'))
|
||||
const inProgressTasks = computed(() => tasks.value.filter((t) => t.status === 'in_progress'))
|
||||
const doneTasks = computed(() => tasks.value.filter((t) => t.status === 'done'))
|
||||
|
||||
const priorityConfig: Record<TaskPriority, { color: string; label: string; glow: string }> = {
|
||||
low: { color: '#4b5563', label: 'LOW', glow: 'rgba(75,85,99,0.3)' },
|
||||
medium: { color: '#60a5fa', label: 'MED', glow: 'rgba(96,165,250,0.3)' },
|
||||
high: { color: '#fbbf24', label: 'HIGH', glow: 'rgba(251,191,36,0.3)' },
|
||||
urgent: { color: '#f87171', label: 'CRIT', glow: 'rgba(248,113,113,0.3)' },
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const response = await taskApi.list()
|
||||
tasks.value = response.data
|
||||
} catch (e) { console.error('加载任务失败:', e) }
|
||||
}
|
||||
|
||||
async function createTask() {
|
||||
if (!newTaskTitle.value.trim()) return
|
||||
try {
|
||||
const response = await taskApi.create({ title: newTaskTitle.value.trim(), priority: newTaskPriority.value })
|
||||
tasks.value.unshift(response.data)
|
||||
newTaskTitle.value = ''
|
||||
showCreateForm.value = false
|
||||
} catch (e) { console.error('创建任务失败:', e) }
|
||||
}
|
||||
|
||||
async function updateStatus(task: Task, status: TaskStatus) {
|
||||
try {
|
||||
const response = await taskApi.update(task.id, { status })
|
||||
const index = tasks.value.findIndex((t) => t.id === task.id)
|
||||
if (index !== -1) tasks.value[index] = response.data
|
||||
} catch (e) { console.error('更新状态失败:', e) }
|
||||
}
|
||||
|
||||
async function deleteTask(id: string) {
|
||||
try {
|
||||
await taskApi.delete(id)
|
||||
tasks.value = tasks.value.filter((t) => t.id !== id)
|
||||
} catch (e) { console.error('删除失败:', e) }
|
||||
}
|
||||
|
||||
onMounted(() => { loadTasks() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kanban-view">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<div class="header-icon"><Zap :size="20" /></div>
|
||||
<div class="header-text">
|
||||
<h1>TASK BOARD</h1>
|
||||
<span class="header-sub">{{ tasks.length }} tasks · {{ doneTasks.length }} completed</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="add-btn" @click="showCreateForm = true">
|
||||
<Plus :size="14" />
|
||||
NEW TASK
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create form -->
|
||||
<div v-if="showCreateForm" class="create-panel">
|
||||
<div class="create-inner">
|
||||
<input
|
||||
v-model="newTaskTitle"
|
||||
placeholder="Describe the task..."
|
||||
@keyup.enter="createTask"
|
||||
autofocus
|
||||
/>
|
||||
<select v-model="newTaskPriority" class="priority-select">
|
||||
<option value="low">LOW</option>
|
||||
<option value="medium">MEDIUM</option>
|
||||
<option value="high">HIGH</option>
|
||||
<option value="urgent">CRITICAL</option>
|
||||
</select>
|
||||
<button class="confirm-btn" @click="createTask">CREATE</button>
|
||||
<button class="cancel-btn" @click="showCreateForm = false">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Board -->
|
||||
<div class="kanban-board">
|
||||
<!-- TODO -->
|
||||
<div class="kanban-col">
|
||||
<div class="col-header">
|
||||
<div class="col-title">
|
||||
<Circle :size="14" />
|
||||
<span>PENDING</span>
|
||||
<div class="col-count">{{ todoTasks.length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-line" style="--col-color: #60a5fa"></div>
|
||||
<div class="col-cards">
|
||||
<div
|
||||
v-for="task in todoTasks"
|
||||
:key="task.id"
|
||||
class="task-card"
|
||||
@click="updateStatus(task, 'in_progress')"
|
||||
>
|
||||
<div class="task-priority-bar" :style="{ background: priorityConfig[task.priority].color, boxShadow: '0 0 6px ' + priorityConfig[task.priority].glow }"></div>
|
||||
<div class="task-body">
|
||||
<div class="task-meta">
|
||||
<span class="task-priority-tag" :style="{ color: priorityConfig[task.priority].color }">
|
||||
{{ priorityConfig[task.priority].label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="task-title">{{ task.title }}</div>
|
||||
</div>
|
||||
<button class="task-delete" @click.stop="deleteTask(task.id)">
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="todoTasks.length === 0" class="col-empty">No pending tasks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IN PROGRESS -->
|
||||
<div class="kanban-col active-col">
|
||||
<div class="col-header">
|
||||
<div class="col-title">
|
||||
<Clock :size="14" />
|
||||
<span>IN PROGRESS</span>
|
||||
<div class="col-count active">{{ inProgressTasks.length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-line" style="--col-color: #fbbf24"></div>
|
||||
<div class="col-cards">
|
||||
<div
|
||||
v-for="task in inProgressTasks"
|
||||
:key="task.id"
|
||||
class="task-card"
|
||||
@click="updateStatus(task, 'done')"
|
||||
>
|
||||
<div class="task-priority-bar" :style="{ background: priorityConfig[task.priority].color, boxShadow: '0 0 6px ' + priorityConfig[task.priority].glow }"></div>
|
||||
<div class="task-body">
|
||||
<div class="task-meta">
|
||||
<span class="task-priority-tag" :style="{ color: priorityConfig[task.priority].color }">
|
||||
{{ priorityConfig[task.priority].label }}
|
||||
</span>
|
||||
<span class="active-dot"></span>
|
||||
</div>
|
||||
<div class="task-title">{{ task.title }}</div>
|
||||
</div>
|
||||
<button class="task-delete" @click.stop="deleteTask(task.id)">
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="inProgressTasks.length === 0" class="col-empty">No active tasks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DONE -->
|
||||
<div class="kanban-col">
|
||||
<div class="col-header">
|
||||
<div class="col-title">
|
||||
<CheckCircle :size="14" />
|
||||
<span>COMPLETED</span>
|
||||
<div class="col-count">{{ doneTasks.length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-line" style="--col-color: #34d399"></div>
|
||||
<div class="col-cards">
|
||||
<div
|
||||
v-for="task in doneTasks"
|
||||
:key="task.id"
|
||||
class="task-card done"
|
||||
@click="updateStatus(task, 'todo')"
|
||||
>
|
||||
<div class="task-priority-bar" :style="{ background: priorityConfig[task.priority].color, boxShadow: '0 0 6px ' + priorityConfig[task.priority].glow, opacity: 0.4 }"></div>
|
||||
<div class="task-body">
|
||||
<div class="task-meta">
|
||||
<span class="task-priority-tag" style="color: var(--text-dim)">
|
||||
{{ priorityConfig[task.priority].label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="task-title done-title">{{ task.title }}</div>
|
||||
</div>
|
||||
<button class="task-delete" @click.stop="deleteTask(task.id)">
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="doneTasks.length === 0" class="col-empty">No completed tasks</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kanban-view {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-left { display: flex; align-items: center; gap: 14px; }
|
||||
|
||||
.header-icon { color: var(--accent-amber); filter: drop-shadow(0 0 8px var(--accent-amber)); }
|
||||
|
||||
h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-sub { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); letter-spacing: 0.1em; }
|
||||
|
||||
.add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: var(--accent-amber-dim);
|
||||
border: 1px solid rgba(249, 168, 37, 0.25);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--accent-amber);
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: rgba(249, 168, 37, 0.2);
|
||||
box-shadow: var(--glow-amber);
|
||||
}
|
||||
|
||||
/* Create panel */
|
||||
.create-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
animation: fade-in-up 0.2s ease;
|
||||
}
|
||||
|
||||
.create-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.create-inner input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.create-inner input:focus {
|
||||
border-color: var(--accent-amber);
|
||||
box-shadow: var(--glow-amber);
|
||||
}
|
||||
|
||||
.priority-select {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
padding: 10px 20px;
|
||||
background: var(--accent-amber-dim);
|
||||
border: 1px solid rgba(249, 168, 37, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--accent-amber);
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.confirm-btn:hover { background: rgba(249, 168, 37, 0.2); box-shadow: var(--glow-amber); }
|
||||
|
||||
.cancel-btn {
|
||||
padding: 10px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.cancel-btn:hover { border-color: var(--accent-red); color: var(--accent-red); }
|
||||
|
||||
/* Board */
|
||||
.kanban-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.kanban-col {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.kanban-col.active-col {
|
||||
border-color: rgba(251, 191, 36, 0.2);
|
||||
background: rgba(251, 191, 36, 0.02);
|
||||
}
|
||||
|
||||
.col-header { }
|
||||
|
||||
.col-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.col-count {
|
||||
margin-left: auto;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: 10px;
|
||||
padding: 1px 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.col-count.active {
|
||||
background: var(--accent-amber-dim);
|
||||
border-color: rgba(249, 168, 37, 0.3);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.col-line {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--col-color, var(--accent-cyan)), transparent);
|
||||
margin-bottom: 4px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.col-cards {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.col-empty {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
border-color: var(--border-mid);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.task-card.done { opacity: 0.55; }
|
||||
|
||||
.task-priority-bar {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-body { flex: 1; min-width: 0; }
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.task-priority-tag {
|
||||
font-family: var(--font-display);
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.active-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-amber);
|
||||
box-shadow: 0 0 6px var(--accent-amber);
|
||||
animation: pulse-glow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.done-title {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.task-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.task-card:hover .task-delete { opacity: 1; }
|
||||
.task-delete:hover { color: var(--accent-red); background: rgba(255,71,87,0.1); }
|
||||
</style>
|
||||
679
frontend/src/pages/knowledge/KnowledgeView.css
Normal file
679
frontend/src/pages/knowledge/KnowledgeView.css
Normal file
@@ -0,0 +1,679 @@
|
||||
.knowledge-view {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
color: var(--accent-cyan);
|
||||
filter: drop-shadow(0 0 8px var(--accent-cyan));
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--text-primary);
|
||||
text-shadow: var(--glow-cyan);
|
||||
}
|
||||
|
||||
.header-sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.staged-shell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: rgba(7, 12, 22, 0.4);
|
||||
border: 1px solid rgba(0, 245, 212, 0.1);
|
||||
border-radius: var(--radius-xl);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(180deg, rgba(0, 245, 212, 0.08), transparent);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 245, 212, 0.1);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--accent-cyan);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nav-btn:not(:disabled):hover {
|
||||
background: rgba(0, 245, 212, 0.2);
|
||||
box-shadow: 0 0 10px rgba(0, 245, 212, 0.3);
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(0, 245, 212, 0.05);
|
||||
}
|
||||
|
||||
.breadcrumb-item.active {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.location-tag {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.15em;
|
||||
padding: 4px 12px;
|
||||
background: rgba(0, 245, 212, 0.1);
|
||||
border: 1px solid rgba(0, 245, 212, 0.2);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.stage-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* LEFT PANEL - FOLDER LIST */
|
||||
.folder-column {
|
||||
width: 320px;
|
||||
height: 100%;
|
||||
border-right: 1px solid rgba(0, 245, 212, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(5, 10, 18, 0.4);
|
||||
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow-y: auto;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.folder-column::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.folder-column::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 245, 212, 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid rgba(0, 245, 212, 0.05);
|
||||
}
|
||||
|
||||
.panel-kicker {
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.2em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.folder-list {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(10, 20, 30, 0.4);
|
||||
border: 1px solid rgba(0, 245, 212, 0.1);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.folder-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--accent-cyan);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.folder-item:hover {
|
||||
background: rgba(0, 245, 212, 0.08);
|
||||
border-color: rgba(0, 245, 212, 0.3);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.folder-item.active {
|
||||
background: linear-gradient(90deg, rgba(0, 245, 212, 0.12), transparent);
|
||||
border-color: rgba(0, 245, 212, 0.4);
|
||||
box-shadow: 0 0 20px rgba(0, 245, 212, 0.1);
|
||||
}
|
||||
|
||||
.folder-item.active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(0, 245, 212, 0.1);
|
||||
border: 1px solid rgba(0, 245, 212, 0.2);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-cyan);
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.folder-item.active .item-icon {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-dark);
|
||||
box-shadow: 0 0 15px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.item-arrow {
|
||||
color: var(--accent-cyan);
|
||||
opacity: 0.5;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.folder-item.active .item-arrow {
|
||||
transform: rotate(90deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* RIGHT PANEL - CONTENT SLIDE OUT */
|
||||
.content-view {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(4, 8, 16, 0.6);
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.content-enter-active,
|
||||
.content-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.content-enter-from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.content-leave-to {
|
||||
transform: translateX(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
padding: 24px;
|
||||
background: linear-gradient(180deg, rgba(0, 245, 212, 0.05), transparent);
|
||||
border-bottom: 1px solid rgba(0, 245, 212, 0.05);
|
||||
}
|
||||
|
||||
.content-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.content-title-group h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-label span:first-child {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(0, 245, 212, 0.4), transparent);
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: rgba(10, 20, 30, 0.6);
|
||||
border: 1px solid rgba(0, 245, 212, 0.1);
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-card:hover {
|
||||
background: rgba(0, 245, 212, 0.1);
|
||||
border-color: rgba(0, 245, 212, 0.4);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 15, 25, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid currentColor;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* HUD OVERLAY */
|
||||
.hud-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
background: rgba(0, 5, 10, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.hud-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
max-height: 90vh;
|
||||
background: linear-gradient(135deg, rgba(10, 25, 40, 0.95), rgba(5, 10, 20, 0.98));
|
||||
border: 1px solid rgba(0, 245, 212, 0.3);
|
||||
border-radius: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 100px rgba(0, 245, 212, 0.15), 0 40px 100px rgba(0, 0, 0, 0.8);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hud-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 32px;
|
||||
background: radial-gradient(circle at top right, rgba(0, 245, 212, 0.05), transparent 40%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hud-header {
|
||||
padding: 24px 32px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(0, 245, 212, 0.1);
|
||||
}
|
||||
|
||||
.hud-title-group h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 24px;
|
||||
color: var(--text-primary);
|
||||
text-shadow: var(--glow-cyan);
|
||||
}
|
||||
|
||||
.hud-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.hud-body {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 1px;
|
||||
background: rgba(0, 245, 212, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hud-preview-panel,
|
||||
.hud-chunks-panel {
|
||||
background: rgba(5, 10, 20, 0.8);
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
display: block;
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chunk-card {
|
||||
background: rgba(15, 25, 35, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.chunk-card:hover {
|
||||
border-color: rgba(0, 245, 212, 0.3);
|
||||
background: rgba(0, 245, 212, 0.05);
|
||||
}
|
||||
|
||||
.chunk-index {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chunk-content {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.close-hud {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.close-hud:hover {
|
||||
background: rgba(255, 71, 87, 0.2);
|
||||
border-color: rgba(255, 71, 87, 0.4);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
/* UTILS */
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 12px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent-cyan);
|
||||
border: none;
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
box-shadow: 0 0 20px rgba(0, 245, 212, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn.ghost:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.hud-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hud-chunks-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.folder-column {
|
||||
width: 80px;
|
||||
}
|
||||
.item-content, .item-arrow, .panel-header h2, .panel-kicker {
|
||||
display: none;
|
||||
}
|
||||
.folder-list {
|
||||
align-items: center;
|
||||
}
|
||||
.folder-item {
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
118
frontend/src/pages/knowledge/components/ContentViewer.vue
Normal file
118
frontend/src/pages/knowledge/components/ContentViewer.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { FolderOpen, Folder, FileText, ChevronRight, FileSearch, Database } from 'lucide-vue-next'
|
||||
import type { FolderTree } from '@/api/folder'
|
||||
import type { Document } from '@/api/document'
|
||||
|
||||
interface Props {
|
||||
isRoot: boolean
|
||||
currentFolder: FolderTree | null
|
||||
currentFolderId: string | null
|
||||
visibleFolders: FolderTree[]
|
||||
documents: Document[]
|
||||
highlightedDocumentId: string | null
|
||||
uploadError: string
|
||||
uploadSuccess: string
|
||||
getFileTypeColor: (type: string) => string
|
||||
formatFileSize: (size: number) => string
|
||||
getStatusLabel: (status?: string, isIndexed?: boolean) => string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['enterFolder', 'openDocument', 'triggerUpload', 'openRename', 'openDelete'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="content">
|
||||
<main v-if="!props.isRoot" class="content-view" :key="props.currentFolderId">
|
||||
<header class="content-header">
|
||||
<div class="content-title-group">
|
||||
<h2>
|
||||
<FolderOpen :size="24" />
|
||||
<span>{{ props.currentFolder?.name }}</span>
|
||||
</h2>
|
||||
<div class="mini-actions">
|
||||
<button class="btn ghost small" @click="emit('openRename', props.currentFolder)">RENAME</button>
|
||||
<button class="btn ghost small danger" @click="emit('openDelete', props.currentFolder)">DELETE</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-grid">
|
||||
<!-- Feedback Messages -->
|
||||
<div v-if="props.uploadError" class="upload-error">{{ props.uploadError }}</div>
|
||||
<div v-if="props.uploadSuccess" class="upload-success">{{ props.uploadSuccess }}</div>
|
||||
|
||||
<!-- Subfolders Section -->
|
||||
<section v-if="props.visibleFolders.length" class="content-section">
|
||||
<div class="section-label">
|
||||
<span>SUB-DIRECTORIES</span>
|
||||
<div class="section-line"></div>
|
||||
</div>
|
||||
<div class="file-list">
|
||||
<div
|
||||
v-for="folder in props.visibleFolders"
|
||||
:key="folder.id"
|
||||
class="file-card folder-card"
|
||||
@click="emit('enterFolder', folder)"
|
||||
>
|
||||
<div class="file-icon"><Folder :size="20" /></div>
|
||||
<div class="file-info">
|
||||
<span class="file-name">{{ folder.name }}</span>
|
||||
<span class="file-details">{{ folder.children?.length ?? 0 }} ITEMS</span>
|
||||
</div>
|
||||
<ChevronRight :size="16" class="item-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Files Section -->
|
||||
<section v-if="props.documents.length" class="content-section">
|
||||
<div class="section-label">
|
||||
<span>DATA OBJECTS</span>
|
||||
<div class="section-line"></div>
|
||||
</div>
|
||||
<div class="file-list">
|
||||
<div
|
||||
v-for="doc in props.documents"
|
||||
:key="doc.id"
|
||||
class="file-card"
|
||||
:class="{ highlighted: props.highlightedDocumentId === doc.id }"
|
||||
@click="emit('openDocument', doc)"
|
||||
>
|
||||
<div class="file-icon" :style="{ color: props.getFileTypeColor(doc.file_type) }">
|
||||
<FileText :size="20" />
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<span class="file-name">{{ doc.title }}</span>
|
||||
<div class="file-details">
|
||||
<span>{{ doc.file_type.toUpperCase() }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ props.formatFileSize(doc.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-pill" :class="(doc.ingestion_status ?? (doc.is_indexed ? 'ready' : 'uploaded')).toLowerCase()">
|
||||
{{ props.getStatusLabel(doc.ingestion_status, doc.is_indexed) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="!props.visibleFolders.length && !props.documents.length" class="empty-state">
|
||||
<FileSearch :size="48" class="empty-icon" />
|
||||
<span>FOLDER IS EMPTY</span>
|
||||
<button class="btn primary" @click="emit('triggerUpload')">UPLOAD FIRST FILE</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main v-else class="content-view">
|
||||
<div class="empty-state">
|
||||
<div class="pulsing-icon">
|
||||
<Database :size="64" class="empty-icon" />
|
||||
</div>
|
||||
<h3>AWAITING SELECTION</h3>
|
||||
<p>SELECT A DIRECTORY NODE TO EXPAND SYSTEM LAYER</p>
|
||||
</div>
|
||||
</main>
|
||||
</Transition>
|
||||
</template>
|
||||
68
frontend/src/pages/knowledge/components/DocumentHUD.vue
Normal file
68
frontend/src/pages/knowledge/components/DocumentHUD.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { X, Loader } from 'lucide-vue-next'
|
||||
import type { Document, DocumentChunk } from '@/api/document'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
activeDocument: Document | null
|
||||
activeDocumentContent: string
|
||||
isLoadingDocumentContent: boolean
|
||||
activeDocumentChunks: DocumentChunk[]
|
||||
isLoadingDocumentChunks: boolean
|
||||
getFileTypeColor: (type: string) => string
|
||||
formatFileSize: (size: number) => string
|
||||
getStatusLabel: (status?: string, isIndexed?: boolean) => string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div v-if="props.show && props.activeDocument" class="hud-overlay" @click.self="emit('close')">
|
||||
<div class="hud-container">
|
||||
<header class="hud-header">
|
||||
<div class="hud-title-group">
|
||||
<span class="panel-kicker">PHASE 03 // DATA PREVIEW</span>
|
||||
<h3>{{ props.activeDocument.title }}</h3>
|
||||
<div class="hud-meta-row">
|
||||
<span class="file-badge" :style="{ color: props.getFileTypeColor(props.activeDocument.file_type), borderColor: props.getFileTypeColor(props.activeDocument.file_type) }">
|
||||
{{ props.activeDocument.file_type.toUpperCase() }}
|
||||
</span>
|
||||
<span>SIZE: {{ props.formatFileSize(props.activeDocument.file_size) }}</span>
|
||||
<span>CHUNKS: {{ props.activeDocument.chunk_count }}</span>
|
||||
<span class="status-pill">{{ props.getStatusLabel(props.activeDocument.ingestion_status, props.activeDocument.is_indexed) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="close-hud" @click="emit('close')"><X :size="20" /></button>
|
||||
</header>
|
||||
|
||||
<div class="hud-body">
|
||||
<div class="hud-preview-panel">
|
||||
<span class="panel-label">RAW CONTENT</span>
|
||||
<div v-if="props.isLoadingDocumentContent" class="preview-loading">
|
||||
<Loader :size="24" class="spin" />
|
||||
<span>DECRYPTING DATA...</span>
|
||||
</div>
|
||||
<pre v-else class="preview-content">{{ props.activeDocumentContent || 'NO CONTENT DETECTED' }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="hud-chunks-panel">
|
||||
<span class="panel-label">KNOWLEDGE CHUNKS</span>
|
||||
<div v-if="props.isLoadingDocumentChunks" class="preview-loading">
|
||||
<Loader :size="20" class="spin" />
|
||||
<span>CHUNKING DATA...</span>
|
||||
</div>
|
||||
<div v-else class="chunk-list">
|
||||
<div v-for="chunk in props.activeDocumentChunks" :key="chunk.id" class="chunk-card">
|
||||
<span class="chunk-index">CHUNK #{{ chunk.chunk_index + 1 }}</span>
|
||||
<p class="chunk-content">{{ chunk.content.substring(0, 200) }}...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
47
frontend/src/pages/knowledge/components/FolderLauncher.vue
Normal file
47
frontend/src/pages/knowledge/components/FolderLauncher.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { Folder, FolderOpen, ChevronRight, HardDrive } from 'lucide-vue-next'
|
||||
import type { FolderTree } from '@/api/folder'
|
||||
|
||||
interface Props {
|
||||
folders: FolderTree[]
|
||||
currentFolderId: string | null
|
||||
breadcrumbs: Array<{ id: string | null; name: string }>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['select'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="folder-column">
|
||||
<div class="panel-header">
|
||||
<span class="panel-kicker">PHASE 01</span>
|
||||
<h2>DIRECTORIES</h2>
|
||||
</div>
|
||||
|
||||
<div class="folder-list">
|
||||
<button
|
||||
v-for="folder in props.folders"
|
||||
:key="folder.id"
|
||||
class="folder-item"
|
||||
:class="{ active: props.currentFolderId === folder.id || props.breadcrumbs.some(b => b.id === folder.id) }"
|
||||
@click="emit('select', folder)"
|
||||
>
|
||||
<div class="item-icon">
|
||||
<FolderOpen v-if="props.currentFolderId === folder.id" :size="20" />
|
||||
<Folder v-else :size="20" />
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<span class="item-name">{{ folder.name }}</span>
|
||||
<span class="item-meta">{{ folder.children?.length ?? 0 }} SUBFOLDERS</span>
|
||||
</div>
|
||||
<ChevronRight :size="16" class="item-arrow" />
|
||||
</button>
|
||||
|
||||
<div v-if="!props.folders.length" class="empty-state">
|
||||
<HardDrive :size="32" class="empty-icon" />
|
||||
<span>NO DATA NODES</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,536 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { animate } from 'motion'
|
||||
|
||||
import { goalApi, type Goal } from '@/api/goal'
|
||||
import { reminderApi, type Reminder } from '@/api/reminder'
|
||||
import { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter'
|
||||
import { taskApi, type Task, type TaskPriority, type TaskStatus } from '@/api/task'
|
||||
import { todoApi, type Todo } from '@/api/todo'
|
||||
|
||||
type TimelineItem =
|
||||
| { id: string; type: 'todo'; tone: 'normal' | 'warn' | 'alert'; time: string; label: string; title: string; meta: string; done: boolean; payload: Todo }
|
||||
| { id: string; type: 'task'; tone: 'normal' | 'warn' | 'alert'; time: string; label: string; title: string; meta: string; done: boolean; payload: Task }
|
||||
| { id: string; type: 'reminder'; tone: 'normal' | 'warn' | 'alert'; time: string; label: string; title: string; meta: string; done: boolean; payload: Reminder }
|
||||
| { id: string; type: 'goal'; tone: 'normal' | 'warn' | 'alert'; time: string; label: string; title: string; meta: string; done: boolean; payload: Goal }
|
||||
|
||||
export function useScheduleCenterPage() {
|
||||
const weekdayLabels = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']
|
||||
const today = new Date()
|
||||
const currentMonth = ref(new Date(today.getFullYear(), today.getMonth(), 1))
|
||||
const selectedDate = ref(formatDate(today))
|
||||
const panelOpen = ref(false)
|
||||
const monthSummary = ref<ScheduleCenterDaySummary[]>([])
|
||||
const detail = ref<ScheduleCenterDateResponse | null>(null)
|
||||
const loadingMonth = ref(false)
|
||||
const loadingDetail = ref(false)
|
||||
const monthRequestId = ref(0)
|
||||
const detailRequestId = ref(0)
|
||||
|
||||
const newTodoTitle = ref('')
|
||||
const newTaskTitle = ref('')
|
||||
const newTaskPriority = ref<TaskPriority>('medium')
|
||||
const newReminderTitle = ref('')
|
||||
const newReminderTime = ref('09:00')
|
||||
const newGoalTitle = ref('')
|
||||
const panelMode = ref<'detail' | 'create'>('detail')
|
||||
const panelAnimation = ref<ReturnType<typeof animate> | null>(null)
|
||||
const panelFrameRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const priorityConfig: Record<TaskPriority, { color: string; label: string; glow: string }> = {
|
||||
low: { color: '#4b5563', label: 'LOW', glow: 'rgba(75,85,99,0.28)' },
|
||||
medium: { color: '#00f5d4', label: 'MED', glow: 'rgba(0,245,212,0.28)' },
|
||||
high: { color: '#f9a825', label: 'HIGH', glow: 'rgba(249,168,37,0.28)' },
|
||||
urgent: { color: '#ff4757', label: 'CRIT', glow: 'rgba(255,71,87,0.32)' },
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function getMonthKey(date: Date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatTimelineDate(dateKey: string) {
|
||||
return dateKey.replace(/-/g, '.')
|
||||
}
|
||||
|
||||
function getDayRisk(summary: ScheduleCenterDaySummary | null | undefined) {
|
||||
if (!summary) return 'LOW'
|
||||
|
||||
const pressure = summary.todo_total + summary.task_due_total * 1.5 + summary.high_priority_total * 2 + summary.reminder_total
|
||||
if (pressure >= 9) return 'HIGH'
|
||||
if (pressure >= 4) return 'MED'
|
||||
return 'LOW'
|
||||
}
|
||||
|
||||
function getDayState(summary: ScheduleCenterDaySummary | null | undefined) {
|
||||
if (!summary) return 'OPEN'
|
||||
if (summary.high_priority_total >= 2) return 'LOCKED'
|
||||
if (summary.task_due_total >= 4) return 'DENSE'
|
||||
if (summary.reminder_total >= 2) return 'TRACKED'
|
||||
if (summary.todo_total + summary.task_due_total + summary.goal_total > 0) return 'QUEUED'
|
||||
return 'CLEAR'
|
||||
}
|
||||
|
||||
function getDayBars(summary: ScheduleCenterDaySummary | null | undefined) {
|
||||
if (!summary) return []
|
||||
|
||||
return [
|
||||
{ key: 'QUEUE', count: summary.todo_total, value: `${Math.min(100, summary.todo_total * 14)}%` },
|
||||
{ key: 'TASK', count: summary.task_due_total, value: `${Math.min(100, summary.task_due_total * 16)}%` },
|
||||
{ key: 'FOCUS', count: summary.high_priority_total, value: `${Math.min(100, summary.high_priority_total * 28)}%` },
|
||||
].filter((item) => item.count > 0)
|
||||
}
|
||||
|
||||
function getVisibleMetrics(summary: ScheduleCenterDaySummary | null | undefined) {
|
||||
if (!summary) return []
|
||||
|
||||
return [
|
||||
summary.todo_total > 0 ? `待办 ${summary.todo_total}` : null,
|
||||
summary.task_due_total > 0 ? `任务 ${summary.task_due_total}` : null,
|
||||
summary.reminder_total > 0 ? `提醒 ${summary.reminder_total}` : null,
|
||||
summary.goal_total > 0 ? `目标 ${summary.goal_total}` : null,
|
||||
].filter((item): item is string => Boolean(item))
|
||||
}
|
||||
|
||||
const summaryMap = computed(() => new Map(monthSummary.value.map((item) => [item.date, item])))
|
||||
|
||||
const monthOverview = computed(() =>
|
||||
monthSummary.value.reduce(
|
||||
(acc, item) => {
|
||||
acc.todo_total += item.todo_total
|
||||
acc.task_due_total += item.task_due_total
|
||||
acc.reminder_total += item.reminder_total
|
||||
acc.goal_total += item.goal_total
|
||||
acc.high_priority_total += item.high_priority_total
|
||||
return acc
|
||||
},
|
||||
{ todo_total: 0, task_due_total: 0, reminder_total: 0, goal_total: 0, high_priority_total: 0 },
|
||||
),
|
||||
)
|
||||
|
||||
const calendarDays = computed(() => {
|
||||
const year = currentMonth.value.getFullYear()
|
||||
const month = currentMonth.value.getMonth()
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
const firstDayOffset = (new Date(year, month, 1).getDay() + 6) % 7
|
||||
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const dayNumber = index - firstDayOffset + 1
|
||||
if (dayNumber < 1 || dayNumber > daysInMonth) {
|
||||
return {
|
||||
date: null,
|
||||
dateKey: `empty-${index}`,
|
||||
dayNumber: null,
|
||||
isCurrentMonth: false,
|
||||
isToday: false,
|
||||
isSelected: false,
|
||||
summary: null,
|
||||
isPlaceholder: true,
|
||||
}
|
||||
}
|
||||
|
||||
const date = new Date(year, month, dayNumber)
|
||||
const dateKey = formatDate(date)
|
||||
const summary = summaryMap.value.get(dateKey) ?? null
|
||||
|
||||
return {
|
||||
date,
|
||||
dateKey,
|
||||
dayNumber,
|
||||
isCurrentMonth: true,
|
||||
isToday: dateKey === formatDate(today),
|
||||
isSelected: dateKey === selectedDate.value,
|
||||
summary,
|
||||
risk: getDayRisk(summary),
|
||||
state: getDayState(summary),
|
||||
bars: getDayBars(summary),
|
||||
isPlaceholder: false,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const panelDateLabel = computed(() => formatTimelineDate(selectedDate.value))
|
||||
const panelSummary = computed(() => detail.value?.summary ?? null)
|
||||
const selectedTodos = computed(() => detail.value?.todos ?? [])
|
||||
const selectedTasks = computed(() => detail.value?.tasks ?? [])
|
||||
const selectedReminders = computed(() => detail.value?.reminders ?? [])
|
||||
const selectedGoals = computed(() => detail.value?.goals ?? [])
|
||||
const panelLockLabel = computed(() => (panelSummary.value?.high_priority_total ? 'Priority Lock' : 'Standard Flow'))
|
||||
|
||||
const panelRings = computed(() => {
|
||||
const summary = panelSummary.value
|
||||
if (!summary) {
|
||||
return [
|
||||
{ label: '负载', value: 0, tone: 'normal' },
|
||||
{ label: '冲突', value: 0, tone: 'warn' },
|
||||
{ label: '专注', value: 0, tone: 'alert' },
|
||||
]
|
||||
}
|
||||
|
||||
const load = Math.min(100, summary.todo_total * 10 + summary.task_due_total * 14 + summary.reminder_total * 8 + summary.goal_total * 6)
|
||||
const conflict = Math.min(100, summary.high_priority_total * 30 + summary.reminder_total * 12 + Math.max(summary.task_due_total - 2, 0) * 8)
|
||||
const focus = Math.min(100, summary.goal_total * 20 + summary.high_priority_total * 22 + summary.todo_completed * 12)
|
||||
|
||||
return [
|
||||
{ label: '负载', value: load, tone: 'normal' as const },
|
||||
{ label: '冲突', value: conflict, tone: 'warn' as const },
|
||||
{ label: '专注', value: focus, tone: 'alert' as const },
|
||||
]
|
||||
})
|
||||
|
||||
const timelineItems = computed<TimelineItem[]>(() => {
|
||||
const todos = selectedTodos.value.map<TimelineItem>((todo, index) => ({
|
||||
id: `todo-${todo.id}`,
|
||||
type: 'todo',
|
||||
tone: todo.is_completed ? 'normal' : 'warn',
|
||||
time: `${String(8 + index).padStart(2, '0')}:00`,
|
||||
label: 'Todo / Queue',
|
||||
title: todo.title,
|
||||
meta: todo.source ? `SOURCE: ${String(todo.source).toUpperCase()}` : 'SOURCE: MANUAL',
|
||||
done: todo.is_completed,
|
||||
payload: todo,
|
||||
}))
|
||||
|
||||
const tasks = selectedTasks.value.map<TimelineItem>((task, index) => ({
|
||||
id: `task-${task.id}`,
|
||||
type: 'task',
|
||||
tone: task.priority === 'urgent' || task.priority === 'high' ? 'alert' : 'normal',
|
||||
time: `${String(10 + index).padStart(2, '0')}:30`,
|
||||
label: 'Task / Active',
|
||||
title: task.title,
|
||||
meta: `PRIORITY: ${priorityConfig[task.priority].label} · STATUS: ${task.status.toUpperCase()}`,
|
||||
done: task.status === 'done',
|
||||
payload: task,
|
||||
}))
|
||||
|
||||
const reminders = selectedReminders.value.map<TimelineItem>((reminder) => ({
|
||||
id: `reminder-${reminder.id}`,
|
||||
type: 'reminder',
|
||||
tone: reminder.status === 'done' ? 'normal' : 'warn',
|
||||
time: reminder.reminder_at.slice(11, 16),
|
||||
label: 'Reminder / Watch',
|
||||
title: reminder.title,
|
||||
meta: `STATE: ${reminder.status.toUpperCase()}`,
|
||||
done: reminder.status === 'done',
|
||||
payload: reminder,
|
||||
}))
|
||||
|
||||
const goals = selectedGoals.value.map<TimelineItem>((goal, index) => ({
|
||||
id: `goal-${goal.id}`,
|
||||
type: 'goal',
|
||||
tone: goal.status === 'done' ? 'normal' : 'alert',
|
||||
time: `${String(18 + index).padStart(2, '0')}:00`,
|
||||
label: 'Goal / Open',
|
||||
title: goal.title,
|
||||
meta: `STATE: ${goal.status.toUpperCase()}`,
|
||||
done: goal.status === 'done',
|
||||
payload: goal,
|
||||
}))
|
||||
|
||||
return [...todos, ...tasks, ...reminders, ...goals].sort((a, b) => a.time.localeCompare(b.time))
|
||||
})
|
||||
|
||||
async function loadMonth() {
|
||||
const requestId = monthRequestId.value + 1
|
||||
monthRequestId.value = requestId
|
||||
loadingMonth.value = true
|
||||
|
||||
try {
|
||||
const response = await scheduleCenterApi.month(getMonthKey(currentMonth.value))
|
||||
if (requestId !== monthRequestId.value) return
|
||||
monthSummary.value = response.data.days
|
||||
} catch (error) {
|
||||
if (requestId !== monthRequestId.value) return
|
||||
console.error('加载月度调度数据失败', error)
|
||||
monthSummary.value = []
|
||||
} finally {
|
||||
if (requestId === monthRequestId.value) {
|
||||
loadingMonth.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDateDetail(dateKey = selectedDate.value) {
|
||||
const requestId = detailRequestId.value + 1
|
||||
detailRequestId.value = requestId
|
||||
loadingDetail.value = true
|
||||
detail.value = null
|
||||
|
||||
try {
|
||||
const response = await scheduleCenterApi.date(dateKey)
|
||||
if (requestId !== detailRequestId.value) return
|
||||
detail.value = response.data
|
||||
} catch (error) {
|
||||
if (requestId !== detailRequestId.value) return
|
||||
console.error('加载日期详情失败', error)
|
||||
detail.value = null
|
||||
} finally {
|
||||
if (requestId === detailRequestId.value) {
|
||||
loadingDetail.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openDate(dateKey: string) {
|
||||
selectedDate.value = dateKey
|
||||
panelOpen.value = true
|
||||
panelMode.value = 'detail'
|
||||
loadDateDetail(dateKey)
|
||||
}
|
||||
|
||||
function shiftMonth(offset: number) {
|
||||
currentMonth.value = new Date(currentMonth.value.getFullYear(), currentMonth.value.getMonth() + offset, 1)
|
||||
}
|
||||
|
||||
async function addTodo() {
|
||||
if (!newTodoTitle.value.trim()) return
|
||||
try {
|
||||
await todoApi.create({ title: newTodoTitle.value.trim(), todo_date: selectedDate.value })
|
||||
newTodoTitle.value = ''
|
||||
await Promise.all([loadDateDetail(), loadMonth()])
|
||||
} catch (error) {
|
||||
console.error('新增待办失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTodo(todo: Todo) {
|
||||
try {
|
||||
await todoApi.update(todo.id, { is_completed: !todo.is_completed })
|
||||
await Promise.all([loadDateDetail(), loadMonth()])
|
||||
} catch (error) {
|
||||
console.error('切换待办状态失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTodo(id: string) {
|
||||
try {
|
||||
await todoApi.delete(id)
|
||||
await Promise.all([loadDateDetail(), loadMonth()])
|
||||
} catch (error) {
|
||||
console.error('删除待办失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function addTask() {
|
||||
if (!newTaskTitle.value.trim()) return
|
||||
try {
|
||||
await taskApi.create({ title: newTaskTitle.value.trim(), priority: newTaskPriority.value, due_date: `${selectedDate.value}T09:00:00Z` })
|
||||
newTaskTitle.value = ''
|
||||
newTaskPriority.value = 'medium'
|
||||
await Promise.all([loadDateDetail(), loadMonth()])
|
||||
} catch (error) {
|
||||
console.error('新增任务失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function cycleTaskStatus(task: Task) {
|
||||
const nextStatus: TaskStatus = task.status === 'todo' ? 'in_progress' : task.status === 'in_progress' ? 'done' : 'todo'
|
||||
try {
|
||||
await taskApi.update(task.id, { status: nextStatus })
|
||||
await Promise.all([loadDateDetail(), loadMonth()])
|
||||
} catch (error) {
|
||||
console.error('切换任务状态失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTask(id: string) {
|
||||
try {
|
||||
await taskApi.delete(id)
|
||||
await Promise.all([loadDateDetail(), loadMonth()])
|
||||
} catch (error) {
|
||||
console.error('删除任务失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function addReminder() {
|
||||
if (!newReminderTitle.value.trim()) return
|
||||
try {
|
||||
await reminderApi.create({
|
||||
title: newReminderTitle.value.trim(),
|
||||
reminder_at: `${selectedDate.value}T${newReminderTime.value}:00Z`,
|
||||
})
|
||||
newReminderTitle.value = ''
|
||||
newReminderTime.value = '09:00'
|
||||
await Promise.all([loadDateDetail(), loadMonth()])
|
||||
} catch (error) {
|
||||
console.error('新增提醒失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleReminder(reminder: Reminder) {
|
||||
try {
|
||||
await reminderApi.update(reminder.id, { status: reminder.status === 'done' ? 'pending' : 'done' })
|
||||
await Promise.all([loadDateDetail(), loadMonth()])
|
||||
} catch (error) {
|
||||
console.error('切换提醒状态失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeReminder(id: string) {
|
||||
try {
|
||||
await reminderApi.delete(id)
|
||||
await Promise.all([loadDateDetail(), loadMonth()])
|
||||
} catch (error) {
|
||||
console.error('删除提醒失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function addGoal() {
|
||||
if (!newGoalTitle.value.trim()) return
|
||||
try {
|
||||
await goalApi.create({ title: newGoalTitle.value.trim(), goal_date: selectedDate.value })
|
||||
newGoalTitle.value = ''
|
||||
await Promise.all([loadDateDetail(), loadMonth()])
|
||||
} catch (error) {
|
||||
console.error('新增目标失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleGoal(goal: Goal) {
|
||||
try {
|
||||
await goalApi.update(goal.id, { status: goal.status === 'done' ? 'active' : 'done' })
|
||||
await Promise.all([loadDateDetail(), loadMonth()])
|
||||
} catch (error) {
|
||||
console.error('切换目标状态失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGoal(id: string) {
|
||||
try {
|
||||
await goalApi.delete(id)
|
||||
await Promise.all([loadDateDetail(), loadMonth()])
|
||||
} catch (error) {
|
||||
console.error('删除目标失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTimelineItem(item: TimelineItem) {
|
||||
if (item.type === 'todo') return toggleTodo(item.payload)
|
||||
if (item.type === 'task') return cycleTaskStatus(item.payload)
|
||||
if (item.type === 'reminder') return toggleReminder(item.payload)
|
||||
return toggleGoal(item.payload)
|
||||
}
|
||||
|
||||
async function removeTimelineItem(item: TimelineItem) {
|
||||
if (item.type === 'todo') return removeTodo(item.payload.id)
|
||||
if (item.type === 'task') return removeTask(item.payload.id)
|
||||
if (item.type === 'reminder') return removeReminder(item.payload.id)
|
||||
return removeGoal(item.payload.id)
|
||||
}
|
||||
|
||||
watch(currentMonth, loadMonth)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMonth()
|
||||
await loadDateDetail()
|
||||
panelOpen.value = true
|
||||
panelMode.value = 'detail'
|
||||
})
|
||||
|
||||
function animatePanelContent() {
|
||||
const panel = panelFrameRef.value
|
||||
if (!panel) return
|
||||
|
||||
const targets = panel.querySelectorAll<HTMLElement>('.panel-header, .panel-switcher, .ring-strip, .panel-loading, .timeline, .create-stack')
|
||||
targets.forEach((element, index) => {
|
||||
animate(
|
||||
element,
|
||||
{
|
||||
opacity: [0, 1],
|
||||
transform: ['translateY(14px)', 'translateY(0px)'] as const,
|
||||
} as never,
|
||||
{
|
||||
duration: 0.24,
|
||||
delay: 0.03 + index * 0.04,
|
||||
easing: [0.22, 1, 0.36, 1],
|
||||
} as never,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function animatePanelEnter(el: Element, done: () => void) {
|
||||
panelAnimation.value?.cancel()
|
||||
const animation = animate(
|
||||
el,
|
||||
{
|
||||
opacity: [0, 1],
|
||||
transform: ['translateX(36px) scale(0.985)', 'translateX(0px) scale(1)'] as const,
|
||||
} as never,
|
||||
{
|
||||
duration: 0.3,
|
||||
easing: [0.22, 1, 0.36, 1],
|
||||
} as never,
|
||||
)
|
||||
panelAnimation.value = animation
|
||||
animation.finished.catch(() => undefined).finally(() => {
|
||||
if (panelAnimation.value === animation) {
|
||||
panelAnimation.value = null
|
||||
requestAnimationFrame(() => animatePanelContent())
|
||||
}
|
||||
done()
|
||||
})
|
||||
}
|
||||
|
||||
function animatePanelLeave(el: Element, done: () => void) {
|
||||
panelAnimation.value?.cancel()
|
||||
const animation = animate(
|
||||
el,
|
||||
{
|
||||
opacity: [1, 0],
|
||||
transform: ['translateX(0px) scale(1)', 'translateX(28px) scale(0.99)'] as const,
|
||||
} as never,
|
||||
{
|
||||
duration: 0.2,
|
||||
easing: 'ease-in',
|
||||
} as never,
|
||||
)
|
||||
panelAnimation.value = animation
|
||||
animation.finished.catch(() => undefined).finally(() => {
|
||||
if (panelAnimation.value === animation) {
|
||||
panelAnimation.value = null
|
||||
}
|
||||
done()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
weekdayLabels,
|
||||
currentMonth,
|
||||
panelOpen,
|
||||
loadingMonth,
|
||||
loadingDetail,
|
||||
newTodoTitle,
|
||||
newTaskTitle,
|
||||
newTaskPriority,
|
||||
newReminderTitle,
|
||||
newReminderTime,
|
||||
newGoalTitle,
|
||||
panelMode,
|
||||
panelFrameRef,
|
||||
priorityConfig,
|
||||
calendarDays,
|
||||
panelDateLabel,
|
||||
panelSummary,
|
||||
panelLockLabel,
|
||||
panelRings,
|
||||
timelineItems,
|
||||
monthOverview,
|
||||
getMonthKey,
|
||||
getVisibleMetrics,
|
||||
openDate,
|
||||
shiftMonth,
|
||||
addTodo,
|
||||
addTask,
|
||||
addReminder,
|
||||
addGoal,
|
||||
toggleTimelineItem,
|
||||
removeTimelineItem,
|
||||
animatePanelEnter,
|
||||
animatePanelLeave,
|
||||
}
|
||||
}
|
||||
163
frontend/src/pages/schedule-center/index.test.ts
Normal file
163
frontend/src/pages/schedule-center/index.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi, beforeEach, type Mocked } from 'vitest'
|
||||
|
||||
vi.mock('@/api/scheduleCenter', () => ({
|
||||
scheduleCenterApi: {
|
||||
month: vi.fn(),
|
||||
date: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/todo', () => ({
|
||||
todoApi: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/task', () => ({
|
||||
taskApi: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/reminder', () => ({
|
||||
reminderApi: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/goal', () => ({
|
||||
goalApi: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import ScheduleCenterPage from './index.vue'
|
||||
import { scheduleCenterApi } from '@/api/scheduleCenter'
|
||||
import { todoApi } from '@/api/todo'
|
||||
import { taskApi } from '@/api/task'
|
||||
import { reminderApi } from '@/api/reminder'
|
||||
import { goalApi } from '@/api/goal'
|
||||
|
||||
const mockedScheduleCenterApi = scheduleCenterApi as Mocked<typeof scheduleCenterApi>
|
||||
const mockedTodoApi = todoApi as Mocked<typeof todoApi>
|
||||
const mockedTaskApi = taskApi as Mocked<typeof taskApi>
|
||||
const mockedReminderApi = reminderApi as Mocked<typeof reminderApi>
|
||||
const mockedGoalApi = goalApi as Mocked<typeof goalApi>
|
||||
|
||||
const monthPayload = {
|
||||
data: {
|
||||
month: '2026-03',
|
||||
days: [
|
||||
{
|
||||
date: '2026-03-26',
|
||||
todo_total: 1,
|
||||
todo_completed: 0,
|
||||
task_due_total: 2,
|
||||
high_priority_total: 1,
|
||||
reminder_total: 1,
|
||||
goal_total: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const datePayload = {
|
||||
data: {
|
||||
date: '2026-03-26',
|
||||
todos: [{ id: 'todo-1', title: 'Write plan', is_completed: false, source: 'manual', source_detail: null, todo_date: '2026-03-26', completed_at: null, created_at: '', updated_at: '' }],
|
||||
tasks: [{ id: 'task-1', title: 'Ship center', status: 'todo', priority: 'high', created_at: '', updated_at: '' }],
|
||||
reminders: [{ id: 'rem-1', title: 'Standup', note: null, reminder_at: '2026-03-26T09:00:00Z', status: 'pending', is_dismissed: false, created_at: '', updated_at: '' }],
|
||||
goals: [{ id: 'goal-1', title: 'Launch', note: null, goal_date: '2026-03-26', status: 'active', created_at: '', updated_at: '' }],
|
||||
summary: {
|
||||
date: '2026-03-26',
|
||||
todo_total: 1,
|
||||
todo_completed: 0,
|
||||
task_due_total: 2,
|
||||
high_priority_total: 1,
|
||||
reminder_total: 1,
|
||||
goal_total: 1,
|
||||
},
|
||||
generated_at: '2026-03-26T00:00:00Z',
|
||||
},
|
||||
}
|
||||
|
||||
describe('ScheduleCenterPage', () => {
|
||||
beforeEach(() => {
|
||||
mockedScheduleCenterApi.month.mockResolvedValue(monthPayload as never)
|
||||
mockedScheduleCenterApi.date.mockResolvedValue(datePayload as never)
|
||||
mockedTodoApi.create.mockResolvedValue({ data: {} } as never)
|
||||
mockedTodoApi.update.mockResolvedValue({ data: {} } as never)
|
||||
mockedTodoApi.delete.mockResolvedValue({ data: {} } as never)
|
||||
mockedTaskApi.create.mockResolvedValue({ data: {} } as never)
|
||||
mockedTaskApi.update.mockResolvedValue({ data: {} } as never)
|
||||
mockedTaskApi.delete.mockResolvedValue({ data: {} } as never)
|
||||
mockedReminderApi.create.mockResolvedValue({ data: {} } as never)
|
||||
mockedReminderApi.update.mockResolvedValue({ data: {} } as never)
|
||||
mockedReminderApi.delete.mockResolvedValue({ data: {} } as never)
|
||||
mockedGoalApi.create.mockResolvedValue({ data: {} } as never)
|
||||
mockedGoalApi.update.mockResolvedValue({ data: {} } as never)
|
||||
mockedGoalApi.delete.mockResolvedValue({ data: {} } as never)
|
||||
})
|
||||
|
||||
it('loads month and selected day data on mount', async () => {
|
||||
const wrapper = mount(ScheduleCenterPage)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockedScheduleCenterApi.month).toHaveBeenCalledTimes(1)
|
||||
expect(mockedScheduleCenterApi.date).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.find('.date-panel').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('SCHEDULE CENTER / TACTICAL CALENDAR MATRIX')
|
||||
expect(wrapper.text()).toContain('查看当前设置')
|
||||
expect(wrapper.text()).toContain('新增安排')
|
||||
expect(wrapper.text()).toContain('Write plan')
|
||||
expect(wrapper.text()).toContain('Ship center')
|
||||
expect(wrapper.text()).not.toContain('新增 TODO')
|
||||
})
|
||||
|
||||
it('keeps the side panel open and refreshes when selecting a calendar cell', async () => {
|
||||
const wrapper = mount(ScheduleCenterPage)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.date-panel').exists()).toBe(true)
|
||||
|
||||
await wrapper.find('.calendar-cell').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.date-panel').exists()).toBe(true)
|
||||
expect(mockedScheduleCenterApi.date.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('switches panel to create mode and creates a todo', async () => {
|
||||
const wrapper = mount(ScheduleCenterPage)
|
||||
await flushPromises()
|
||||
|
||||
const panelTabs = wrapper.findAll('.panel-tab')
|
||||
await panelTabs[1].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('新增 TODO')
|
||||
expect(wrapper.text()).toContain('新增任务')
|
||||
expect(wrapper.text()).toContain('新增提醒')
|
||||
expect(wrapper.text()).toContain('新增目标')
|
||||
expect(wrapper.text()).not.toContain('Write plan')
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
await inputs[0].setValue('New todo item')
|
||||
const addButtons = wrapper.findAll('.create-row button')
|
||||
await addButtons[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockedTodoApi.create).toHaveBeenCalledWith({ title: 'New todo item', todo_date: expect.any(String) })
|
||||
expect(mockedScheduleCenterApi.date.mock.calls.length).toBeGreaterThanOrEqual(3)
|
||||
expect(mockedScheduleCenterApi.month.mock.calls.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
|
||||
228
frontend/src/pages/schedule-center/index.vue
Normal file
228
frontend/src/pages/schedule-center/index.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, Clock3, Plus } from 'lucide-vue-next'
|
||||
import { useScheduleCenterPage } from './composables/useScheduleCenterPage'
|
||||
|
||||
const {
|
||||
weekdayLabels,
|
||||
currentMonth,
|
||||
panelOpen,
|
||||
loadingMonth,
|
||||
loadingDetail,
|
||||
newTodoTitle,
|
||||
newTaskTitle,
|
||||
newTaskPriority,
|
||||
newReminderTitle,
|
||||
newReminderTime,
|
||||
newGoalTitle,
|
||||
panelMode,
|
||||
panelFrameRef,
|
||||
calendarDays,
|
||||
panelRings,
|
||||
timelineItems,
|
||||
monthOverview,
|
||||
getMonthKey,
|
||||
openDate,
|
||||
shiftMonth,
|
||||
addTodo,
|
||||
addTask,
|
||||
addReminder,
|
||||
addGoal,
|
||||
toggleTimelineItem,
|
||||
removeTimelineItem,
|
||||
animatePanelEnter,
|
||||
animatePanelLeave,
|
||||
} = useScheduleCenterPage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="schedule-center scanlines" :class="{ 'panel-active': panelOpen }">
|
||||
<div class="bg-grid"></div>
|
||||
<div class="bg-scan"></div>
|
||||
<div class="bg-orbit"></div>
|
||||
|
||||
<div class="schedule-shell">
|
||||
<section class="frame board">
|
||||
<div class="topbar">
|
||||
<div class="hero-label">SCHEDULE CENTER / TACTICAL CALENDAR MATRIX</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="small-label">Pending Queue</div>
|
||||
<strong>{{ monthOverview.todo_total }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="small-label">Task Pressure</div>
|
||||
<strong>{{ monthOverview.task_due_total }}</strong>
|
||||
</div>
|
||||
<div class="metric warn">
|
||||
<div class="small-label">Reminder Burst</div>
|
||||
<strong>{{ monthOverview.reminder_total }}</strong>
|
||||
</div>
|
||||
<div class="metric alert">
|
||||
<div class="small-label">Critical Focus</div>
|
||||
<strong>{{ monthOverview.high_priority_total }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-head">
|
||||
<div>
|
||||
<div class="eyebrow">Calendar Matrix</div>
|
||||
<div class="month">{{ getMonthKey(currentMonth) }}</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="ghost-btn" type="button" @click="shiftMonth(-1)">
|
||||
<ChevronLeft :size="16" />
|
||||
</button>
|
||||
<button class="ghost-btn" type="button" @click="shiftMonth(1)">
|
||||
<ChevronRight :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weekday">
|
||||
<div v-for="label in weekdayLabels" :key="label">{{ label }}</div>
|
||||
</div>
|
||||
|
||||
<div class="days" :class="{ loading: loadingMonth }">
|
||||
<template v-for="day in calendarDays" :key="day.dateKey">
|
||||
<div v-if="day.isPlaceholder" class="day muted" aria-hidden="true"></div>
|
||||
<button
|
||||
v-else
|
||||
class="day calendar-cell"
|
||||
:class="{
|
||||
active: day.isSelected,
|
||||
today: day.isToday,
|
||||
}"
|
||||
@click="openDate(day.dateKey)"
|
||||
>
|
||||
<div class="day-top">
|
||||
<span class="day-num">{{ day.dayNumber }}</span>
|
||||
<span class="risk">{{ day.risk }}</span>
|
||||
</div>
|
||||
<div class="state">{{ day.state }}</div>
|
||||
<div class="bars">
|
||||
<div v-for="bar in day.bars" :key="bar.key" class="bar">
|
||||
<span>{{ bar.key }}</span>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" :style="{ '--value': bar.value }"></div>
|
||||
</div>
|
||||
<span>{{ bar.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Transition :css="false" @enter="animatePanelEnter" @leave="animatePanelLeave">
|
||||
<aside v-if="panelOpen" ref="panelFrameRef" class="frame side date-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title-line">DAY DIAGNOSTICS / ACTIVE FEED</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-switcher">
|
||||
<button class="panel-tab" :class="{ active: panelMode === 'detail' }" @click="panelMode = 'detail'">查看当前设置</button>
|
||||
<button class="panel-tab" :class="{ active: panelMode === 'create' }" @click="panelMode = 'create'">新增安排</button>
|
||||
</div>
|
||||
|
||||
<div class="ring-strip">
|
||||
<div v-for="ring in panelRings" :key="ring.label" class="ring-card">
|
||||
<div class="ring" :class="ring.tone" :style="{ '--pct': ring.value }">{{ ring.value }}%</div>
|
||||
<div class="micro">{{ ring.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingDetail" class="panel-loading">SYNCING DAY DATA...</div>
|
||||
|
||||
<div v-else class="panel-content">
|
||||
<template v-if="panelMode === 'detail'">
|
||||
<div class="timeline">
|
||||
<article v-for="item in timelineItems" :key="item.id" class="event" :class="item.tone">
|
||||
<div class="event-top">
|
||||
<span class="event-time">{{ item.time }}</span>
|
||||
<span class="event-type">{{ item.label }}</span>
|
||||
</div>
|
||||
<div class="event-title">{{ item.title }}</div>
|
||||
<div class="event-meta">{{ item.meta }}</div>
|
||||
<div class="event-actions">
|
||||
<button class="mini-btn" type="button" @click="toggleTimelineItem(item)">{{ item.done ? '恢复' : '完成' }}</button>
|
||||
<button class="mini-btn danger" type="button" @click="removeTimelineItem(item)">删除</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="!timelineItems.length" class="empty-state">当前日期还没有安排</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="create-stack">
|
||||
<section class="create-card">
|
||||
<div class="create-head">
|
||||
<span>新增 TODO</span>
|
||||
<span>QUEUE</span>
|
||||
</div>
|
||||
<div class="create-row">
|
||||
<input v-model="newTodoTitle" placeholder="新增当天待办..." @keyup.enter="addTodo" />
|
||||
<button type="button" @click="addTodo">
|
||||
<Plus :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="create-card">
|
||||
<div class="create-head">
|
||||
<span>新增任务</span>
|
||||
<span>TASK</span>
|
||||
</div>
|
||||
<div class="create-row split">
|
||||
<input v-model="newTaskTitle" placeholder="新增计划任务..." @keyup.enter="addTask" />
|
||||
<select v-model="newTaskPriority">
|
||||
<option value="low">LOW</option>
|
||||
<option value="medium">MEDIUM</option>
|
||||
<option value="high">HIGH</option>
|
||||
<option value="urgent">CRITICAL</option>
|
||||
</select>
|
||||
<button type="button" @click="addTask">
|
||||
<Plus :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="create-card">
|
||||
<div class="create-head">
|
||||
<span>新增提醒</span>
|
||||
<span>REMINDER</span>
|
||||
</div>
|
||||
<div class="create-row split">
|
||||
<input v-model="newReminderTitle" placeholder="新增提醒..." @keyup.enter="addReminder" />
|
||||
<label class="time-field"><span class="time-field-icon"><Clock3 :size="14" /></span><input v-model="newReminderTime" type="time" aria-label="提醒时间" /></label>
|
||||
<button type="button" @click="addReminder">
|
||||
<Plus :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="create-card">
|
||||
<div class="create-head">
|
||||
<span>新增目标</span>
|
||||
<span>GOAL</span>
|
||||
</div>
|
||||
<div class="create-row">
|
||||
<input v-model="newGoalTitle" placeholder="新增当天目标..." @keyup.enter="addGoal" />
|
||||
<button type="button" @click="addGoal">
|
||||
<Plus :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped src="./scheduleCenterPage.css"></style>
|
||||
|
||||
751
frontend/src/pages/schedule-center/scheduleCenterPage.css
Normal file
751
frontend/src/pages/schedule-center/scheduleCenterPage.css
Normal file
@@ -0,0 +1,751 @@
|
||||
.schedule-center {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(0, 245, 212, 0.08), transparent 30%),
|
||||
radial-gradient(circle at 84% 18%, rgba(0, 245, 212, 0.05), transparent 24%),
|
||||
linear-gradient(180deg, var(--bg-deep) 0%, var(--bg-void) 48%, #02060c 100%);
|
||||
}
|
||||
|
||||
.bg-grid,
|
||||
.bg-scan,
|
||||
.bg-orbit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 245, 212, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 245, 212, 0.04) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
mask-image: radial-gradient(circle at center, black 48%, transparent 94%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bg-scan {
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(0, 245, 212, 0.08) 49%, transparent 51%, transparent 100%);
|
||||
transform: translateY(-100%);
|
||||
animation: scan 12s linear infinite;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.bg-orbit {
|
||||
width: 44vw;
|
||||
height: 44vw;
|
||||
inset: -8vw 0 0 auto;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-dim);
|
||||
box-shadow: 0 0 40px rgba(0, 245, 212, 0.06);
|
||||
animation: spin 36s linear infinite;
|
||||
}
|
||||
|
||||
.bg-orbit::before,
|
||||
.bg-orbit::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 7%;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.bg-orbit::after {
|
||||
inset: 21%;
|
||||
}
|
||||
|
||||
.schedule-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(780px, 1.25fr) 420px;
|
||||
gap: 22px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 28px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.frame {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-mid);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(10, 15, 26, 0.94) 0%, rgba(7, 13, 22, 0.82) 100%),
|
||||
radial-gradient(circle at top right, rgba(0, 245, 212, 0.08), transparent 38%);
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.45), inset 0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.frame::before,
|
||||
.frame::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.frame::before {
|
||||
inset: 14px;
|
||||
border: 1px solid rgba(0, 245, 212, 0.07);
|
||||
clip-path: polygon(0 14px, 14px 0, calc(100% - 62px) 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 62px 100%, 0 100%);
|
||||
}
|
||||
|
||||
.frame::after {
|
||||
top: 18px;
|
||||
right: 20px;
|
||||
width: 120px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(0, 245, 212, 0.55), transparent);
|
||||
}
|
||||
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto auto 1fr;
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
border-radius: 28px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto 1fr;
|
||||
gap: 22px;
|
||||
padding: 28px 24px 24px;
|
||||
border-radius: 28px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.date-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.grid-head,
|
||||
.panel-header,
|
||||
.event-top,
|
||||
.event-actions,
|
||||
.create-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.grid-head,
|
||||
.panel-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.title-group,
|
||||
.bars,
|
||||
.timeline,
|
||||
.create-stack,
|
||||
.panel-content {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.small-label,
|
||||
.micro,
|
||||
.month,
|
||||
.weekday div,
|
||||
.day-top,
|
||||
.state,
|
||||
.bar,
|
||||
.lock,
|
||||
.event-time,
|
||||
.event-type,
|
||||
.create-head,
|
||||
.panel-tab,
|
||||
.mini-btn {
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.small-label,
|
||||
.micro {
|
||||
color: var(--text-dim);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.hero-label,
|
||||
.panel-title-line {
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-label {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.panel-title-line {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.month {
|
||||
font-size: 20px;
|
||||
font-family: var(--font-display);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
max-width: 520px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.toolbar,
|
||||
.weekday,
|
||||
.days,
|
||||
.ring-strip {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
grid-auto-flow: column;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.weekday,
|
||||
.days {
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
min-height: 0;
|
||||
grid-auto-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.days {
|
||||
align-content: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ring-strip {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metric,
|
||||
.ring-card,
|
||||
.event,
|
||||
.action,
|
||||
.day,
|
||||
.create-card {
|
||||
border: 1px solid var(--border-dim);
|
||||
background: linear-gradient(180deg, rgba(13, 21, 37, 0.94), rgba(10, 15, 26, 0.82));
|
||||
}
|
||||
.metric strong {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metric {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
|
||||
}
|
||||
|
||||
.metric::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: 11px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--accent-cyan), transparent);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.metric.warn strong {
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.metric.alert strong {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.ghost-btn,
|
||||
.mini-btn,
|
||||
.create-row button {
|
||||
border: 1px solid var(--border-mid);
|
||||
background: var(--accent-cyan-dim);
|
||||
color: var(--accent-cyan);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 38px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.ghost-btn:hover,
|
||||
.mini-btn:hover,
|
||||
.create-row button:hover,
|
||||
.day:hover:not(.muted) {
|
||||
border-color: var(--border-bright);
|
||||
box-shadow: var(--glow-cyan);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.weekday div {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.days.loading {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.day {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 9px;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
gap: 6px;
|
||||
clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.day.muted {
|
||||
opacity: 0.16;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.day.active {
|
||||
border-color: rgba(249, 168, 37, 0.72);
|
||||
box-shadow: 0 0 24px rgba(249, 168, 37, 0.16);
|
||||
}
|
||||
|
||||
.day.today {
|
||||
border-color: rgba(249, 168, 37, 0.28);
|
||||
}
|
||||
|
||||
.day-num {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.risk {
|
||||
color: var(--accent-amber);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.state {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 1fr 18px;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 7px;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
position: relative;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: var(--value);
|
||||
background: linear-gradient(90deg, var(--accent-cyan), rgba(0, 245, 212, 0.14));
|
||||
box-shadow: 0 0 14px rgba(0, 245, 212, 0.22);
|
||||
}
|
||||
|
||||
.panel-switcher {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-tab {
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: 6px;
|
||||
clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 14px 100%, 0 calc(100% - 14px));
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255,255,255,0.01));
|
||||
color: var(--text-secondary);
|
||||
padding: 12px 14px;
|
||||
font-size: 11px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.panel-tab:hover,
|
||||
.panel-tab.active {
|
||||
border-color: var(--border-bright);
|
||||
color: var(--accent-cyan);
|
||||
background: linear-gradient(180deg, rgba(0, 245, 212, 0.12), rgba(0, 245, 212, 0.05));
|
||||
box-shadow: 0 0 18px rgba(0, 245, 212, 0.14);
|
||||
}
|
||||
|
||||
.ring-card {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 12px;
|
||||
background: rgba(0, 245, 212, 0.03);
|
||||
}
|
||||
|
||||
.ring {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(3, 10, 18, 0.95) 0 54%, transparent 55%),
|
||||
conic-gradient(var(--accent-cyan) calc(var(--pct) * 1%), rgba(255, 255, 255, 0.06) 0);
|
||||
box-shadow: inset 0 0 18px rgba(0, 245, 212, 0.08), 0 0 24px rgba(0, 245, 212, 0.08);
|
||||
}
|
||||
|
||||
.ring.warn {
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(3, 10, 18, 0.95) 0 54%, transparent 55%),
|
||||
conic-gradient(var(--accent-amber) calc(var(--pct) * 1%), rgba(255, 255, 255, 0.06) 0);
|
||||
}
|
||||
|
||||
.ring.alert {
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(3, 10, 18, 0.95) 0 54%, transparent 55%),
|
||||
conic-gradient(var(--accent-red) calc(var(--pct) * 1%), rgba(255, 255, 255, 0.06) 0);
|
||||
}
|
||||
|
||||
.panel-loading {
|
||||
padding: 24px 0;
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 1px;
|
||||
background: linear-gradient(180deg, rgba(0, 245, 212, 0.36), rgba(0, 245, 212, 0.06));
|
||||
}
|
||||
|
||||
.event {
|
||||
position: relative;
|
||||
padding: 14px 14px 14px 16px;
|
||||
}
|
||||
|
||||
.event::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -17px;
|
||||
top: 18px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 14px rgba(0, 245, 212, 0.5);
|
||||
}
|
||||
|
||||
.event.warn::before {
|
||||
background: var(--accent-amber);
|
||||
box-shadow: 0 0 14px rgba(249, 168, 37, 0.42);
|
||||
}
|
||||
|
||||
.event.alert::before {
|
||||
background: var(--accent-red);
|
||||
box-shadow: 0 0 14px rgba(255, 71, 87, 0.42);
|
||||
}
|
||||
|
||||
.event-time {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.event-type {
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
margin: 8px 0 4px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.mini-btn {
|
||||
border-radius: 10px;
|
||||
padding: 7px 10px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mini-btn.danger {
|
||||
border-color: rgba(255, 71, 87, 0.35);
|
||||
background: rgba(255, 71, 87, 0.08);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.create-stack {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.create-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.create-head {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.create-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.create-row.split select,
|
||||
.create-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.create-row.split > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.create-row.split select {
|
||||
flex: 0 0 112px;
|
||||
}
|
||||
|
||||
.create-row.split input[type='time'] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.time-field {
|
||||
flex: 0 0 148px;
|
||||
min-width: 148px;
|
||||
max-width: 148px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(10, 18, 28, 0.94), rgba(8, 14, 23, 0.88)),
|
||||
radial-gradient(circle at top left, rgba(0, 245, 212, 0.07), transparent 55%);
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
.time-field:focus-within {
|
||||
border-color: var(--border-bright);
|
||||
box-shadow: var(--glow-cyan);
|
||||
}
|
||||
|
||||
.time-field-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-cyan);
|
||||
opacity: 0.9;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.time-field input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(84%) sepia(78%) saturate(613%) hue-rotate(112deg) brightness(101%) contrast(101%);
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.create-row button {
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 12px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.01);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.schedule-center {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.schedule-shell {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.ring-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.schedule-shell {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.board,
|
||||
.side {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.ring-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.days {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.day {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.create-row.split {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.create-row.split input:first-child {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.create-row.split select,
|
||||
.create-row.split input[type='time'],
|
||||
.time-field {
|
||||
flex: 1 1 0;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,6 @@ export function useSettingsView() {
|
||||
function createEmptyModel(type: string): LLMModelConfig {
|
||||
return {
|
||||
name: `${type.toUpperCase()}-${Date.now()}`,
|
||||
provider: 'openai',
|
||||
model: type === 'chat'
|
||||
? 'gpt-4o'
|
||||
: type === 'vlm'
|
||||
@@ -105,7 +104,7 @@ export function useSettingsView() {
|
||||
: type === 'embedding'
|
||||
? 'text-embedding-3-small'
|
||||
: 'bge-reranker-v2',
|
||||
base_url: '',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
api_key: '',
|
||||
enabled: true,
|
||||
}
|
||||
@@ -242,7 +241,6 @@ export function useSettingsView() {
|
||||
try {
|
||||
const response = await settingsApi.testLLM({
|
||||
type: type as LLMType,
|
||||
provider: model.provider,
|
||||
model: model.model,
|
||||
base_url: model.base_url,
|
||||
api_key: model.api_key,
|
||||
|
||||
446
frontend/src/pages/skills/composables/useSkillsPage.ts
Normal file
446
frontend/src/pages/skills/composables/useSkillsPage.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { skillApi, type Skill, type SkillCreate } from '@/api/skill'
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return typeof window !== 'undefined'
|
||||
&& typeof window.matchMedia === 'function'
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
|
||||
function animateIn(el: Element, done: () => void) {
|
||||
const target = el as HTMLElement
|
||||
if (prefersReducedMotion()) {
|
||||
target.style.opacity = '1'
|
||||
target.style.transform = 'translateY(0)'
|
||||
done()
|
||||
return
|
||||
}
|
||||
|
||||
target.animate(
|
||||
[
|
||||
{ opacity: '0', transform: 'translateY(12px)' },
|
||||
{ opacity: '1', transform: 'translateY(0)' },
|
||||
],
|
||||
{ duration: 220, easing: 'ease-out', fill: 'forwards' },
|
||||
).finished.finally(done)
|
||||
}
|
||||
|
||||
function animateOut(el: Element, done: () => void) {
|
||||
const target = el as HTMLElement
|
||||
if (prefersReducedMotion()) {
|
||||
target.style.opacity = '0'
|
||||
target.style.transform = 'translateY(12px)'
|
||||
done()
|
||||
return
|
||||
}
|
||||
|
||||
target.animate(
|
||||
[
|
||||
{ opacity: '1', transform: 'translateY(0)' },
|
||||
{ opacity: '0', transform: 'translateY(12px)' },
|
||||
],
|
||||
{ duration: 180, easing: 'ease-in', fill: 'forwards' },
|
||||
).finished.finally(done)
|
||||
}
|
||||
|
||||
const AGENT_TYPES = ['general', 'schedule_planner', 'executor', 'librarian', 'analyst']
|
||||
const AVAILABLE_TOOLS = ['file_operations', 'web_search', 'code_execution', 'database', 'api_calls', 'shell', 'git', 'calendar', 'tasks']
|
||||
const VISIBILITY_OPTIONS = ['private', 'team', 'market'] as const
|
||||
const SOURCE_OPTIONS = ['all', 'builtin', 'custom'] as const
|
||||
const STATUS_OPTIONS = ['all', 'active', 'inactive'] as const
|
||||
|
||||
const BUILTIN_MCP_PACKS = [
|
||||
{
|
||||
id: 'mcp-schedule-coordinator',
|
||||
name: '鏃ョ▼鍗忚皟宸ュ叿鍖?',
|
||||
agentType: 'schedule_planner',
|
||||
category: 'planning',
|
||||
tools: ['calendar', 'tasks'],
|
||||
description: '鍥寸粫鎺掓湡銆佸啿绐佽瘑鍒笌璁″垝钀藉湴缁勭粐鍙墽琛屽伐鍏疯兘鍔涖€?',
|
||||
},
|
||||
{
|
||||
id: 'mcp-execution-ops',
|
||||
name: '鎵ц鎺ㄨ繘宸ュ叿鍖?',
|
||||
agentType: 'executor',
|
||||
category: 'operations',
|
||||
tools: ['shell', 'api_calls', 'git'],
|
||||
description: '涓烘墽琛岃鑹叉彁渚涗换鍔℃帹杩涖€佸懡浠ゆ墽琛屼笌澶栭儴浜や簰鑳藉姏銆?',
|
||||
},
|
||||
{
|
||||
id: 'mcp-knowledge-pipeline',
|
||||
name: '鐭ヨ瘑娌夋穩宸ュ叿鍖?',
|
||||
agentType: 'librarian',
|
||||
category: 'knowledge',
|
||||
tools: ['web_search', 'database'],
|
||||
description: '鏀寔妫€绱€佹暣鐞嗕笌鍥捐氨娌夋穩绛夌煡璇嗗伐浣滄祦銆?',
|
||||
},
|
||||
{
|
||||
id: 'mcp-analysis-console',
|
||||
name: '鍒嗘瀽娲炲療宸ュ叿鍖?',
|
||||
agentType: 'analyst',
|
||||
category: 'analysis',
|
||||
tools: ['database', 'api_calls', 'code_execution'],
|
||||
description: '鑱氬悎鍒嗘瀽銆佸姣斾笌娲炲療鏁寸悊鎵€闇€鐨勬牳蹇冨伐鍏枫€?',
|
||||
},
|
||||
] as const
|
||||
|
||||
type BuiltinMcpPack = (typeof BUILTIN_MCP_PACKS)[number]
|
||||
|
||||
export function useSkillsPage() {
|
||||
const skills = ref<Skill[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const modalOpen = ref(false)
|
||||
const mcpPanelOpen = ref(false)
|
||||
const editingSkill = ref<Skill | null>(null)
|
||||
const selectedSkillId = ref<string | null>(null)
|
||||
const detailDrawerOpen = ref(false)
|
||||
const drawerTitleId = 'skills-detail-drawer-title'
|
||||
const drawerDescriptionId = 'skills-detail-drawer-description'
|
||||
const drawerPanelRef = ref<HTMLElement | null>(null)
|
||||
const lastTriggerElement = ref<HTMLElement | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const selectedAgentFilter = ref<'all' | string>('all')
|
||||
const selectedSourceFilter = ref<(typeof SOURCE_OPTIONS)[number]>('all')
|
||||
const selectedStatusFilter = ref<(typeof STATUS_OPTIONS)[number]>('all')
|
||||
const selectedVisibilityFilter = ref<'all' | Skill['visibility']>('all')
|
||||
|
||||
const mcpPacksByAgent = computed(() => {
|
||||
return BUILTIN_MCP_PACKS.reduce<Record<string, BuiltinMcpPack[]>>((acc, pack) => {
|
||||
const existing = acc[pack.agentType] || []
|
||||
acc[pack.agentType] = [...existing, pack]
|
||||
return acc
|
||||
}, {})
|
||||
})
|
||||
|
||||
const activeSkillCount = computed(() => skills.value.filter(skill => skill.is_active).length)
|
||||
const coverageToolCount = computed(() => new Set(skills.value.flatMap(skill => skill.tools)).size)
|
||||
const activeRatio = computed(() => skills.value.length ? `${Math.round((activeSkillCount.value / skills.value.length) * 100)}%` : '0%')
|
||||
const selectedSkill = computed(() => skills.value.find(skill => skill.id === selectedSkillId.value) ?? null)
|
||||
|
||||
const filteredSkills = computed(() => {
|
||||
const keyword = searchQuery.value.trim().toLowerCase()
|
||||
return skills.value.filter((skill) => {
|
||||
const matchesKeyword = !keyword || [
|
||||
skill.name,
|
||||
skill.description || '',
|
||||
skill.agent_type,
|
||||
skill.visibility,
|
||||
...skill.tools,
|
||||
].some(value => value.toLowerCase().includes(keyword))
|
||||
|
||||
const matchesAgent = selectedAgentFilter.value === 'all' || skill.agent_type === selectedAgentFilter.value
|
||||
const matchesSource = selectedSourceFilter.value === 'all'
|
||||
|| (selectedSourceFilter.value === 'builtin' ? skill.is_builtin : !skill.is_builtin)
|
||||
const matchesStatus = selectedStatusFilter.value === 'all'
|
||||
|| (selectedStatusFilter.value === 'active' ? skill.is_active : !skill.is_active)
|
||||
const matchesVisibility = selectedVisibilityFilter.value === 'all' || skill.visibility === selectedVisibilityFilter.value
|
||||
|
||||
return matchesKeyword && matchesAgent && matchesSource && matchesStatus && matchesVisibility
|
||||
})
|
||||
})
|
||||
|
||||
const form = ref<SkillCreate>({
|
||||
name: '',
|
||||
description: '',
|
||||
instructions: '',
|
||||
agent_type: 'general',
|
||||
tools: [],
|
||||
visibility: 'private',
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
name: '',
|
||||
description: '',
|
||||
instructions: '',
|
||||
agent_type: 'general',
|
||||
tools: [],
|
||||
visibility: 'private',
|
||||
is_active: true,
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingSkill.value = null
|
||||
resetForm()
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
function openEditModal(skill: Skill) {
|
||||
editingSkill.value = skill
|
||||
form.value = {
|
||||
name: skill.name,
|
||||
description: skill.description || '',
|
||||
instructions: skill.instructions,
|
||||
agent_type: skill.agent_type,
|
||||
tools: [...skill.tools],
|
||||
visibility: skill.visibility,
|
||||
is_active: skill.is_active,
|
||||
}
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen.value = false
|
||||
editingSkill.value = null
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function openMcpPanel() {
|
||||
mcpPanelOpen.value = true
|
||||
}
|
||||
|
||||
function closeMcpPanel() {
|
||||
mcpPanelOpen.value = false
|
||||
}
|
||||
|
||||
function selectSkill(skillId: string, trigger?: EventTarget | null) {
|
||||
selectedSkillId.value = skillId
|
||||
lastTriggerElement.value = trigger instanceof HTMLElement ? trigger : null
|
||||
detailDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function closeDetailDrawer() {
|
||||
detailDrawerOpen.value = false
|
||||
}
|
||||
|
||||
function handleDrawerKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeDetailDrawer()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== 'Tab' || !drawerPanelRef.value) return
|
||||
|
||||
const focusableElements = Array.from(
|
||||
drawerPanelRef.value.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),
|
||||
).filter(element => !element.hasAttribute('disabled'))
|
||||
|
||||
if (focusableElements.length === 0) return
|
||||
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement = focusableElements[focusableElements.length - 1]
|
||||
const activeElement = document.activeElement
|
||||
|
||||
if (event.shiftKey && activeElement === firstElement) {
|
||||
event.preventDefault()
|
||||
lastElement.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.shiftKey && activeElement === lastElement) {
|
||||
event.preventDefault()
|
||||
firstElement.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string) {
|
||||
const timestamp = new Date(value).getTime()
|
||||
if (Number.isNaN(timestamp)) return '--'
|
||||
const diffMinutes = Math.max(0, Math.floor((Date.now() - timestamp) / 60000))
|
||||
if (diffMinutes < 1) return '鍒氬垰'
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
return `${diffDays}d ago`
|
||||
}
|
||||
|
||||
function getBindingCount(skill: Skill) {
|
||||
return Math.max(1, Math.min(4, skill.tools.length || 1))
|
||||
}
|
||||
|
||||
function getSourceLabel(skill: Skill) {
|
||||
return skill.is_builtin ? 'BUILT-IN' : 'CUSTOM'
|
||||
}
|
||||
|
||||
function getStatusLabel(skill: Skill) {
|
||||
return skill.is_active ? 'ACTIVE' : 'INACTIVE'
|
||||
}
|
||||
|
||||
function getToolPreview(skill: Skill) {
|
||||
return skill.tools.slice(0, 3)
|
||||
}
|
||||
|
||||
function getToolOverflow(skill: Skill) {
|
||||
return Math.max(0, skill.tools.length - 3)
|
||||
}
|
||||
|
||||
async function fetchSkills() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await skillApi.list()
|
||||
if (res.data.length === 0) {
|
||||
const bootstrapRes = await skillApi.bootstrapBuiltin()
|
||||
skills.value = bootstrapRes.data
|
||||
} else {
|
||||
skills.value = res.data
|
||||
}
|
||||
selectedSkillId.value = skills.value[0]?.id ?? null
|
||||
detailDrawerOpen.value = false
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch skills', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createSkill() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await skillApi.create(form.value)
|
||||
skills.value = [...skills.value, res.data]
|
||||
selectedSkillId.value = res.data.id
|
||||
detailDrawerOpen.value = true
|
||||
closeModal()
|
||||
} catch (e) {
|
||||
console.error('Failed to create skill', e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSkill() {
|
||||
if (!editingSkill.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await skillApi.update(editingSkill.value.id, form.value)
|
||||
skills.value = skills.value.map(skill => skill.id === editingSkill.value?.id ? res.data : skill)
|
||||
selectedSkillId.value = res.data.id
|
||||
detailDrawerOpen.value = true
|
||||
closeModal()
|
||||
} catch (e) {
|
||||
console.error('Failed to update skill', e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSkill(skill: Skill) {
|
||||
if (!confirm(`Delete skill "${skill.name}"?`)) return
|
||||
try {
|
||||
await skillApi.delete(skill.id)
|
||||
skills.value = skills.value.filter(s => s.id !== skill.id)
|
||||
if (selectedSkillId.value === skill.id) {
|
||||
selectedSkillId.value = skills.value[0]?.id ?? null
|
||||
if (!selectedSkillId.value) {
|
||||
detailDrawerOpen.value = false
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete skill', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(skill: Skill) {
|
||||
try {
|
||||
const res = await skillApi.update(skill.id, { is_active: !skill.is_active })
|
||||
skills.value = skills.value.map(item => item.id === skill.id ? res.data : item)
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle skill active state', e)
|
||||
}
|
||||
}
|
||||
|
||||
function copySkill(skill: Skill) {
|
||||
const skillText = JSON.stringify({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
instructions: skill.instructions,
|
||||
agent_type: skill.agent_type,
|
||||
tools: skill.tools,
|
||||
visibility: skill.visibility,
|
||||
}, null, 2)
|
||||
navigator.clipboard.writeText(skillText).catch(e => {
|
||||
console.error('Failed to copy skill', e)
|
||||
})
|
||||
}
|
||||
|
||||
function toggleTool(tool: string) {
|
||||
const idx = form.value.tools?.indexOf(tool) ?? -1
|
||||
if (idx === -1) {
|
||||
form.value.tools = [...(form.value.tools || []), tool]
|
||||
} else {
|
||||
form.value.tools = form.value.tools?.filter(t => t !== tool) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
watch(detailDrawerOpen, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
await nextTick()
|
||||
drawerPanelRef.value?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const target = lastTriggerElement.value
|
||||
lastTriggerElement.value = null
|
||||
target?.focus()
|
||||
})
|
||||
|
||||
watch(skills, (nextSkills) => {
|
||||
if (selectedSkillId.value && !nextSkills.some(skill => skill.id === selectedSkillId.value)) {
|
||||
selectedSkillId.value = null
|
||||
detailDrawerOpen.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(fetchSkills)
|
||||
onBeforeUnmount(() => {
|
||||
lastTriggerElement.value = null
|
||||
})
|
||||
|
||||
return {
|
||||
AGENT_TYPES,
|
||||
AVAILABLE_TOOLS,
|
||||
VISIBILITY_OPTIONS,
|
||||
SOURCE_OPTIONS,
|
||||
STATUS_OPTIONS,
|
||||
BUILTIN_MCP_PACKS,
|
||||
skills,
|
||||
loading,
|
||||
saving,
|
||||
modalOpen,
|
||||
mcpPanelOpen,
|
||||
editingSkill,
|
||||
detailDrawerOpen,
|
||||
drawerTitleId,
|
||||
drawerDescriptionId,
|
||||
drawerPanelRef,
|
||||
searchQuery,
|
||||
selectedAgentFilter,
|
||||
selectedSourceFilter,
|
||||
selectedStatusFilter,
|
||||
selectedVisibilityFilter,
|
||||
mcpPacksByAgent,
|
||||
activeSkillCount,
|
||||
coverageToolCount,
|
||||
activeRatio,
|
||||
selectedSkill,
|
||||
filteredSkills,
|
||||
form,
|
||||
openCreateModal,
|
||||
openEditModal,
|
||||
closeModal,
|
||||
openMcpPanel,
|
||||
closeMcpPanel,
|
||||
selectSkill,
|
||||
closeDetailDrawer,
|
||||
handleDrawerKeydown,
|
||||
formatRelativeTime,
|
||||
getBindingCount,
|
||||
getSourceLabel,
|
||||
getStatusLabel,
|
||||
getToolPreview,
|
||||
getToolOverflow,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
toggleActive,
|
||||
copySkill,
|
||||
toggleTool,
|
||||
animateIn,
|
||||
animateOut,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1038
frontend/src/pages/skills/skillsPage.css
Normal file
1038
frontend/src/pages/skills/skillsPage.css
Normal file
File diff suppressed because it is too large
Load Diff
296
frontend/src/pages/skills/skillsPage.test.ts
Normal file
296
frontend/src/pages/skills/skillsPage.test.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
bootstrapBuiltin: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/skill', () => ({
|
||||
skillApi: {
|
||||
list: mocks.list,
|
||||
create: mocks.create,
|
||||
update: mocks.update,
|
||||
delete: mocks.delete,
|
||||
bootstrapBuiltin: mocks.bootstrapBuiltin,
|
||||
},
|
||||
}))
|
||||
|
||||
import SkillsPage from './index.vue'
|
||||
|
||||
const skillFixtures = [
|
||||
{
|
||||
id: 'skill-schedule-1',
|
||||
name: 'Priority Router',
|
||||
description: 'Aligns planner priorities.',
|
||||
instructions: 'Prioritize schedule risks.',
|
||||
agent_type: 'schedule_planner',
|
||||
tools: ['calendar', 'tasks'],
|
||||
required_context: [],
|
||||
output_format: null,
|
||||
visibility: 'private' as const,
|
||||
is_builtin: true,
|
||||
team_id: null,
|
||||
is_active: true,
|
||||
owner_id: 'user-1',
|
||||
created_at: '2026-03-26T00:00:00Z',
|
||||
updated_at: '2026-03-26T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'skill-analyst-1',
|
||||
name: 'Trend Lens',
|
||||
description: 'Summarizes movement signals.',
|
||||
instructions: 'Summarize the latest trend shifts.',
|
||||
agent_type: 'analyst',
|
||||
tools: ['database'],
|
||||
required_context: [],
|
||||
output_format: null,
|
||||
visibility: 'team' as const,
|
||||
is_builtin: false,
|
||||
team_id: null,
|
||||
is_active: true,
|
||||
owner_id: 'user-1',
|
||||
created_at: '2026-03-26T00:00:00Z',
|
||||
updated_at: '2026-03-26T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
describe('skills page ability center', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.list.mockResolvedValue({ data: skillFixtures })
|
||||
mocks.create.mockResolvedValue({ data: skillFixtures[0] })
|
||||
mocks.update.mockResolvedValue({ data: skillFixtures[0] })
|
||||
mocks.delete.mockResolvedValue({})
|
||||
mocks.bootstrapBuiltin.mockResolvedValue({ data: skillFixtures })
|
||||
vi.stubGlobal('navigator', {
|
||||
clipboard: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
})
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
vi.stubGlobal('matchMedia', vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})))
|
||||
})
|
||||
|
||||
it('renders the ability center title and preserves the create skill action', async () => {
|
||||
const wrapper = mount(SkillsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(wrapper.get('[data-testid="skills-page-title"]').text()).toContain('能力中心')
|
||||
expect(wrapper.text()).not.toContain('技能中心')
|
||||
expect(wrapper.get('[data-testid="skills-create-button"]').text()).toContain('新建技能')
|
||||
})
|
||||
|
||||
it('removes the legacy refresh action from the header', async () => {
|
||||
const wrapper = mount(SkillsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(wrapper.find('[data-testid="skills-refresh-button"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows an MCP action in the header', async () => {
|
||||
const wrapper = mount(SkillsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(wrapper.get('[data-testid="skills-mcp-button"]').text()).toContain('MCP')
|
||||
expect(wrapper.find('[data-testid="skills-create-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows builtin badges in the table without the legacy overview chips', async () => {
|
||||
const wrapper = mount(SkillsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(wrapper.find('[data-testid="skills-overview"]').exists()).toBe(false)
|
||||
|
||||
const builtinBadges = wrapper.findAll('[data-testid="builtin-skill-badge"]')
|
||||
expect(builtinBadges).toHaveLength(1)
|
||||
expect(builtinBadges[0].text()).toContain('内置')
|
||||
expect(wrapper.text()).toContain('Priority Router')
|
||||
expect(wrapper.text()).toContain('Trend Lens')
|
||||
})
|
||||
|
||||
it('renders the new table-first console layout with the detail drawer hidden by default', async () => {
|
||||
const wrapper = mount(SkillsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(wrapper.find('[data-testid="skills-table"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="skills-detail-panel"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-testid="skills-metrics-strip"]').text()).toContain('TOTAL')
|
||||
expect(wrapper.text()).not.toContain('ABILITY TABLE')
|
||||
expect(wrapper.text()).not.toContain('rows')
|
||||
expect(wrapper.find('.panel-title').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('filters rows from the search input', async () => {
|
||||
const wrapper = mount(SkillsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="skills-search-input"]').setValue('Trend')
|
||||
|
||||
expect(wrapper.find('[data-testid="skills-table-row-skill-schedule-1"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-testid="skills-table-row-skill-analyst-1"]').text()).toContain('Trend Lens')
|
||||
})
|
||||
|
||||
it('opens the detail drawer when a row is activated', async () => {
|
||||
const wrapper = mount(SkillsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="skills-table-row-skill-analyst-1"]').trigger('click')
|
||||
|
||||
expect(wrapper.get('[data-testid="skills-detail-panel"]').text()).toContain('Trend Lens')
|
||||
expect(wrapper.get('[data-testid="skills-detail-panel"]').text()).toContain('Summarizes movement signals.')
|
||||
})
|
||||
|
||||
it('closes the detail drawer from the close button', async () => {
|
||||
const wrapper = mount(SkillsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="skills-table-row-skill-analyst-1"]').trigger('click')
|
||||
await wrapper.get('[aria-label="关闭详情滑窗"]').trigger('click')
|
||||
|
||||
expect(wrapper.find('[data-testid="skills-detail-panel"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the selected drawer record stable when filters hide its row', async () => {
|
||||
const wrapper = mount(SkillsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="skills-table-row-skill-analyst-1"]').trigger('click')
|
||||
await wrapper.get('[data-testid="skills-search-input"]').setValue('Priority')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="skills-table-row-skill-analyst-1"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-testid="skills-detail-panel"]').text()).toContain('Trend Lens')
|
||||
})
|
||||
|
||||
it('renders the detail drawer as an accessible dialog and closes on escape', async () => {
|
||||
const wrapper = mount(SkillsPage, { attachTo: document.body })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="skills-table-row-skill-analyst-1"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const panel = wrapper.get('[data-testid="skills-detail-panel"]')
|
||||
expect(panel.attributes('role')).toBe('dialog')
|
||||
expect(panel.attributes('aria-modal')).toBe('true')
|
||||
|
||||
await panel.trigger('keydown', { key: 'Escape' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="skills-detail-panel"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('opens and closes the drawer without animation when reduced motion is enabled', async () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn().mockImplementation(() => ({
|
||||
matches: true,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})))
|
||||
|
||||
const wrapper = mount(SkillsPage, { attachTo: document.body })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="skills-table-row-skill-analyst-1"]').trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.find('[data-testid="skills-detail-panel"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.get('[aria-label="关闭详情滑窗"]').trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.find('[data-testid="skills-detail-panel"]').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('bootstraps builtin skills when the first list is empty', async () => {
|
||||
mocks.list.mockResolvedValueOnce({ data: [] })
|
||||
mocks.bootstrapBuiltin.mockResolvedValueOnce({ data: skillFixtures })
|
||||
|
||||
const wrapper = mount(SkillsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mocks.bootstrapBuiltin).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('Priority Router')
|
||||
expect(wrapper.find('[data-testid="skills-overview"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('opens the MCP panel inside the skills page when MCP is clicked', async () => {
|
||||
const wrapper = mount(SkillsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(wrapper.find('[data-testid="skills-mcp-panel"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.get('[data-testid="skills-mcp-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.get('[data-testid="skills-mcp-panel"]').text()).toContain('MCP 工具能力包')
|
||||
expect(wrapper.get('[data-testid="skills-page-title"]').text()).toContain('能力中心')
|
||||
})
|
||||
|
||||
it('submits schedule_planner agent type when creating a skill', async () => {
|
||||
const wrapper = mount(SkillsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="skills-create-button"]').trigger('click')
|
||||
await wrapper.get('[data-testid="skill-agent-type-select"]').setValue('schedule_planner')
|
||||
await wrapper.get('[data-testid="skill-name-input"]').setValue('Planner Skill')
|
||||
await wrapper.get('[data-testid="skill-instructions-input"]').setValue('Prioritize schedule risks.')
|
||||
await wrapper.get('[data-testid="skill-save-button"]').trigger('click')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ agent_type: 'schedule_planner' }))
|
||||
})
|
||||
|
||||
it('keeps the create skill modal working after the MCP action is added', async () => {
|
||||
const wrapper = mount(SkillsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
await wrapper.get('[data-testid="skills-create-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.get('[data-testid="skills-skill-modal"]').text()).toContain('新建技能')
|
||||
expect(wrapper.find('[data-testid="skills-mcp-panel"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('marks the table region as the internal scroll container', async () => {
|
||||
const wrapper = mount(SkillsPage)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
const tableWrap = wrapper.get('[data-testid="skills-table-wrap"]')
|
||||
expect(tableWrap.classes()).toContain('skills-table-wrap')
|
||||
expect(wrapper.find('.table-viewport').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,563 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import * as statsApi from '@/api/stats'
|
||||
import { Cpu, HardDrive, MemoryStick, Clock, TrendingUp, Tag } from 'lucide-vue-next'
|
||||
|
||||
const reloadPage = () => globalThis.location.reload()
|
||||
import SectionHeader from '@/components/stats/SectionHeader.vue'
|
||||
import MetricCard from '@/components/stats/MetricCard.vue'
|
||||
import MiniLineChart from '@/components/stats/MiniLineChart.vue'
|
||||
import MiniBarChart from '@/components/stats/MiniBarChart.vue'
|
||||
|
||||
type DailyPoint = { date: string; count: number }
|
||||
type HourlyPoint = { hour: number; count: number }
|
||||
|
||||
const isLoading = ref(true)
|
||||
const hasError = ref(false)
|
||||
|
||||
// 数据状态
|
||||
const systemHealth = ref<any>(null)
|
||||
const conversationStats = ref<any>(null)
|
||||
const knowledgeStats = ref<any>(null)
|
||||
const kanbanStats = ref<any>(null)
|
||||
const communityStats = ref<any>(null)
|
||||
const personalInsights = ref<any>(null)
|
||||
|
||||
function formatUptime(seconds: number) {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
if (hours > 0) return `${hours}h ${mins}m`
|
||||
return `${mins}m`
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 系统健康不需要认证
|
||||
const sys = await statsApi.getSystemHealth().catch(() => null)
|
||||
systemHealth.value = sys?.data || null
|
||||
|
||||
// 用户相关数据需要认证
|
||||
const [conv, know, kanban, community, insights] = await Promise.all([
|
||||
statsApi.getConversationStats().catch(() => null),
|
||||
statsApi.getKnowledgeStats().catch(() => null),
|
||||
statsApi.getKanbanStats().catch(() => null),
|
||||
statsApi.getCommunityStats().catch(() => null),
|
||||
statsApi.getPersonalInsights().catch(() => null),
|
||||
])
|
||||
conversationStats.value = conv?.data || null
|
||||
knowledgeStats.value = know?.data || null
|
||||
kanbanStats.value = kanban?.data || null
|
||||
communityStats.value = community?.data || null
|
||||
personalInsights.value = insights?.data || null
|
||||
} catch (e) {
|
||||
hasError.value = true
|
||||
console.error('Failed to load stats:', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// 图表数据转换
|
||||
const convChartData = computed(() =>
|
||||
conversationStats.value?.daily_conversations?.map((d: DailyPoint) => ({ date: d.date, value: d.count })) || []
|
||||
)
|
||||
|
||||
const knowChartData = computed(() =>
|
||||
knowledgeStats.value?.daily_new_tags?.map((d: DailyPoint) => ({ date: d.date, value: d.count })) || []
|
||||
)
|
||||
|
||||
const kanbanNewData = computed(() =>
|
||||
kanbanStats.value?.daily_new_tasks?.map((d: DailyPoint) => d.count) || []
|
||||
)
|
||||
const kanbanDoneData = computed(() =>
|
||||
kanbanStats.value?.daily_completed_tasks?.map((d: DailyPoint) => d.count) || []
|
||||
)
|
||||
|
||||
const communityChartData = computed(() =>
|
||||
communityStats.value?.daily_posts?.map((d: DailyPoint) => ({ date: d.date, value: d.count })) || []
|
||||
)
|
||||
|
||||
const hourlyActivityData = computed(() =>
|
||||
personalInsights.value?.hourly_activity?.map((h: HourlyPoint) => h.count) || []
|
||||
)
|
||||
|
||||
const convBarValues = computed(() => convChartData.value.map((d: { date: string; value: number }) => d.value))
|
||||
const knowBarValues = computed(() => knowChartData.value.map((d: { date: string; value: number }) => d.value))
|
||||
const communityBarValues = computed(() => communityChartData.value.map((d: { date: string; value: number }) => d.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats-view">
|
||||
<div class="stats-header">
|
||||
<h1>// 运行状态</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>Loading metrics...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasError" class="error-state">
|
||||
<span>Failed to load stats</span>
|
||||
<button @click="reloadPage">Refresh</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="stats-content">
|
||||
<!-- SYSTEM HEALTH -->
|
||||
<section class="stats-section">
|
||||
<SectionHeader title="SYSTEM HEALTH" tag="cyan" />
|
||||
<div class="metrics-grid">
|
||||
<MetricCard
|
||||
:icon="Cpu"
|
||||
label="CPU Usage"
|
||||
:value="systemHealth ? systemHealth.cpu_percent + '%' : '--'"
|
||||
accentColor="var(--accent-cyan)"
|
||||
/>
|
||||
<MetricCard
|
||||
:icon="MemoryStick"
|
||||
label="Memory"
|
||||
:value="systemHealth ? systemHealth.memory_percent + '%' : '--'"
|
||||
accentColor="var(--accent-purple)"
|
||||
/>
|
||||
<MetricCard
|
||||
:icon="HardDrive"
|
||||
label="Disk"
|
||||
:value="systemHealth ? systemHealth.disk_percent + '%' : '--'"
|
||||
accentColor="var(--accent-amber)"
|
||||
/>
|
||||
<MetricCard
|
||||
:icon="Clock"
|
||||
label="Uptime"
|
||||
:value="systemHealth ? formatUptime(systemHealth.uptime_seconds) : '--'"
|
||||
accentColor="var(--accent-green)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CONVERSATIONS -->
|
||||
<section class="stats-section">
|
||||
<SectionHeader title="沟通系统" tag="cyan" />
|
||||
<div class="stats-metrics-grid-4">
|
||||
<div class="stat-bar-item">
|
||||
<div class="stat-bar-label">对话数</div>
|
||||
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.conversations || 0) }}</div>
|
||||
<div class="stat-bar-chart">
|
||||
<MiniBarChart v-if="convChartData.length > 0" :data="convBarValues" color="var(--accent-cyan)" :height="30" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-bar-item">
|
||||
<div class="stat-bar-label">消息数</div>
|
||||
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.messages || 0) }}</div>
|
||||
<div class="stat-bar-chart">
|
||||
<MiniBarChart v-if="convChartData.length > 0" :data="convBarValues" color="var(--accent-purple)" :height="30" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-bar-item">
|
||||
<div class="stat-bar-label">输入Token</div>
|
||||
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.input_tokens || 0) }}</div>
|
||||
<div class="stat-bar-chart">
|
||||
<MiniLineChart v-if="convChartData.length > 0" :data="convChartData" color="var(--accent-amber)" :height="30" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-bar-item">
|
||||
<div class="stat-bar-label">输出Token</div>
|
||||
<div class="stat-bar-value">{{ formatNumber(conversationStats?.totals?.output_tokens || 0) }}</div>
|
||||
<div class="stat-bar-chart">
|
||||
<MiniLineChart v-if="convChartData.length > 0" :data="convChartData" color="var(--accent-green)" :height="30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- KNOWLEDGE -->
|
||||
<section class="stats-section">
|
||||
<SectionHeader title="知识库" tag="purple" />
|
||||
<div class="stats-metrics-row">
|
||||
<div class="stat-bar-item">
|
||||
<div class="stat-bar-label">新标签</div>
|
||||
<div class="stat-bar-value">{{ formatNumber(knowledgeStats?.totals?.new_tags || 0) }}</div>
|
||||
<div class="stat-bar-chart">
|
||||
<MiniBarChart v-if="knowChartData.length > 0" :data="knowBarValues" color="var(--accent-purple)" :height="30" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-bar-item">
|
||||
<div class="stat-bar-label">文档数</div>
|
||||
<div class="stat-bar-value">{{ formatNumber(knowledgeStats?.totals?.documents || 0) }}</div>
|
||||
<div class="stat-bar-chart">
|
||||
<MiniBarChart v-if="knowChartData.length > 0" :data="knowBarValues" color="var(--accent-cyan)" :height="30" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-bar-item">
|
||||
<div class="stat-bar-label">标签关联</div>
|
||||
<div class="stat-bar-value">{{ formatNumber(knowledgeStats?.totals?.tag_relations || 0) }}</div>
|
||||
<div class="stat-bar-chart">
|
||||
<MiniLineChart v-if="knowChartData.length > 0" :data="knowChartData" color="var(--accent-amber)" :height="30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- KANBAN -->
|
||||
<section class="stats-section">
|
||||
<SectionHeader title="任务矩阵" tag="cyan" />
|
||||
<div class="stats-metrics-row">
|
||||
<div class="stat-bar-item">
|
||||
<div class="stat-bar-label">待处理</div>
|
||||
<div class="stat-bar-value">{{ kanbanStats?.current_pending_tasks || 0 }}</div>
|
||||
<div class="stat-bar-chart">
|
||||
<MiniBarChart :data="kanbanNewData" color="var(--accent-amber)" :height="30" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-bar-item">
|
||||
<div class="stat-bar-label">新建(30天)</div>
|
||||
<div class="stat-bar-value">{{ formatNumber(kanbanStats?.totals?.new_tasks || 0) }}</div>
|
||||
<div class="stat-bar-chart">
|
||||
<MiniBarChart :data="kanbanNewData" color="var(--accent-cyan)" :height="30" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-bar-item">
|
||||
<div class="stat-bar-label">完成(30天)</div>
|
||||
<div class="stat-bar-value">{{ formatNumber(kanbanStats?.totals?.completed_tasks || 0) }}</div>
|
||||
<div class="stat-bar-chart">
|
||||
<MiniBarChart :data="kanbanDoneData" color="var(--accent-green)" :height="30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- COMMUNITY -->
|
||||
<section class="stats-section">
|
||||
<SectionHeader title="信息交易所" tag="amber" />
|
||||
<div class="stats-metrics-row">
|
||||
<div class="stat-bar-item">
|
||||
<div class="stat-bar-label">帖子数</div>
|
||||
<div class="stat-bar-value">{{ formatNumber(communityStats?.totals?.posts || 0) }}</div>
|
||||
<div class="stat-bar-chart">
|
||||
<MiniBarChart v-if="communityChartData.length > 0" :data="communityBarValues" color="var(--accent-amber)" :height="30" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-bar-item">
|
||||
<div class="stat-bar-label">回复数</div>
|
||||
<div class="stat-bar-value">{{ formatNumber(communityStats?.totals?.replies || 0) }}</div>
|
||||
<div class="stat-bar-chart">
|
||||
<MiniBarChart v-if="communityChartData.length > 0" :data="communityBarValues" color="var(--accent-purple)" :height="30" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-bar-item">
|
||||
<div class="stat-bar-label">AI执行</div>
|
||||
<div class="stat-bar-value">{{ formatNumber(communityStats?.totals?.ai_executions || 0) }}</div>
|
||||
<div class="stat-bar-chart">
|
||||
<MiniLineChart v-if="communityChartData.length > 0" :data="communityChartData" color="var(--accent-cyan)" :height="30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- INSIGHTS -->
|
||||
<section class="stats-section">
|
||||
<SectionHeader title="个人洞察" tag="cyan" />
|
||||
<div class="insights-grid" v-if="personalInsights">
|
||||
<div class="insight-card">
|
||||
<h4>Hourly Activity</h4>
|
||||
<MiniBarChart
|
||||
v-if="hourlyActivityData.length > 0"
|
||||
:data="hourlyActivityData"
|
||||
color="var(--accent-cyan)"
|
||||
:height="80"
|
||||
:maxBars="24"
|
||||
/>
|
||||
<div v-else class="empty-state small">No activity data</div>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<h4>Top Tags</h4>
|
||||
<ul class="tag-list" v-if="personalInsights.top_tags?.length">
|
||||
<li v-for="tag in personalInsights.top_tags" :key="tag.tag_path">
|
||||
<Tag :size="12" />
|
||||
<span class="tag-name">{{ tag.tag_path }}</span>
|
||||
<span class="tag-count">{{ tag.usage_count }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="empty-state small">No tags yet</div>
|
||||
</div>
|
||||
<div class="insight-card">
|
||||
<h4>Token Trend</h4>
|
||||
<div class="token-trend">
|
||||
<span class="trend-value" :class="personalInsights.token_trend_percent > 0 ? 'up' : 'down'">
|
||||
<TrendingUp :size="16" />
|
||||
{{ personalInsights.token_trend_percent }}%
|
||||
</span>
|
||||
<span class="trend-label">vs last month</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<span>Login to see personal insights</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stats-view {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stats-header h1 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.stats-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stats-metrics-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-metrics-grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-bar-item {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat-bar-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-bar-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-bar-chart {
|
||||
margin-top: 8px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
background: rgba(10, 15, 26, 0.6);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.metrics-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.metrics-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.chart-box {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bar-chart-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.insights-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.insights-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.insights-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.insight-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.insight-card h4 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tag-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
flex: 1;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-count {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent-cyan);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.token-trend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.trend-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.trend-value.up {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.trend-value.down {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.trend-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border-dim);
|
||||
border-top-color: var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-state.small {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent-cyan-dim);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: rgba(0, 245, 212, 0.2);
|
||||
box-shadow: var(--glow-cyan);
|
||||
}
|
||||
</style>
|
||||
@@ -1,419 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { todoApi, type Todo } from '@/api/todo'
|
||||
import { CheckSquare, Plus, Sparkles, Calendar } from 'lucide-vue-next'
|
||||
import { animate } from 'motion'
|
||||
|
||||
// 状态
|
||||
const selectedDate = ref(new Date().toISOString().slice(0, 10)) // YYYY-MM-DD
|
||||
const todos = ref<Todo[]>([])
|
||||
const loading = ref(false)
|
||||
const generating = ref(false)
|
||||
const newTitle = ref('')
|
||||
|
||||
const isToday = computed(() => selectedDate.value === new Date().toISOString().slice(0, 10))
|
||||
|
||||
// 日期快捷切换
|
||||
function formatDate(date: Date) {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
selectedDate.value = formatDate(new Date())
|
||||
}
|
||||
|
||||
function goYesterday() {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
selectedDate.value = formatDate(d)
|
||||
}
|
||||
|
||||
function goBeforeYesterday() {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 2)
|
||||
selectedDate.value = formatDate(d)
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadTodos() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await todoApi.list(selectedDate.value)
|
||||
todos.value = res.data.items
|
||||
} catch (e) {
|
||||
console.error('加载待办失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 新增
|
||||
async function addTodo() {
|
||||
if (!newTitle.value.trim()) return
|
||||
try {
|
||||
const res = await todoApi.create(newTitle.value.trim())
|
||||
todos.value.unshift(res.data)
|
||||
newTitle.value = ''
|
||||
} catch (e) {
|
||||
console.error('创建待办失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换完成状态
|
||||
async function toggleComplete(todo: Todo) {
|
||||
if (!isToday.value) return
|
||||
try {
|
||||
const res = await todoApi.update(todo.id, { is_completed: !todo.is_completed })
|
||||
const idx = todos.value.findIndex(t => t.id === todo.id)
|
||||
if (idx !== -1) {
|
||||
todos.value[idx] = res.data
|
||||
// 播放动画
|
||||
const el = document.querySelector(`[data-todo-id="${todo.id}"]`)
|
||||
if (el) {
|
||||
animate(el, { opacity: [0.5, 1] }, { duration: 0.3 }).play()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('更新待办失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
async function deleteTodo(id: string) {
|
||||
if (!isToday.value) return
|
||||
try {
|
||||
await todoApi.delete(id)
|
||||
todos.value = todos.value.filter(t => t.id !== id)
|
||||
} catch (e) {
|
||||
console.error('删除待办失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// AI 生成
|
||||
async function aiGenerate() {
|
||||
generating.value = true
|
||||
try {
|
||||
const res = await todoApi.aiGenerate()
|
||||
todos.value = res.data.items
|
||||
} catch (e) {
|
||||
console.error('AI 生成失败', e)
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听日期变化
|
||||
watch(selectedDate, () => {
|
||||
loadTodos()
|
||||
})
|
||||
|
||||
onMounted(loadTodos)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="todo-view scanlines">
|
||||
<!-- 背景 -->
|
||||
<div class="bg-grid"></div>
|
||||
<div class="bg-glow"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="view-header">
|
||||
<div class="header-title">
|
||||
<CheckSquare :size="16" />
|
||||
<span class="title-bracket">[</span>
|
||||
<span class="title-text">DAILY TODO</span>
|
||||
<span class="title-bracket">]</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div v-if="isToday" class="ai-btn" @click="aiGenerate" :class="{ loading: generating }">
|
||||
<Sparkles :size="14" :class="{ 'ai-spin': generating }" />
|
||||
<span>{{ generating ? '生成中...' : 'AI 规划今日' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日期导航 -->
|
||||
<div class="date-nav">
|
||||
<button class="date-btn" :class="{ active: !isToday }" @click="goBeforeYesterday">前天</button>
|
||||
<button class="date-btn" @click="goYesterday">昨天</button>
|
||||
<button class="date-btn primary" :class="{ active: isToday }" @click="goToday">
|
||||
今天
|
||||
<Calendar :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<div class="todo-content">
|
||||
<!-- 今日:新增输入框 -->
|
||||
<div v-if="isToday" class="add-form">
|
||||
<input
|
||||
v-model="newTitle"
|
||||
class="add-input"
|
||||
placeholder="输入待办事项,按回车添加..."
|
||||
@keyup.enter="addTodo"
|
||||
/>
|
||||
<button class="add-btn" @click="addTodo">
|
||||
<Plus :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 待办列表 -->
|
||||
<div class="todo-list">
|
||||
<div
|
||||
v-for="todo in todos"
|
||||
:key="todo.id"
|
||||
:data-todo-id="todo.id"
|
||||
class="todo-item"
|
||||
:class="{ completed: todo.is_completed, 'ai-source': todo.source !== 'manual' }"
|
||||
>
|
||||
<button class="check-btn" @click="toggleComplete(todo)" :disabled="!isToday">
|
||||
<span class="check-box" :class="{ checked: todo.is_completed }">
|
||||
<span v-if="todo.is_completed" class="check-mark">✓</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="todo-content">
|
||||
<span class="todo-title">{{ todo.title }}</span>
|
||||
<span v-if="todo.source_detail" class="todo-source">{{ todo.source_detail }}</span>
|
||||
</div>
|
||||
<button v-if="isToday" class="del-btn" @click="deleteTodo(todo.id)">
|
||||
<span>×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!loading && todos.length === 0" class="empty-state">
|
||||
<span class="empty-icon">[ ]</span>
|
||||
<span class="empty-text">{{ isToday ? '今日待办为空,点击上方新增' : '该日无待办记录' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<span class="loading-text">LOADING...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.todo-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-void);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,245,212,0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,245,212,0.04) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.bg-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0,245,212,0.05) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 24px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
background: rgba(5,8,16,0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.title-bracket { color: var(--accent-cyan); opacity: 0.6; }
|
||||
|
||||
.ai-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(249,168,37,0.08);
|
||||
border: 1px solid rgba(249,168,37,0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent-amber);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.ai-btn:hover { background: rgba(249,168,37,0.15); border-color: var(--accent-amber); box-shadow: 0 0 12px rgba(249,168,37,0.2); }
|
||||
.ai-btn.loading { opacity: 0.7; cursor: not-allowed; }
|
||||
.ai-spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 日期导航 */
|
||||
.date-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
.date-btn {
|
||||
padding: 5px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.date-btn:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); }
|
||||
.date-btn.active { border-color: var(--accent-cyan); color: var(--accent-cyan); background: rgba(0,245,212,0.08); }
|
||||
.date-btn.primary { font-weight: 600; }
|
||||
|
||||
/* 内容区 */
|
||||
.todo-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.add-input {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.add-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 2px rgba(0,245,212,0.1);
|
||||
}
|
||||
.add-input::placeholder { color: var(--text-dim); }
|
||||
|
||||
.add-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0,245,212,0.08);
|
||||
border: 1px solid rgba(0,245,212,0.3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--accent-cyan);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.add-btn:hover { background: rgba(0,245,212,0.15); box-shadow: var(--glow-cyan); }
|
||||
|
||||
/* 待办列表 */
|
||||
.todo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.todo-item:hover { border-color: var(--border-mid); }
|
||||
.todo-item.ai-source { border-left: 2px solid var(--accent-amber); }
|
||||
.todo-item.completed { opacity: 0.5; }
|
||||
.todo-item.completed .todo-title { text-decoration: line-through; color: var(--text-dim); }
|
||||
|
||||
.check-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.check-btn:disabled { cursor: default; }
|
||||
.check-box {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.check-box.checked { background: var(--accent-cyan); border-color: var(--accent-cyan); }
|
||||
.check-mark { color: var(--bg-void); font-size: 12px; font-weight: bold; }
|
||||
|
||||
.todo-content { flex: 1; min-width: 0; }
|
||||
.todo-title { display: block; font-size: 13px; color: var(--text-primary); font-family: var(--font-mono); }
|
||||
.todo-source { display: block; font-size: 10px; color: var(--text-dim); margin-top: 3px; font-family: var(--font-mono); }
|
||||
|
||||
.del-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
border-radius: 4px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.del-btn:hover { color: var(--accent-red); background: rgba(255,71,87,0.1); }
|
||||
|
||||
/* 空/加载状态 */
|
||||
.empty-state, .loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
.empty-icon { font-family: var(--font-mono); font-size: 32px; color: var(--text-dim); opacity: 0.3; }
|
||||
.empty-text { font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); letter-spacing: 0.1em; }
|
||||
.loading-text { font-family: var(--font-mono); font-size: 11px; color: var(--accent-cyan); letter-spacing: 0.2em; animation: pulse 1s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user