mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add bounded local web console
This commit is contained in:
@@ -0,0 +1,795 @@
|
||||
:root {
|
||||
--fog: #e8eeef;
|
||||
--paper: #f8fbfa;
|
||||
--ink: #14272c;
|
||||
--deep: #19353d;
|
||||
--deep-soft: #254750;
|
||||
--signal: #009fb4;
|
||||
--signal-soft: #c8edf0;
|
||||
--amber: #e3972f;
|
||||
--danger: #ba4a43;
|
||||
--line: #b9c8ca;
|
||||
--muted: #64777b;
|
||||
--white: #ffffff;
|
||||
--shadow: 0 18px 60px rgba(15, 45, 53, 0.12);
|
||||
font-family: 'Avenir Next', Avenir, 'Segoe UI', system-ui, sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--fog);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(rgba(20, 39, 44, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(20, 39, 44, 0.035) 1px, transparent 1px),
|
||||
var(--fog);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 3px solid var(--amber);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
padding: 10px 14px;
|
||||
color: var(--white);
|
||||
background: var(--deep);
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 22vw) 1fr;
|
||||
}
|
||||
|
||||
.gate {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
min-height: 100vh;
|
||||
padding: 28px clamp(24px, 3vw, 44px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--white);
|
||||
background: var(--deep);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-items: end;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.brand-mark i {
|
||||
display: block;
|
||||
background: var(--signal);
|
||||
}
|
||||
|
||||
.brand-mark i:nth-child(1) {
|
||||
height: 42%;
|
||||
}
|
||||
.brand-mark i:nth-child(2) {
|
||||
height: 100%;
|
||||
}
|
||||
.brand-mark i:nth-child(3) {
|
||||
height: 68%;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-version {
|
||||
margin-left: auto;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
font: 600 11px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.gate-copy {
|
||||
margin: clamp(56px, 11vh, 104px) 0 32px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
color: var(--signal);
|
||||
font: 700 11px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gate-copy h1 {
|
||||
margin: 0;
|
||||
font-family: 'Avenir Next Condensed', 'Arial Narrow', 'Segoe UI', sans-serif;
|
||||
font-size: clamp(42px, 5.4vw, 70px);
|
||||
font-weight: 600;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.gate-copy > p:last-child {
|
||||
max-width: 27ch;
|
||||
margin: 24px 0 0;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.connection-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.16);
|
||||
font: 650 12px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
.signal {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--amber);
|
||||
box-shadow: 0 0 0 4px rgba(227, 151, 47, 0.16);
|
||||
}
|
||||
|
||||
.connection-state[data-state='connected'] .signal {
|
||||
background: var(--signal);
|
||||
box-shadow: 0 0 0 4px rgba(0, 159, 180, 0.18);
|
||||
}
|
||||
|
||||
.connection-state[data-state='error'] .signal {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 4px rgba(186, 74, 67, 0.18);
|
||||
}
|
||||
|
||||
.credential-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.credential-form label {
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font: 700 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.credential-form input {
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
color: var(--white);
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
border-radius: 0;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
font: 500 13px/1.3 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
.credential-form input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.32);
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.quiet-button,
|
||||
.danger-button,
|
||||
.refresh-button,
|
||||
.action-button {
|
||||
min-height: 40px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
margin-top: 4px;
|
||||
color: var(--deep);
|
||||
background: var(--signal);
|
||||
}
|
||||
|
||||
.quiet-button {
|
||||
color: inherit;
|
||||
border-color: currentColor;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
color: var(--white);
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.privacy-note {
|
||||
margin: 4px 0 0;
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.section-nav {
|
||||
margin-top: 28px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.section-nav button {
|
||||
width: 100%;
|
||||
padding: 14px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section-nav button[aria-current='page'] {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.section-nav button[aria-current='page'] span::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 3px;
|
||||
margin: 0 10px 3px 0;
|
||||
background: var(--signal);
|
||||
}
|
||||
|
||||
.section-nav kbd {
|
||||
color: inherit;
|
||||
font: 600 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
.gate-footer {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
font: 600 9px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
padding: clamp(28px, 5vw, 72px);
|
||||
}
|
||||
|
||||
.masthead {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.masthead h2 {
|
||||
margin: 0;
|
||||
font-family: 'Avenir Next Condensed', 'Arial Narrow', 'Segoe UI', sans-serif;
|
||||
font-size: clamp(34px, 4.2vw, 58px);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.masthead > div > p:last-child {
|
||||
max-width: 65ch;
|
||||
margin: 14px 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
flex: 0 0 auto;
|
||||
color: var(--deep);
|
||||
border-color: var(--line);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(280px, 0.85fr);
|
||||
min-height: 620px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(248, 251, 250, 0.82);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.ledger,
|
||||
.detail {
|
||||
min-width: 0;
|
||||
padding: clamp(20px, 3vw, 34px);
|
||||
}
|
||||
|
||||
.ledger {
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.detail {
|
||||
background: rgba(255, 255, 255, 0.46);
|
||||
}
|
||||
|
||||
.welcome-state,
|
||||
.detail-empty,
|
||||
.empty-state,
|
||||
.loading-state,
|
||||
.error-state {
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-index {
|
||||
color: var(--signal);
|
||||
font: 800 clamp(46px, 8vw, 100px) / 0.85 'Avenir Next Condensed',
|
||||
'Arial Narrow', sans-serif;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.welcome-state p {
|
||||
margin: 28px 0;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.welcome-rule {
|
||||
width: 180px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.welcome-rule i {
|
||||
height: 4px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.welcome-rule i:nth-child(2) {
|
||||
background: var(--signal);
|
||||
}
|
||||
|
||||
.detail-glyph {
|
||||
color: var(--signal);
|
||||
font-size: 54px;
|
||||
}
|
||||
|
||||
.detail-empty p,
|
||||
.empty-state p,
|
||||
.error-state p {
|
||||
max-width: 30ch;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
margin-bottom: 18px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
border-bottom: 2px solid var(--ink);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.list-header strong {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.list-header span {
|
||||
color: var(--muted);
|
||||
font: 600 11px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.record {
|
||||
width: 100%;
|
||||
padding: 18px 4px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
color: var(--ink);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.record:hover,
|
||||
.record[aria-current='true'] {
|
||||
background: linear-gradient(90deg, var(--signal-soft), transparent 74%);
|
||||
}
|
||||
|
||||
.record-title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-weight: 750;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.record-meta {
|
||||
margin-top: 7px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px 14px;
|
||||
color: var(--muted);
|
||||
font: 550 11px/1.4 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
.record-side {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
align-content: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font: 750 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--signal);
|
||||
}
|
||||
|
||||
.status[data-tone='waiting']::before {
|
||||
background: var(--amber);
|
||||
}
|
||||
.status[data-tone='failed']::before {
|
||||
background: var(--danger);
|
||||
}
|
||||
.status[data-tone='quiet']::before {
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.record-time {
|
||||
color: var(--muted);
|
||||
font: 500 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid var(--ink);
|
||||
}
|
||||
|
||||
.detail-header h3 {
|
||||
margin: 5px 0 8px;
|
||||
font-family: 'Avenir Next Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 30px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-header code,
|
||||
.fact dd,
|
||||
.event-sequence {
|
||||
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
.detail-header code {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.facts {
|
||||
margin: 22px 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.fact {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--line);
|
||||
}
|
||||
|
||||
.fact dt {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fact dd {
|
||||
margin: 5px 0 0;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 22px 0;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
color: var(--deep);
|
||||
border-color: var(--deep);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.action-button[data-kind='danger'] {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.timeline-heading {
|
||||
margin: 30px 0 14px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.event-spine {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.event-spine::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
bottom: 7px;
|
||||
left: 16px;
|
||||
width: 2px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.event {
|
||||
position: relative;
|
||||
min-height: 50px;
|
||||
padding: 0 0 18px 46px;
|
||||
}
|
||||
|
||||
.event-sequence {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 34px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--deep);
|
||||
border: 2px solid var(--signal);
|
||||
background: var(--paper);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.event strong {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.event span:last-child {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font: 500 10px/1.3 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
.confirmation-dialog {
|
||||
width: min(460px, calc(100vw - 32px));
|
||||
padding: 0;
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0;
|
||||
background: var(--paper);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.confirmation-dialog::backdrop {
|
||||
background: rgba(11, 31, 36, 0.7);
|
||||
}
|
||||
|
||||
.confirmation-dialog form {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.confirmation-dialog h2 {
|
||||
margin: 0;
|
||||
font-family: 'Avenir Next Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.confirmation-dialog form > p:not(.eyebrow) {
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
margin-top: 26px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.confirmation-dialog .quiet-button {
|
||||
color: var(--deep);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
max-width: min(420px, calc(100vw - 48px));
|
||||
padding: 14px 18px;
|
||||
color: var(--white);
|
||||
background: var(--deep-soft);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toast[data-tone='error'] {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.loading-state span {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 3px solid var(--line);
|
||||
border-top-color: var(--signal);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.shell {
|
||||
grid-template-columns: 240px 1fr;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ledger {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.shell {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gate {
|
||||
position: relative;
|
||||
min-height: auto;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.gate-copy {
|
||||
margin: 42px 0 26px;
|
||||
}
|
||||
|
||||
.gate-copy h1 {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.gate-footer {
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 26px 18px 48px;
|
||||
}
|
||||
|
||||
.masthead {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.masthead h2 {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.welcome-state,
|
||||
.detail-empty,
|
||||
.empty-state,
|
||||
.loading-state,
|
||||
.error-state {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.record {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.record-side {
|
||||
justify-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const TOKEN_PATTERN =
|
||||
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
|
||||
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
created: '已创建',
|
||||
queued: '排队中',
|
||||
dispatching: '派发中',
|
||||
running: '运行中',
|
||||
cancel_requested: '正在取消',
|
||||
succeeded: '成功',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
timed_out: '已超时',
|
||||
});
|
||||
const ERROR_LABELS = Object.freeze({
|
||||
authentication_required: '凭据无效或已经失效。请断开连接后重新输入。',
|
||||
authorization_denied: '当前身份没有执行该操作的权限。',
|
||||
server_overloaded: '设备正在处理其他请求,请稍后刷新。',
|
||||
server_draining: '服务正在停止,暂不接受新请求。',
|
||||
task_list_unavailable: '任务列表暂时不可用。请检查本机服务与数据库。',
|
||||
task_query_unavailable: '任务详情暂时不可用。',
|
||||
run_list_unavailable: '运行列表暂时不可用。请检查本机服务与数据库。',
|
||||
run_query_unavailable: '运行详情暂时不可用。',
|
||||
run_event_list_unavailable: '运行事件暂时不可用。',
|
||||
run_step_list_unavailable: 'Workflow Step 暂时不可用。',
|
||||
task_start_fence_rejected:
|
||||
'任务在确认期间发生变化,本次启动已安全拒绝。请刷新后重试。',
|
||||
run_cancellation_fence_rejected:
|
||||
'运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。',
|
||||
request_unavailable: '本次请求没有完成,请确认服务仍在运行。',
|
||||
});
|
||||
|
||||
const nodes = Object.freeze({
|
||||
form: document.getElementById('credential-form'),
|
||||
project: document.getElementById('project-input'),
|
||||
token: document.getElementById('token-input'),
|
||||
disconnect: document.getElementById('disconnect-button'),
|
||||
connection: document.getElementById('connection-state'),
|
||||
connectionLabel: document.getElementById('connection-label'),
|
||||
nav: document.querySelector('.section-nav'),
|
||||
ledger: document.getElementById('ledger'),
|
||||
detail: document.getElementById('detail'),
|
||||
kicker: document.getElementById('section-kicker'),
|
||||
title: document.getElementById('section-title'),
|
||||
description: document.getElementById('section-description'),
|
||||
refresh: document.getElementById('refresh-button'),
|
||||
dialog: document.getElementById('confirmation-dialog'),
|
||||
dialogTitle: document.getElementById('confirmation-title'),
|
||||
dialogCopy: document.getElementById('confirmation-copy'),
|
||||
dialogAccept: document.getElementById('confirmation-accept'),
|
||||
toast: document.getElementById('toast'),
|
||||
});
|
||||
|
||||
const state = {
|
||||
token: null,
|
||||
project: 'default',
|
||||
view: 'tasks',
|
||||
selectedId: null,
|
||||
pendingAction: null,
|
||||
toastTimer: null,
|
||||
};
|
||||
|
||||
class ConsoleRequestError extends Error {
|
||||
constructor(code, status, requestId) {
|
||||
super(code);
|
||||
this.name = 'ConsoleRequestError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.requestId = requestId;
|
||||
}
|
||||
}
|
||||
|
||||
function element(tag, className, text) {
|
||||
const value = document.createElement(tag);
|
||||
if (className) value.className = className;
|
||||
if (text !== undefined) value.textContent = String(text);
|
||||
return value;
|
||||
}
|
||||
|
||||
function replace(node, ...children) {
|
||||
node.replaceChildren(...children);
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!Number.isSafeInteger(value) || value < 0) return '—';
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function shortDigest(value) {
|
||||
return typeof value === 'string' && value.length > 18
|
||||
? `${value.slice(0, 10)}…${value.slice(-6)}`
|
||||
: value || '—';
|
||||
}
|
||||
|
||||
function statusTone(status) {
|
||||
if (status === 'failed' || status === 'timed_out') return 'failed';
|
||||
if (
|
||||
status === 'created' ||
|
||||
status === 'queued' ||
|
||||
status === 'dispatching' ||
|
||||
status === 'cancel_requested'
|
||||
) {
|
||||
return 'waiting';
|
||||
}
|
||||
if (status === 'cancelled') return 'quiet';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const badge = element('span', 'status', STATUS_LABELS[status] || status);
|
||||
badge.dataset.tone = statusTone(status);
|
||||
return badge;
|
||||
}
|
||||
|
||||
function newMutationId() {
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = [...bytes].map((value) => value.toString(16).padStart(2, '0'));
|
||||
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex
|
||||
.slice(6, 8)
|
||||
.join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
if (!state.token) {
|
||||
throw new ConsoleRequestError('authentication_required', 401, null);
|
||||
}
|
||||
const headers = {
|
||||
accept: 'application/json',
|
||||
authorization: `Bearer ${state.token}`,
|
||||
};
|
||||
let body;
|
||||
if (options.body !== undefined) {
|
||||
body = JSON.stringify(options.body);
|
||||
headers['content-type'] = 'application/json';
|
||||
}
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
method: options.method || 'GET',
|
||||
headers,
|
||||
...(body === undefined ? {} : { body }),
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
referrerPolicy: 'no-referrer',
|
||||
});
|
||||
} catch {
|
||||
throw new ConsoleRequestError('request_unavailable', 503, null);
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
value = await response.json();
|
||||
} catch {
|
||||
throw new ConsoleRequestError(
|
||||
'response_unavailable',
|
||||
response.status,
|
||||
response.headers.get('x-request-id'),
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ConsoleRequestError(
|
||||
typeof value.code === 'string' ? value.code : 'request_unavailable',
|
||||
response.status,
|
||||
response.headers.get('x-request-id'),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function describeError(error) {
|
||||
if (!(error instanceof ConsoleRequestError))
|
||||
return '操作没有完成,请刷新后重试。';
|
||||
const label = ERROR_LABELS[error.code] || `请求被拒绝:${error.code}`;
|
||||
return error.requestId ? `${label} 请求编号 ${error.requestId}` : label;
|
||||
}
|
||||
|
||||
function showToast(message, tone = 'ok') {
|
||||
if (state.toastTimer) window.clearTimeout(state.toastTimer);
|
||||
nodes.toast.textContent = message;
|
||||
nodes.toast.dataset.tone = tone;
|
||||
nodes.toast.hidden = false;
|
||||
state.toastTimer = window.setTimeout(() => {
|
||||
nodes.toast.hidden = true;
|
||||
state.toastTimer = null;
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
function setConnection(mode, label) {
|
||||
nodes.connection.dataset.state = mode;
|
||||
nodes.connectionLabel.textContent = label;
|
||||
}
|
||||
|
||||
function setBusy(value) {
|
||||
nodes.ledger.setAttribute('aria-busy', String(value));
|
||||
nodes.refresh.disabled = value;
|
||||
}
|
||||
|
||||
function loading() {
|
||||
const box = element('div', 'loading-state');
|
||||
box.append(element('span'));
|
||||
replace(nodes.ledger, box);
|
||||
setBusy(true);
|
||||
}
|
||||
|
||||
function empty(message) {
|
||||
const box = element('div', 'empty-state');
|
||||
box.append(element('p', null, message));
|
||||
replace(nodes.ledger, box);
|
||||
}
|
||||
|
||||
function errorState(error) {
|
||||
const box = element('div', 'error-state');
|
||||
box.append(element('p', null, describeError(error)));
|
||||
const retry = element('button', 'action-button', '重新读取');
|
||||
retry.type = 'button';
|
||||
retry.addEventListener('click', () => refresh());
|
||||
box.append(retry);
|
||||
replace(nodes.ledger, box);
|
||||
setConnection('error', '连接需要检查');
|
||||
}
|
||||
|
||||
function detailEmpty(message = '选择一项,查看它的执行围栏与事件轨迹。') {
|
||||
const box = element('div', 'detail-empty');
|
||||
box.append(element('span', 'detail-glyph', '⌁'));
|
||||
box.append(element('p', null, message));
|
||||
replace(nodes.detail, box);
|
||||
}
|
||||
|
||||
function listHeader(label, count) {
|
||||
const header = element('div', 'list-header');
|
||||
header.append(element('strong', null, label));
|
||||
header.append(element('span', null, `${count} 条 / 当前窗口`));
|
||||
return header;
|
||||
}
|
||||
|
||||
function recordMeta(values) {
|
||||
const meta = element('span', 'record-meta');
|
||||
for (const value of values) meta.append(element('span', null, value));
|
||||
return meta;
|
||||
}
|
||||
|
||||
function fact(label, value) {
|
||||
const item = element('dl', 'fact');
|
||||
item.append(element('dt', null, label));
|
||||
item.append(element('dd', null, value));
|
||||
return item;
|
||||
}
|
||||
|
||||
function detailHeader(kicker, title, identity) {
|
||||
const header = element('header', 'detail-header');
|
||||
header.append(element('p', 'eyebrow', kicker));
|
||||
header.append(element('h3', null, title));
|
||||
header.append(element('code', null, identity));
|
||||
return header;
|
||||
}
|
||||
|
||||
function actionButton(label, handler, kind = 'normal') {
|
||||
const button = element('button', 'action-button', label);
|
||||
button.type = 'button';
|
||||
button.dataset.kind = kind;
|
||||
button.addEventListener('click', handler);
|
||||
return button;
|
||||
}
|
||||
|
||||
function confirmAction(title, copy, acceptLabel, action, dangerous = false) {
|
||||
state.pendingAction = action;
|
||||
nodes.dialogTitle.textContent = title;
|
||||
nodes.dialogCopy.textContent = copy;
|
||||
nodes.dialogAccept.textContent = acceptLabel;
|
||||
nodes.dialogAccept.className = dangerous
|
||||
? 'danger-button'
|
||||
: 'primary-button';
|
||||
nodes.dialog.returnValue = '';
|
||||
nodes.dialog.showModal();
|
||||
}
|
||||
|
||||
async function renderTasks() {
|
||||
const value = await api(`/api/v3/projects/${state.project}/tasks?limit=64`);
|
||||
const tasks = Array.isArray(value.tasks) ? value.tasks : [];
|
||||
if (tasks.length === 0) {
|
||||
empty('还没有可见任务。请先通过受信任的管理入口发布 Task。');
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(listHeader('Task switchboard', tasks.length));
|
||||
const list = element('div', 'record-list');
|
||||
for (const task of tasks) {
|
||||
const button = element('button', 'record');
|
||||
button.type = 'button';
|
||||
button.dataset.identity = task.taskId;
|
||||
if (state.selectedId === task.taskId)
|
||||
button.setAttribute('aria-current', 'true');
|
||||
const main = element('span');
|
||||
main.append(element('span', 'record-title', task.name));
|
||||
main.append(
|
||||
recordMeta([
|
||||
task.taskId,
|
||||
`${task.kind} · rev ${task.revision}`,
|
||||
task.specSchema,
|
||||
]),
|
||||
);
|
||||
const side = element('span', 'record-side');
|
||||
const enabled = element(
|
||||
'span',
|
||||
'status',
|
||||
task.enabled ? '可运行' : '已停用',
|
||||
);
|
||||
enabled.dataset.tone = task.enabled ? 'active' : 'quiet';
|
||||
side.append(enabled);
|
||||
side.append(element('span', 'record-time', formatTime(task.updatedAtMs)));
|
||||
button.append(main, side);
|
||||
button.addEventListener('click', () => selectTask(task.taskId));
|
||||
list.append(button);
|
||||
}
|
||||
fragment.append(list);
|
||||
if (value.hasMore) {
|
||||
fragment.append(
|
||||
element(
|
||||
'p',
|
||||
'privacy-note',
|
||||
'当前只展示前 64 条;完整分页将在后续 Console 切片开放。',
|
||||
),
|
||||
);
|
||||
}
|
||||
replace(nodes.ledger, fragment);
|
||||
}
|
||||
|
||||
async function selectTask(taskId) {
|
||||
state.selectedId = taskId;
|
||||
const selected = nodes.ledger.querySelectorAll('.record');
|
||||
for (const row of selected) {
|
||||
if (row.dataset.identity === taskId) {
|
||||
row.setAttribute('aria-current', 'true');
|
||||
} else {
|
||||
row.removeAttribute('aria-current');
|
||||
}
|
||||
}
|
||||
const loadingBox = element('div', 'loading-state');
|
||||
loadingBox.append(element('span'));
|
||||
replace(nodes.detail, loadingBox);
|
||||
try {
|
||||
const value = await api(
|
||||
`/api/v3/projects/${state.project}/tasks/${taskId}`,
|
||||
);
|
||||
renderTaskDetail(value.task);
|
||||
} catch (error) {
|
||||
detailEmpty(describeError(error));
|
||||
}
|
||||
}
|
||||
|
||||
function renderTaskDetail(task) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(detailHeader('Task definition', task.name, task.taskId));
|
||||
const facts = element('div', 'facts');
|
||||
facts.append(
|
||||
fact('状态', task.enabled ? '可运行' : '已停用'),
|
||||
fact('类型', task.kind),
|
||||
fact('Revision', task.revision),
|
||||
fact('Schema', task.specSchema),
|
||||
fact('Content fence', shortDigest(task.contentDigest)),
|
||||
fact('更新时间', formatTime(task.updatedAtMs)),
|
||||
);
|
||||
fragment.append(facts);
|
||||
const actions = element('div', 'detail-actions');
|
||||
if (task.enabled) {
|
||||
actions.append(
|
||||
actionButton('运行一次', () => {
|
||||
confirmAction(
|
||||
'运行这个任务?',
|
||||
`将按 revision ${task.revision} 与当前内容摘要启动“${task.name}”。定义如有变化,服务端会拒绝本次操作。`,
|
||||
'确认运行',
|
||||
() => startTask(task),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
fragment.append(actions);
|
||||
replace(nodes.detail, fragment);
|
||||
}
|
||||
|
||||
async function startTask(task) {
|
||||
try {
|
||||
const value = await api(
|
||||
`/api/v3/projects/${state.project}/tasks/${task.taskId}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
schema: 'qinglong/task-start@v1',
|
||||
mutationId: newMutationId(),
|
||||
expectedRevision: task.revision,
|
||||
expectedContentDigest: task.contentDigest,
|
||||
},
|
||||
},
|
||||
);
|
||||
showToast(
|
||||
value.status === 'existing'
|
||||
? '已找到同一启动请求。'
|
||||
: '任务已进入运行队列。',
|
||||
);
|
||||
state.view = 'runs';
|
||||
state.selectedId = value.runId;
|
||||
updateNavigation();
|
||||
await refresh();
|
||||
await selectRun(value.runId);
|
||||
} catch (error) {
|
||||
showToast(describeError(error), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function renderRuns() {
|
||||
const value = await api(`/api/v3/projects/${state.project}/runs?limit=64`);
|
||||
const runs = Array.isArray(value.runs) ? value.runs : [];
|
||||
if (runs.length === 0) {
|
||||
empty('还没有运行记录。切换到任务,选择一个已启用 Task 开始运行。');
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(listHeader('Run ledger', runs.length));
|
||||
const list = element('div', 'record-list');
|
||||
for (const run of runs) {
|
||||
const button = element('button', 'record');
|
||||
button.type = 'button';
|
||||
button.dataset.identity = run.id;
|
||||
if (state.selectedId === run.id)
|
||||
button.setAttribute('aria-current', 'true');
|
||||
const main = element('span');
|
||||
main.append(element('span', 'record-title', run.taskId));
|
||||
main.append(
|
||||
recordMeta([
|
||||
run.id,
|
||||
`rev ${run.taskRevision}`,
|
||||
`${run.executionOrigin} / ${run.executionOwner}`,
|
||||
]),
|
||||
);
|
||||
const side = element('span', 'record-side');
|
||||
side.append(statusBadge(run.status));
|
||||
side.append(element('span', 'record-time', formatTime(run.createdAtMs)));
|
||||
button.append(main, side);
|
||||
button.addEventListener('click', () => selectRun(run.id));
|
||||
list.append(button);
|
||||
}
|
||||
fragment.append(list);
|
||||
if (value.hasMore) {
|
||||
fragment.append(
|
||||
element(
|
||||
'p',
|
||||
'privacy-note',
|
||||
'当前只展示最近 64 条;使用 API 可继续读取下一页。',
|
||||
),
|
||||
);
|
||||
}
|
||||
replace(nodes.ledger, fragment);
|
||||
}
|
||||
|
||||
async function selectRun(runId) {
|
||||
state.selectedId = runId;
|
||||
const selected = nodes.ledger.querySelectorAll('.record');
|
||||
for (const row of selected) {
|
||||
if (row.dataset.identity === runId) {
|
||||
row.setAttribute('aria-current', 'true');
|
||||
} else {
|
||||
row.removeAttribute('aria-current');
|
||||
}
|
||||
}
|
||||
const loadingBox = element('div', 'loading-state');
|
||||
loadingBox.append(element('span'));
|
||||
replace(nodes.detail, loadingBox);
|
||||
try {
|
||||
const [runValue, eventValue, stepValue] = await Promise.all([
|
||||
api(`/api/v3/projects/${state.project}/runs/${runId}`),
|
||||
api(`/api/v3/projects/${state.project}/runs/${runId}/events?limit=64`),
|
||||
api(`/api/v3/projects/${state.project}/runs/${runId}/steps?limit=64`),
|
||||
]);
|
||||
renderRunDetail(runValue.run, eventValue, stepValue);
|
||||
} catch (error) {
|
||||
detailEmpty(describeError(error));
|
||||
}
|
||||
}
|
||||
|
||||
function renderRunDetail(run, eventPage, stepPage) {
|
||||
const events = Array.isArray(eventPage.events) ? eventPage.events : [];
|
||||
const steps = Array.isArray(stepPage.steps) ? stepPage.steps : [];
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(detailHeader('Run evidence', run.taskId, run.id));
|
||||
const facts = element('div', 'facts');
|
||||
facts.append(
|
||||
fact('状态', STATUS_LABELS[run.status] || run.status),
|
||||
fact('Version', run.version),
|
||||
fact('事件', run.eventSequence),
|
||||
fact('Workflow steps', steps.length),
|
||||
fact('执行归属', run.executionOwner),
|
||||
fact('创建时间', formatTime(run.createdAtMs)),
|
||||
);
|
||||
fragment.append(facts);
|
||||
if (!TERMINAL.has(run.status) && run.status !== 'cancel_requested') {
|
||||
const actions = element('div', 'detail-actions');
|
||||
actions.append(
|
||||
actionButton(
|
||||
'请求取消',
|
||||
() => {
|
||||
confirmAction(
|
||||
'请求取消运行?',
|
||||
'取消请求会先写入 durable intent。正在执行的进程只有在安全控制链确认后才会停止。',
|
||||
'确认请求取消',
|
||||
() => cancelRun(run),
|
||||
true,
|
||||
);
|
||||
},
|
||||
'danger',
|
||||
),
|
||||
);
|
||||
fragment.append(actions);
|
||||
}
|
||||
fragment.append(element('h4', 'timeline-heading', 'Event sequence'));
|
||||
if (events.length === 0) {
|
||||
fragment.append(element('p', 'privacy-note', '当前窗口没有可见事件。'));
|
||||
} else {
|
||||
const timeline = element('ol', 'event-spine');
|
||||
for (const event of events) {
|
||||
const item = element('li', 'event');
|
||||
item.append(element('span', 'event-sequence', event.sequence));
|
||||
const copy = element('div');
|
||||
copy.append(element('strong', null, event.type));
|
||||
copy.append(
|
||||
element(
|
||||
'span',
|
||||
null,
|
||||
`${event.actorType} · ${formatTime(event.createdAtMs)}`,
|
||||
),
|
||||
);
|
||||
item.append(copy);
|
||||
timeline.append(item);
|
||||
}
|
||||
fragment.append(timeline);
|
||||
}
|
||||
if (eventPage.hasMore || stepPage.hasMore) {
|
||||
fragment.append(
|
||||
element(
|
||||
'p',
|
||||
'privacy-note',
|
||||
'详情超过当前 64 条窗口;完整证据可通过 API 分页读取。',
|
||||
),
|
||||
);
|
||||
}
|
||||
replace(nodes.detail, fragment);
|
||||
}
|
||||
|
||||
async function cancelRun(run) {
|
||||
try {
|
||||
const value = await api(
|
||||
`/api/v3/projects/${state.project}/runs/${run.id}/cancellation`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
mutationId: newMutationId(),
|
||||
},
|
||||
},
|
||||
);
|
||||
showToast(
|
||||
value.status === 'already_terminal'
|
||||
? '运行已经结束。'
|
||||
: value.status === 'already_requested'
|
||||
? '取消请求已经存在。'
|
||||
: '取消请求已写入。',
|
||||
);
|
||||
await refresh();
|
||||
await selectRun(run.id);
|
||||
} catch (error) {
|
||||
showToast(describeError(error), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateNavigation() {
|
||||
for (const button of nodes.nav.querySelectorAll('button[data-view]')) {
|
||||
if (button.dataset.view === state.view)
|
||||
button.setAttribute('aria-current', 'page');
|
||||
else button.removeAttribute('aria-current');
|
||||
}
|
||||
if (state.view === 'tasks') {
|
||||
nodes.kicker.textContent = 'Project task authority';
|
||||
nodes.title.textContent = '任务调度台';
|
||||
nodes.description.textContent =
|
||||
'查看当前 Task revision 与内容围栏。运行前会再次读取详情并要求显式确认。';
|
||||
} else {
|
||||
nodes.kicker.textContent = 'Durable run evidence';
|
||||
nodes.title.textContent = '运行事实账本';
|
||||
nodes.description.textContent =
|
||||
'状态来自持久化 Run;取消只是请求,只有终态事件才能证明执行已经停止。';
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!state.token) return;
|
||||
loading();
|
||||
try {
|
||||
if (state.view === 'tasks') await renderTasks();
|
||||
else await renderRuns();
|
||||
setConnection('connected', `${state.project} · 已连接`);
|
||||
} catch (error) {
|
||||
errorState(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function connect(token, project) {
|
||||
state.token = token;
|
||||
state.project = project;
|
||||
state.view = 'tasks';
|
||||
state.selectedId = null;
|
||||
nodes.token.value = '';
|
||||
nodes.token.disabled = true;
|
||||
nodes.project.disabled = true;
|
||||
nodes.form.querySelector('.primary-button').hidden = true;
|
||||
nodes.disconnect.hidden = false;
|
||||
nodes.nav.hidden = false;
|
||||
nodes.refresh.hidden = false;
|
||||
detailEmpty();
|
||||
updateNavigation();
|
||||
setConnection('connected', `${project} · 正在验证`);
|
||||
refresh();
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
state.token = null;
|
||||
state.selectedId = null;
|
||||
state.pendingAction = null;
|
||||
nodes.token.value = '';
|
||||
nodes.token.disabled = false;
|
||||
nodes.project.disabled = false;
|
||||
nodes.form.querySelector('.primary-button').hidden = false;
|
||||
nodes.disconnect.hidden = true;
|
||||
nodes.nav.hidden = true;
|
||||
nodes.refresh.hidden = true;
|
||||
setConnection('idle', '等待凭据');
|
||||
nodes.kicker.textContent = 'Connection gate';
|
||||
nodes.title.textContent = '先建立一条本机连接';
|
||||
nodes.description.textContent =
|
||||
'输入 quickstart 交付的 Owner API Credential。连接成功后才能读取或执行操作。';
|
||||
const welcome = element('div', 'welcome-state');
|
||||
welcome.append(element('span', 'welcome-index', 'QL / 03'));
|
||||
welcome.append(element('p', null, '任务是计划,运行是事实,事件是证据。'));
|
||||
const rule = element('div', 'welcome-rule');
|
||||
rule.setAttribute('aria-hidden', 'true');
|
||||
rule.append(element('i'), element('i'), element('i'));
|
||||
welcome.append(rule);
|
||||
replace(nodes.ledger, welcome);
|
||||
detailEmpty();
|
||||
nodes.token.focus();
|
||||
}
|
||||
|
||||
nodes.form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const token = nodes.token.value.trim();
|
||||
const project = nodes.project.value.trim();
|
||||
if (!PROJECT_PATTERN.test(project)) {
|
||||
showToast('项目 ID 格式无效。', 'error');
|
||||
nodes.project.focus();
|
||||
return;
|
||||
}
|
||||
if (!TOKEN_PATTERN.test(token)) {
|
||||
showToast('API Credential 格式无效。', 'error');
|
||||
nodes.token.focus();
|
||||
return;
|
||||
}
|
||||
connect(token, project);
|
||||
});
|
||||
|
||||
nodes.disconnect.addEventListener('click', disconnect);
|
||||
nodes.refresh.addEventListener('click', refresh);
|
||||
|
||||
for (const button of nodes.nav.querySelectorAll('button[data-view]')) {
|
||||
button.addEventListener('click', () => {
|
||||
if (button.dataset.view === state.view) return;
|
||||
state.view = button.dataset.view;
|
||||
state.selectedId = null;
|
||||
detailEmpty();
|
||||
updateNavigation();
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
|
||||
nodes.dialog.addEventListener('close', async () => {
|
||||
const action = state.pendingAction;
|
||||
state.pendingAction = null;
|
||||
if (nodes.dialog.returnValue === 'accept' && typeof action === 'function') {
|
||||
await action();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (
|
||||
!state.token ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey ||
|
||||
event.target instanceof HTMLInputElement ||
|
||||
nodes.dialog.open
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const view =
|
||||
event.key.toLowerCase() === 't'
|
||||
? 'tasks'
|
||||
: event.key.toLowerCase() === 'r'
|
||||
? 'runs'
|
||||
: null;
|
||||
if (!view || view === state.view) return;
|
||||
state.view = view;
|
||||
state.selectedId = null;
|
||||
detailEmpty();
|
||||
updateNavigation();
|
||||
refresh();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,130 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#19353d" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'"
|
||||
/>
|
||||
<title>QingLong 3.0 · 自动化值班台</title>
|
||||
<link rel="stylesheet" href="/console.css" />
|
||||
<script src="/console.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#workspace">跳到操作区</a>
|
||||
<div class="shell">
|
||||
<aside class="gate" aria-label="连接与导航">
|
||||
<header class="brand">
|
||||
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||
<span class="brand-name">QingLong</span>
|
||||
<span class="brand-version">3.0</span>
|
||||
</header>
|
||||
|
||||
<section class="gate-copy">
|
||||
<p class="eyebrow">Local operations</p>
|
||||
<h1>自动化<br />值班台</h1>
|
||||
<p>在一块屏幕上看清任务、运行与事件,然后只执行你明确确认的动作。</p>
|
||||
</section>
|
||||
|
||||
<div class="connection-state" id="connection-state" data-state="idle">
|
||||
<span class="signal" aria-hidden="true"></span>
|
||||
<span id="connection-label">等待凭据</span>
|
||||
</div>
|
||||
|
||||
<form id="credential-form" class="credential-form" autocomplete="off">
|
||||
<label for="project-input">项目</label>
|
||||
<input
|
||||
id="project-input"
|
||||
name="project"
|
||||
value="default"
|
||||
inputmode="text"
|
||||
maxlength="128"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<label for="token-input">API Credential</label>
|
||||
<input
|
||||
id="token-input"
|
||||
name="token"
|
||||
type="password"
|
||||
placeholder="ql3c_…"
|
||||
maxlength="128"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
<button class="primary-button" type="submit">连接本机</button>
|
||||
<button class="quiet-button" id="disconnect-button" type="button" hidden>
|
||||
断开并清除凭据
|
||||
</button>
|
||||
<p class="privacy-note">凭据只保存在当前页面内存;不写入 URL、Cookie 或浏览器存储。</p>
|
||||
</form>
|
||||
|
||||
<nav class="section-nav" aria-label="工作区" hidden>
|
||||
<button type="button" data-view="tasks" aria-current="page">
|
||||
<span>任务</span><kbd>T</kbd>
|
||||
</button>
|
||||
<button type="button" data-view="runs">
|
||||
<span>运行</span><kbd>R</kbd>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<footer class="gate-footer">
|
||||
<span>Loopback only</span>
|
||||
<span>Alpha · API v3</span>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<main id="workspace" class="workspace" tabindex="-1">
|
||||
<header class="masthead">
|
||||
<div>
|
||||
<p class="eyebrow" id="section-kicker">Connection gate</p>
|
||||
<h2 id="section-title">先建立一条本机连接</h2>
|
||||
<p id="section-description">
|
||||
输入 quickstart 交付的 Owner API Credential。连接成功后才能读取或执行操作。
|
||||
</p>
|
||||
</div>
|
||||
<button class="refresh-button" id="refresh-button" type="button" hidden>
|
||||
<span aria-hidden="true">↻</span> 刷新
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="workspace-grid">
|
||||
<section class="ledger" id="ledger" aria-live="polite" aria-busy="false">
|
||||
<div class="welcome-state">
|
||||
<span class="welcome-index">QL / 03</span>
|
||||
<p>任务是计划,运行是事实,事件是证据。</p>
|
||||
<div class="welcome-rule" aria-hidden="true"><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="detail" id="detail" aria-label="详情">
|
||||
<div class="detail-empty">
|
||||
<span class="detail-glyph" aria-hidden="true">⌁</span>
|
||||
<p>选择一项,查看它的执行围栏与事件轨迹。</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="confirmation-dialog" class="confirmation-dialog">
|
||||
<form method="dialog">
|
||||
<p class="eyebrow">Explicit action</p>
|
||||
<h2 id="confirmation-title">确认操作</h2>
|
||||
<p id="confirmation-copy"></p>
|
||||
<div class="dialog-actions">
|
||||
<button class="quiet-button" value="cancel">返回</button>
|
||||
<button class="danger-button" id="confirmation-accept" value="accept">
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user