feat: enhance agent orchestration, knowledge flow and UI refinements

This commit is contained in:
2026-03-29 20:31:13 +08:00
parent d85cb9cf35
commit e0fe3ca623
301 changed files with 1197804 additions and 7863 deletions

View 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); }

View File

@@ -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('暂无可关联技能')
})
})

View 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

View File

@@ -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>

View File

@@ -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"')
})
})

View File

@@ -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')
})
})

View File

@@ -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>

View 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)
})
})

View File

@@ -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('已经帮你建好提醒。')
})
})

View File

@@ -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

View File

@@ -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>

View 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;
}
}

View 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>

View 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>

View 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

View File

@@ -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,
}
}

View 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)
})
})

View 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>

View 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;
}
}

View File

@@ -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,

View 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

File diff suppressed because it is too large Load Diff

View 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)
})
})

View File

@@ -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>

View File

@@ -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">&#10003;</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>&#215;</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>