| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title data-i18n="pageTitle.main">WindsurfAPI bydwgx1337 控制台</title> |
| <style> |
| :root { |
| --bg: #09090b; |
| --bg-elev: #0d0d10; |
| --surface: #111114; |
| --surface-2: #17171c; |
| --surface-3: #1c1c22; |
| --border: #26262e; |
| --border-strong: #32323c; |
| --text: #f4f4f5; |
| --text-muted: #a1a1aa; |
| --text-dim: #71717a; |
| --accent: #6366f1; |
| --accent-hover: #7c7ff5; |
| --accent-soft: rgba(99, 102, 241, .14); |
| --success: #22c55e; |
| --success-soft: rgba(34, 197, 94, .14); |
| --warn: #f59e0b; |
| --warn-soft: rgba(245, 158, 11, .14); |
| --error: #ef4444; |
| --error-soft: rgba(239, 68, 68, .14); |
| --info: #3b82f6; |
| --info-soft: rgba(59, 130, 246, .14); |
| --radius: 10px; |
| --radius-sm: 6px; |
| --radius-lg: 14px; |
| --shadow: 0 1px 2px rgba(0,0,0,.4), 0 4px 12px rgba(0,0,0,.2); |
| --shadow-lg: 0 10px 40px rgba(0,0,0,.4); |
| --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', system-ui, sans-serif; |
| --mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace; |
| } |
| |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| *::before, *::after { box-sizing: border-box; } |
| html, body { height: 100%; } |
| body { |
| font-family: var(--font); |
| background: var(--bg); |
| color: var(--text); |
| font-size: 14px; |
| line-height: 1.5; |
| -webkit-font-smoothing: antialiased; |
| -moz-osx-font-smoothing: grayscale; |
| display: flex; |
| min-height: 100vh; |
| } |
| |
| |
| ::-webkit-scrollbar { width: 10px; height: 10px; } |
| ::-webkit-scrollbar-track { background: transparent; } |
| ::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 5px; border: 2px solid var(--bg); } |
| ::-webkit-scrollbar-thumb:hover { background: var(--border-strong); } |
| |
| a { color: var(--accent); text-decoration: none; } |
| code { |
| font-family: var(--mono); |
| font-size: 12px; |
| background: var(--surface-2); |
| color: var(--text); |
| padding: 2px 6px; |
| border-radius: 4px; |
| border: 1px solid var(--border); |
| } |
| |
| |
| .sidebar { |
| width: 232px; |
| background: var(--surface); |
| border-right: 1px solid var(--border); |
| position: fixed; |
| top: 0; left: 0; bottom: 0; |
| display: flex; |
| flex-direction: column; |
| z-index: 10; |
| } |
| .sidebar .brand { |
| padding: 20px 22px 18px; |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| border-bottom: 1px solid var(--border); |
| } |
| .sidebar .brand-logo { |
| width: 32px; height: 32px; |
| border-radius: 8px; |
| background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%); |
| display: flex; align-items: center; justify-content: center; |
| font-weight: 800; color: #fff; font-size: 15px; |
| box-shadow: 0 4px 12px rgba(99,102,241,.35); |
| } |
| .sidebar .brand-name { font-size: 15px; font-weight: 600; letter-spacing: -.01em; } |
| .sidebar .brand-sub { font-size: 11px; color: var(--text-dim); margin-top: 1px; } |
| |
| .sidebar nav { flex: 1; padding: 12px 10px; overflow-y: auto; } |
| .sidebar nav .nav-group { margin-bottom: 18px; } |
| .sidebar nav .nav-group-label { |
| font-size: 10px; |
| text-transform: uppercase; |
| letter-spacing: .08em; |
| color: var(--text-dim); |
| padding: 0 12px 6px; |
| font-weight: 600; |
| } |
| .sidebar nav a { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| padding: 8px 12px; |
| color: var(--text-muted); |
| font-size: 13px; |
| font-weight: 500; |
| border-radius: var(--radius-sm); |
| transition: all .15s; |
| margin-bottom: 2px; |
| } |
| .sidebar nav a:hover { color: var(--text); background: var(--surface-2); } |
| .sidebar nav a.active { |
| color: var(--text); |
| background: var(--surface-3); |
| box-shadow: inset 2px 0 0 var(--accent); |
| } |
| .sidebar nav a svg { width: 16px; height: 16px; stroke-width: 2; flex-shrink: 0; } |
| |
| .sidebar .footer { |
| padding: 12px 18px; |
| font-size: 11px; |
| color: var(--text-dim); |
| border-top: 1px solid var(--border); |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| } |
| .sidebar .footer .ver { font-family: var(--mono); } |
| |
| |
| .main { |
| margin-left: 232px; |
| flex: 1; |
| padding: 28px 36px 40px; |
| min-height: 100vh; |
| |
| } |
| .page-header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| margin-bottom: 24px; |
| gap: 16px; |
| flex-wrap: wrap; |
| } |
| .page-title { |
| font-size: 22px; |
| font-weight: 700; |
| letter-spacing: -.02em; |
| } |
| .page-subtitle { |
| font-size: 13px; |
| color: var(--text-muted); |
| margin-top: 2px; |
| } |
| .panel { display: none; animation: fadeIn .2s ease; } |
| .panel.active { display: block; } |
| |
| @keyframes fadeIn { |
| from { opacity: 0; transform: translateY(4px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| |
| |
| .card { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| padding: 18px 20px; |
| transition: border-color .15s; |
| } |
| .card:hover { border-color: var(--border-strong); } |
| .card-header { |
| display: flex; |
| align-items: flex-start; |
| justify-content: space-between; |
| margin-bottom: 4px; |
| } |
| .card-title { |
| font-size: 12px; |
| font-weight: 500; |
| color: var(--text-muted); |
| text-transform: uppercase; |
| letter-spacing: .04em; |
| } |
| .card-icon { |
| width: 18px; height: 18px; |
| color: var(--text-dim); |
| } |
| .card-value { |
| font-size: 26px; |
| font-weight: 700; |
| letter-spacing: -.02em; |
| line-height: 1.2; |
| margin-top: 6px; |
| } |
| .card-sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; } |
| .card.accent .card-value { color: var(--accent); } |
| .card.success .card-value { color: var(--success); } |
| .card.warn .card-value { color: var(--warn); } |
| .card.error .card-value { color: var(--error); } |
| .card.info .card-value { color: var(--info); } |
| |
| .metrics-grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); |
| gap: 14px; |
| margin-bottom: 24px; |
| } |
| |
| |
| .section { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| margin-bottom: 20px; |
| overflow: hidden; |
| } |
| .section-header { |
| padding: 16px 20px; |
| border-bottom: 1px solid var(--border); |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 12px; |
| flex-wrap: wrap; |
| } |
| .section-title { |
| font-size: 14px; |
| font-weight: 600; |
| letter-spacing: -.01em; |
| } |
| .section-desc { |
| font-size: 12px; |
| color: var(--text-muted); |
| margin-top: 2px; |
| font-weight: 400; |
| } |
| .section-body { padding: 20px; } |
| .section-body.tight { padding: 0; } |
| |
| |
| .btn { |
| display: inline-flex; |
| align-items: center; |
| justify-content: center; |
| gap: 6px; |
| padding: 8px 16px; |
| font-size: 13px; |
| font-weight: 500; |
| font-family: var(--font); |
| border: 1px solid transparent; |
| border-radius: var(--radius-sm); |
| cursor: pointer; |
| transition: all .15s; |
| background: var(--surface-2); |
| color: var(--text); |
| white-space: nowrap; |
| line-height: 1.2; |
| } |
| .btn:hover:not(:disabled) { background: var(--surface-3); border-color: var(--border-strong); } |
| .btn:active:not(:disabled) { transform: translateY(1px); } |
| .btn:disabled { opacity: .5; cursor: not-allowed; } |
| .btn svg { width: 14px; height: 14px; } |
| |
| .btn-primary { |
| background: var(--accent); |
| color: #fff; |
| border-color: var(--accent); |
| box-shadow: 0 1px 3px rgba(99,102,241,.3); |
| } |
| .btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); } |
| |
| .btn-outline { |
| background: transparent; |
| border-color: var(--border-strong); |
| color: var(--text); |
| } |
| .btn-outline:hover:not(:disabled) { background: var(--surface-2); border-color: var(--accent); } |
| |
| .btn-ghost { background: transparent; border-color: transparent; color: var(--text-muted); } |
| .btn-ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); } |
| |
| .btn-danger { |
| background: var(--error); |
| color: #fff; |
| border-color: var(--error); |
| } |
| .btn-danger:hover:not(:disabled) { background: #dc2626; border-color: #dc2626; } |
| |
| .btn-success { |
| background: var(--success); |
| color: #fff; |
| border-color: var(--success); |
| } |
| .btn-success:hover:not(:disabled) { background: #16a34a; border-color: #16a34a; } |
| |
| .btn-sm { padding: 5px 10px; font-size: 12px; } |
| .btn-xs { padding: 3px 8px; font-size: 11px; } |
| .btn-icon { padding: 7px; } |
| |
| .btn-group { display: inline-flex; gap: 6px; flex-wrap: wrap; } |
| |
| .btn.active, .btn.active:hover { |
| background: var(--accent); color: #fff; border-color: var(--accent); |
| } |
| |
| |
| .switch { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; } |
| .switch input { opacity: 0; width: 0; height: 0; } |
| .switch-slider { |
| position: absolute; cursor: pointer; inset: 0; |
| background: var(--surface-2); border: 1px solid var(--border); |
| border-radius: 22px; transition: .18s; |
| } |
| .switch-slider::before { |
| content: ''; position: absolute; height: 16px; width: 16px; |
| left: 2px; bottom: 2px; background: var(--text); |
| border-radius: 50%; transition: .18s; |
| } |
| .switch input:checked + .switch-slider { |
| background: var(--accent); border-color: var(--accent); |
| } |
| .switch input:checked + .switch-slider::before { transform: translateX(18px); background: #fff; } |
| |
| |
| .input, .select, .textarea { |
| display: block; |
| width: 100%; |
| padding: 9px 12px; |
| font-size: 13px; |
| font-family: var(--font); |
| background: var(--bg-elev); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| color: var(--text); |
| outline: none; |
| transition: all .15s; |
| line-height: 1.35; |
| } |
| .input:hover, .select:hover, .textarea:hover { border-color: var(--border-strong); } |
| .input:focus, .select:focus, .textarea:focus { |
| border-color: var(--accent); |
| box-shadow: 0 0 0 3px var(--accent-soft); |
| } |
| .input::placeholder, .textarea::placeholder { color: var(--text-dim); } |
| |
| .select { |
| appearance: none; |
| background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a1a1aa' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>"); |
| background-repeat: no-repeat; |
| background-position: right 10px center; |
| padding-right: 32px; |
| cursor: pointer; |
| } |
| .select option { background: var(--surface); color: var(--text); } |
| |
| .field { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; } |
| .field-label { |
| font-size: 12px; |
| color: var(--text-muted); |
| font-weight: 500; |
| } |
| .field-hint { |
| font-size: 12px; |
| color: var(--text-dim); |
| line-height: 1.5; |
| } |
| .field-row { |
| display: grid; |
| gap: 12px; |
| grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); |
| align-items: end; |
| } |
| .field-row-actions { |
| display: flex; |
| gap: 8px; |
| margin-top: 14px; |
| flex-wrap: wrap; |
| } |
| |
| |
| .input-group { display: flex; gap: 6px; align-items: stretch; } |
| .input-group .input { flex: 1; } |
| |
| |
| .checkbox { |
| display: inline-flex; |
| align-items: center; |
| gap: 8px; |
| cursor: pointer; |
| font-size: 13px; |
| color: var(--text-muted); |
| user-select: none; |
| } |
| .checkbox input { position: absolute; opacity: 0; pointer-events: none; } |
| .checkbox .box { |
| width: 16px; height: 16px; |
| border: 1.5px solid var(--border-strong); |
| border-radius: 4px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| background: var(--bg-elev); |
| transition: all .15s; |
| flex-shrink: 0; |
| } |
| .checkbox input:checked + .box { |
| background: var(--accent); |
| border-color: var(--accent); |
| } |
| .checkbox input:checked + .box::after { |
| content: ''; |
| width: 4px; height: 8px; |
| border: solid #fff; |
| border-width: 0 2px 2px 0; |
| transform: rotate(45deg) translateY(-1px); |
| } |
| .checkbox:hover .box { border-color: var(--accent); } |
| |
| |
| .segmented { |
| display: inline-flex; |
| background: var(--bg-elev); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| padding: 3px; |
| gap: 2px; |
| } |
| .segmented label { |
| padding: 6px 14px; |
| font-size: 12px; |
| font-weight: 500; |
| color: var(--text-muted); |
| cursor: pointer; |
| border-radius: 5px; |
| transition: all .15s; |
| position: relative; |
| } |
| .segmented label input { position: absolute; opacity: 0; pointer-events: none; } |
| .segmented label:hover { color: var(--text); } |
| .segmented label:has(input:checked) { |
| background: var(--surface-3); |
| color: var(--text); |
| box-shadow: 0 1px 2px rgba(0,0,0,.3); |
| } |
| |
| |
| .table-wrap { |
| overflow-x: auto; |
| border-radius: var(--radius); |
| } |
| table { |
| width: 100%; |
| border-collapse: collapse; |
| font-size: 13px; |
| } |
| thead th { |
| text-align: left; |
| padding: 11px 16px; |
| font-size: 11px; |
| font-weight: 600; |
| text-transform: uppercase; |
| letter-spacing: .04em; |
| color: var(--text-muted); |
| background: var(--surface-2); |
| border-bottom: 1px solid var(--border); |
| white-space: nowrap; |
| } |
| thead th.th-help { |
| cursor: help; |
| } |
| thead th.th-help::after { |
| content: " ⓘ"; |
| opacity: .5; |
| font-size: 10px; |
| margin-left: 3px; |
| letter-spacing: normal; |
| } |
| tbody td { |
| padding: 11px 16px; |
| border-bottom: 1px solid var(--border); |
| vertical-align: middle; |
| } |
| tbody tr:last-child td { border-bottom: none; } |
| tbody tr { transition: background .1s; } |
| tbody tr:hover td { background: var(--surface-2); } |
| .empty-row td { |
| text-align: center; |
| color: var(--text-dim); |
| padding: 32px 16px; |
| font-style: italic; |
| } |
| |
| |
| .expand-chevron { |
| display: inline-flex; |
| align-items: center; |
| justify-content: center; |
| width: 20px; |
| height: 20px; |
| border-radius: 4px; |
| border: 1px solid transparent; |
| background: transparent; |
| color: var(--text-dim); |
| cursor: pointer; |
| transition: all .15s ease; |
| font-size: 10px; |
| margin-right: 6px; |
| vertical-align: middle; |
| } |
| .expand-chevron:hover { |
| color: var(--text); |
| background: var(--surface-2); |
| border-color: var(--border); |
| } |
| .expand-chevron.open { |
| color: var(--accent); |
| transform: rotate(90deg); |
| } |
| .account-detail-row > td { |
| padding: 0 !important; |
| background: #0d0d10 !important; |
| border-bottom: 1px solid var(--border) !important; |
| } |
| .account-detail-wrap { |
| padding: 20px 28px; |
| background: linear-gradient(180deg, rgba(99,102,241,.04), transparent 80%); |
| } |
| .detail-grid { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 16px; |
| margin-bottom: 14px; |
| } |
| @media (max-width: 1100px) { .detail-grid { grid-template-columns: 1fr; } } |
| .detail-card { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: 10px; |
| padding: 14px 16px; |
| } |
| .detail-card-head { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| font-size: 11px; |
| font-weight: 600; |
| color: var(--text-muted); |
| text-transform: uppercase; |
| letter-spacing: .06em; |
| margin-bottom: 10px; |
| } |
| .detail-card-head .dot { |
| width: 6px; height: 6px; |
| border-radius: 50%; |
| background: var(--accent); |
| box-shadow: 0 0 6px var(--accent); |
| } |
| .detail-kv { |
| display: grid; |
| grid-template-columns: 90px 1fr; |
| gap: 4px 14px; |
| font-size: 12.5px; |
| line-height: 1.6; |
| } |
| .detail-kv > .k { color: var(--text-dim); } |
| .detail-kv > .v { color: var(--text); font-variant-numeric: tabular-nums; } |
| .detail-kv code { font-size: 11px; padding: 1px 5px; } |
| |
| .detail-bar { |
| display: grid; |
| grid-template-columns: 60px 1fr 50px; |
| align-items: center; |
| gap: 10px; |
| margin-bottom: 6px; |
| font-size: 12px; |
| } |
| .detail-bar .bar-label { color: var(--text-dim); font-size: 11px; } |
| .detail-bar .bar-track { |
| height: 7px; |
| border-radius: 4px; |
| background: var(--surface-2); |
| overflow: hidden; |
| border: 1px solid var(--border); |
| } |
| .detail-bar .bar-fill { |
| height: 100%; |
| border-radius: 3px; |
| transition: width .6s cubic-bezier(.2,.8,.2,1); |
| } |
| .detail-bar .bar-pct { |
| text-align: right; |
| color: var(--text); |
| font-family: var(--mono); |
| font-size: 11px; |
| font-weight: 600; |
| } |
| .detail-bar .bar-reset { |
| grid-column: 2 / 4; |
| font-size: 10.5px; |
| color: var(--text-dim); |
| font-family: var(--mono); |
| margin-top: 2px; |
| } |
| |
| |
| .model-list-card { grid-column: 1 / -1; } |
| .model-group { |
| margin-bottom: 14px; |
| } |
| .model-group:last-child { margin-bottom: 0; } |
| .model-group-head { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| margin-bottom: 6px; |
| padding-bottom: 5px; |
| border-bottom: 1px dashed var(--border); |
| } |
| .model-group-name { |
| font-size: 11px; |
| font-weight: 600; |
| color: var(--text); |
| letter-spacing: .02em; |
| } |
| .model-group-count { |
| font-size: 10px; |
| color: var(--text-dim); |
| font-family: var(--mono); |
| } |
| .model-chips { |
| display: grid; |
| grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); |
| gap: 6px; |
| } |
| .model-chip { |
| display: grid; |
| grid-template-columns: 10px 1fr auto; |
| align-items: center; |
| gap: 8px; |
| padding: 6px 10px; |
| background: var(--surface-2); |
| border: 1px solid var(--border); |
| border-radius: 6px; |
| font-size: 11.5px; |
| transition: all .15s ease; |
| } |
| .model-chip:hover { |
| border-color: var(--border-strong); |
| background: var(--surface-3); |
| } |
| .model-chip.blocked { |
| opacity: .45; |
| text-decoration: line-through; |
| text-decoration-color: var(--text-dim); |
| text-decoration-thickness: 1px; |
| } |
| .model-chip-dot { |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| background: var(--success); |
| } |
| .model-chip.blocked .model-chip-dot { background: var(--error); } |
| .model-chip-name { |
| font-family: var(--mono); |
| font-size: 11px; |
| color: var(--text); |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| } |
| .model-chip-cost { |
| font-family: var(--mono); |
| font-size: 10px; |
| color: var(--warn); |
| padding: 1px 6px; |
| background: rgba(245,158,11,.08); |
| border: 1px solid rgba(245,158,11,.2); |
| border-radius: 10px; |
| white-space: nowrap; |
| font-weight: 600; |
| } |
| .provider-swatch { |
| width: 14px; height: 14px; |
| border-radius: 3px; |
| display: inline-block; |
| margin-right: 6px; |
| vertical-align: middle; |
| } |
| |
| |
| .badge { |
| display: inline-flex; |
| align-items: center; |
| gap: 4px; |
| padding: 2px 8px; |
| border-radius: 999px; |
| font-size: 11px; |
| font-weight: 600; |
| line-height: 1.5; |
| border: 1px solid transparent; |
| } |
| .badge::before { |
| content: ''; |
| width: 6px; height: 6px; |
| border-radius: 50%; |
| background: currentColor; |
| } |
| .badge.active, .badge.running, .badge.success { |
| background: var(--success-soft); |
| color: var(--success); |
| } |
| .badge.error, .badge.stopped { |
| background: var(--error-soft); |
| color: var(--error); |
| } |
| .badge.disabled, .badge.muted { |
| background: var(--surface-3); |
| color: var(--text-muted); |
| } |
| .badge.warn { |
| background: var(--warn-soft); |
| color: var(--warn); |
| } |
| .badge.info, .badge.pro { |
| background: var(--info-soft); |
| color: var(--info); |
| } |
| .badge.no-dot::before { display: none; } |
| |
| |
| .tier { |
| display: inline-flex; |
| align-items: center; |
| padding: 2px 10px; |
| border-radius: 5px; |
| font-size: 11px; |
| font-weight: 700; |
| text-transform: uppercase; |
| letter-spacing: .04em; |
| } |
| .tier.pro { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff; } |
| .tier.free { background: var(--info-soft); color: var(--info); } |
| .tier.expired { background: var(--error-soft); color: var(--error); } |
| .tier.unknown { background: var(--surface-3); color: var(--text-muted); } |
| |
| |
| .ls-pool { |
| display: grid; |
| grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); |
| gap: 12px; |
| } |
| .ls-inst { |
| padding: 14px 16px; |
| background: var(--bg-elev); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| font-size: 12px; |
| } |
| .ls-inst .ls-key { |
| font-weight: 600; |
| color: var(--text); |
| font-size: 13px; |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| margin-bottom: 6px; |
| } |
| .ls-inst .ls-dot { |
| width: 8px; height: 8px; |
| border-radius: 50%; |
| background: var(--success); |
| box-shadow: 0 0 8px var(--success); |
| } |
| .ls-inst .ls-dot.pending { background: var(--warn); box-shadow: 0 0 8px var(--warn); } |
| .ls-inst .ls-meta { color: var(--text-dim); font-family: var(--mono); font-size: 11px; margin-top: 2px; } |
| |
| |
| .model-chips { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 6px; |
| margin: 10px 0; |
| } |
| .model-chip { |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| padding: 5px 11px; |
| border-radius: 6px; |
| font-size: 12px; |
| border: 1px solid var(--border); |
| background: var(--bg-elev); |
| color: var(--text-muted); |
| cursor: pointer; |
| transition: all .15s; |
| font-weight: 500; |
| } |
| .model-chip:hover { |
| border-color: var(--accent); |
| color: var(--text); |
| } |
| .model-chip.selected { |
| background: var(--accent-soft); |
| border-color: var(--accent); |
| color: var(--accent); |
| } |
| .model-chip .remove { |
| color: var(--error); |
| font-weight: 700; |
| cursor: pointer; |
| padding: 0 2px; |
| } |
| .provider-group { margin-bottom: 16px; } |
| .provider-label { |
| font-size: 10px; |
| color: var(--text-dim); |
| text-transform: uppercase; |
| letter-spacing: .08em; |
| margin-bottom: 6px; |
| font-weight: 600; |
| } |
| |
| |
| .log-container { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| height: 520px; |
| overflow-y: auto; |
| font-family: var(--mono); |
| font-size: 12px; |
| line-height: 1.6; |
| } |
| .log-entry { |
| padding: 3px 16px; |
| border-bottom: 1px solid rgba(255,255,255,.02); |
| display: flex; |
| gap: 10px; |
| align-items: baseline; |
| word-break: break-all; |
| } |
| .log-entry:hover { background: var(--surface-2); } |
| .log-entry .ts { color: var(--text-dim); flex-shrink: 0; } |
| .log-entry .lvl { |
| font-weight: 700; |
| flex-shrink: 0; |
| width: 50px; |
| } |
| .log-entry.debug .lvl { color: var(--text-dim); } |
| .log-entry.info .lvl { color: var(--info); } |
| .log-entry.warn .lvl { color: var(--warn); } |
| .log-entry.error .lvl { color: var(--error); } |
| .log-entry.error { background: rgba(239,68,68,.03); } |
| |
| .log-controls { |
| display: flex; |
| gap: 10px; |
| align-items: center; |
| flex-wrap: wrap; |
| margin-bottom: 14px; |
| } |
| .log-controls .input, .log-controls .select { width: auto; } |
| .log-controls .search { flex: 1; min-width: 200px; } |
| |
| |
| .chart { |
| padding: 22px 24px 20px; |
| background: linear-gradient(135deg, var(--surface), rgba(59,130,246,.03)); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| margin-bottom: 20px; |
| position: relative; |
| overflow: hidden; |
| } |
| .chart::before { |
| content: ""; |
| position: absolute; |
| top: 0; left: 0; right: 0; |
| height: 2px; |
| background: linear-gradient(90deg, #3b82f6, #a78bfa, #3b82f6); |
| background-size: 200% 100%; |
| animation: chart-shimmer 3s linear infinite; |
| } |
| @keyframes chart-shimmer { 0%{background-position:200% 0} 100%{background-position:0 0} } |
| .chart h3 { font-size: 14px; font-weight: 600; margin: 0; letter-spacing: -.01em; } |
| .chart-header { |
| display: flex; |
| align-items: flex-start; |
| justify-content: space-between; |
| gap: 16px; |
| flex-wrap: wrap; |
| margin-bottom: 18px; |
| } |
| .chart-title-group { min-width: 0; flex: 1; } |
| .chart-subtitle { |
| font-size: 12px; |
| color: var(--text-dim); |
| margin-top: 4px; |
| letter-spacing: .01em; |
| } |
| .chart-controls { |
| display: flex; |
| gap: 8px; |
| flex-wrap: wrap; |
| align-items: center; |
| } |
| |
| |
| .seg-group { |
| display: inline-flex; |
| background: var(--surface-2); |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| padding: 3px; |
| gap: 2px; |
| } |
| .seg-btn { |
| padding: 5px 11px; |
| font-size: 12px; |
| font-weight: 500; |
| color: var(--text-muted); |
| background: transparent; |
| border: none; |
| border-radius: 6px; |
| cursor: pointer; |
| transition: all .18s ease; |
| font-family: inherit; |
| letter-spacing: -.005em; |
| white-space: nowrap; |
| } |
| .seg-btn:hover { |
| color: var(--text); |
| background: rgba(255,255,255,.03); |
| } |
| .seg-btn.active { |
| color: #fff; |
| background: linear-gradient(135deg, #3b82f6, #6366f1); |
| box-shadow: 0 1px 3px rgba(59,130,246,.35), 0 0 0 0.5px rgba(255,255,255,.06) inset; |
| } |
| |
| |
| .chart-canvas-wrap { |
| position: relative; |
| width: 100%; |
| height: 280px; |
| padding-top: 6px; |
| } |
| .chart-canvas-wrap canvas { |
| width: 100%; |
| height: 100%; |
| display: block; |
| } |
| .chart-range-indicator { |
| position: absolute; |
| top: 8px; |
| right: 8px; |
| font-size: 11px; |
| color: var(--text-dim); |
| font-family: var(--mono); |
| padding: 3px 8px; |
| background: rgba(255,255,255,.02); |
| border: 1px solid var(--border); |
| border-radius: 6px; |
| pointer-events: none; |
| letter-spacing: .01em; |
| } |
| |
| |
| .custom-range-popup { |
| position: relative; |
| margin-top: 14px; |
| padding: 18px; |
| background: var(--surface-2); |
| border: 1px solid var(--border); |
| border-radius: 10px; |
| max-width: 420px; |
| } |
| .custom-range-title { |
| font-size: 13px; |
| font-weight: 600; |
| margin-bottom: 14px; |
| color: var(--text); |
| } |
| .custom-range-fields { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 14px; |
| margin-bottom: 10px; |
| } |
| .custom-range-fields label { |
| display: flex; |
| flex-direction: column; |
| gap: 5px; |
| font-size: 11px; |
| color: var(--text-dim); |
| font-weight: 500; |
| letter-spacing: .01em; |
| } |
| .custom-range-fields input[type="date"] { |
| font-family: var(--mono); |
| font-size: 12px; |
| color-scheme: dark; |
| } |
| .custom-range-hint { |
| font-size: 11px; |
| color: var(--text-dim); |
| margin-bottom: 14px; |
| } |
| .custom-range-actions { |
| display: flex; |
| gap: 8px; |
| justify-content: flex-end; |
| } |
| .chart-tooltip { |
| position: absolute; |
| pointer-events: none; |
| background: rgba(17,17,24,.96); |
| color: var(--text); |
| padding: 8px 12px; |
| border-radius: 8px; |
| font-size: 12px; |
| line-height: 1.55; |
| border: 1px solid var(--border-strong); |
| box-shadow: 0 8px 28px rgba(0,0,0,.5); |
| backdrop-filter: blur(10px); |
| opacity: 0; |
| transform: translate(-50%, 0); |
| transition: opacity .12s ease, left .08s ease-out; |
| z-index: 5; |
| white-space: nowrap; |
| } |
| .chart-tooltip.show { opacity: 1; } |
| .chart-tooltip .tt-time { color: var(--text-dim); font-size: 11px; margin-bottom: 4px; } |
| .chart-tooltip .tt-row { display: flex; align-items: center; gap: 6px; } |
| .chart-tooltip .tt-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } |
| .chart-tooltip .tt-label { color: var(--text-dim); margin-right: 4px; } |
| .chart-tooltip .tt-value { font-weight: 600; font-variant-numeric: tabular-nums; } |
| |
| |
| .chart-legend { |
| display: flex; |
| gap: 18px; |
| justify-content: center; |
| margin-top: 14px; |
| padding-top: 14px; |
| border-top: 1px solid var(--border); |
| flex-wrap: wrap; |
| } |
| .chart-legend-item { |
| display: flex; |
| align-items: center; |
| gap: 7px; |
| font-size: 12px; |
| color: var(--text-muted); |
| cursor: pointer; |
| user-select: none; |
| transition: opacity .15s ease; |
| } |
| .chart-legend-item.muted { opacity: .4; } |
| .chart-legend-item:hover { color: var(--text); } |
| .chart-legend-swatch { |
| width: 14px; height: 4px; |
| border-radius: 2px; |
| } |
| .chart-legend-swatch.dot { |
| width: 10px; height: 10px; |
| border-radius: 50%; |
| } |
| .chart-legend-value { |
| font-family: var(--mono); |
| font-size: 11px; |
| color: var(--text-dim); |
| font-variant-numeric: tabular-nums; |
| } |
| |
| |
| .chart-grid { |
| display: grid; |
| grid-template-columns: 1fr; |
| gap: 20px; |
| margin-bottom: 20px; |
| } |
| @media (min-width: 960px) { |
| .chart-grid { grid-template-columns: 1fr; } |
| } |
| |
| .chart-pie-body { |
| display: grid; |
| grid-template-columns: 200px 1fr; |
| gap: 28px; |
| align-items: center; |
| } |
| @media (max-width: 680px) { |
| .chart-pie-body { grid-template-columns: 1fr; } |
| } |
| .chart-pie-body canvas { |
| width: 200px; |
| height: 200px; |
| display: block; |
| margin: 0 auto; |
| } |
| .pie-legend { |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| font-size: 12px; |
| } |
| .pie-legend-item { |
| display: grid; |
| grid-template-columns: 12px 1fr auto auto; |
| gap: 10px; |
| align-items: center; |
| padding: 6px 10px; |
| border-radius: 6px; |
| transition: background .15s ease; |
| } |
| .pie-legend-item:hover { background: var(--surface-2); } |
| .pie-legend-dot { |
| width: 10px; height: 10px; border-radius: 3px; |
| } |
| .pie-legend-name { |
| font-family: var(--mono); |
| font-size: 11px; |
| color: var(--text); |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| .pie-legend-count { |
| font-variant-numeric: tabular-nums; |
| color: var(--text-muted); |
| font-size: 11px; |
| } |
| .pie-legend-pct { |
| font-variant-numeric: tabular-nums; |
| color: var(--text-dim); |
| font-size: 11px; |
| min-width: 38px; |
| text-align: right; |
| } |
| .bars { |
| display: flex; |
| align-items: flex-end; |
| gap: 4px; |
| height: 160px; |
| padding-bottom: 4px; |
| border-bottom: 1px solid rgba(255,255,255,.05); |
| } |
| .bar-wrap { |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| height: 100%; |
| justify-content: flex-end; |
| position: relative; |
| } |
| .bar { |
| width: 100%; |
| min-width: 6px; |
| max-width: 28px; |
| background: linear-gradient(to top, rgba(59,130,246,.6), rgba(96,165,250,.9)); |
| border-radius: 4px 4px 1px 1px; |
| transition: all .3s cubic-bezier(.4,0,.2,1); |
| cursor: pointer; |
| position: relative; |
| } |
| .bar::after { |
| content: ""; |
| position: absolute; |
| inset: 0; |
| border-radius: inherit; |
| background: linear-gradient(to top, transparent 60%, rgba(255,255,255,.12)); |
| pointer-events: none; |
| } |
| .bar.has-errors { |
| background: linear-gradient(to top, rgba(239,68,68,.6), rgba(251,146,60,.9)); |
| } |
| .bar-wrap:hover .bar { |
| box-shadow: 0 0 16px rgba(96,165,250,.4), inset 0 0 8px rgba(255,255,255,.08); |
| transform: scaleY(1.02) scaleX(1.08); |
| filter: brightness(1.15); |
| } |
| .bar-wrap:hover .bar.has-errors { |
| box-shadow: 0 0 16px rgba(239,68,68,.4), inset 0 0 8px rgba(255,255,255,.08); |
| } |
| .bar-label { |
| font-size: 9px; |
| color: var(--text-dim); |
| margin-top: 6px; |
| font-family: var(--mono); |
| opacity: .6; |
| transition: opacity .2s; |
| } |
| .bar-wrap:hover .bar-label { opacity: 1; color: var(--text); } |
| .bar-tooltip { |
| display: none; |
| position: absolute; |
| bottom: calc(100% + 8px); |
| left: 50%; |
| transform: translateX(-50%); |
| background: rgba(15,15,25,.95); |
| color: #e0e0e0; |
| padding: 8px 12px; |
| border-radius: 8px; |
| font-size: 12px; |
| line-height: 1.5; |
| white-space: nowrap; |
| pointer-events: none; |
| z-index: 10; |
| box-shadow: 0 8px 24px rgba(0,0,0,.5); |
| border: 1px solid rgba(255,255,255,.08); |
| backdrop-filter: blur(8px); |
| } |
| .bar-tooltip b { color: #60a5fa; } |
| .bar-tooltip::after { |
| content: ""; |
| position: absolute; |
| top: 100%; |
| left: 50%; |
| transform: translateX(-50%); |
| border: 6px solid transparent; |
| border-top-color: rgba(15,15,25,.95); |
| } |
| .bar-wrap:hover .bar-tooltip { display: block; animation: tt-in .15s ease; } |
| @keyframes tt-in { from{opacity:0;transform:translateX(-50%) translateY(4px)} to{opacity:1;transform:translateX(-50%) translateY(0)} } |
| |
| |
| .toast-stack { |
| position: fixed; |
| bottom: 24px; |
| right: 24px; |
| display: flex; |
| flex-direction: column-reverse; |
| gap: 10px; |
| z-index: 9999; |
| pointer-events: none; |
| } |
| .toast { |
| padding: 12px 18px; |
| border-radius: var(--radius-sm); |
| font-size: 13px; |
| background: var(--surface); |
| border: 1px solid var(--border); |
| box-shadow: var(--shadow-lg); |
| animation: slideIn .25s ease; |
| pointer-events: auto; |
| max-width: 400px; |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| .toast.success { border-left: 3px solid var(--success); } |
| .toast.error { border-left: 3px solid var(--error); } |
| .toast.info { border-left: 3px solid var(--info); } |
| @keyframes slideIn { |
| from { opacity: 0; transform: translateX(20px); } |
| to { opacity: 1; transform: translateX(0); } |
| } |
| |
| |
| .modal-overlay { |
| position: fixed; |
| inset: 0; |
| background: rgba(0,0,0,.7); |
| backdrop-filter: blur(4px); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| z-index: 100; |
| animation: fadeIn .15s ease; |
| } |
| .modal { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-lg); |
| width: 90%; |
| max-width: 440px; |
| box-shadow: var(--shadow-lg); |
| overflow: hidden; |
| animation: modalIn .2s cubic-bezier(.16,1,.3,1); |
| } |
| @keyframes modalIn { |
| from { opacity: 0; transform: translateY(-10px) scale(.98); } |
| to { opacity: 1; transform: translateY(0) scale(1); } |
| } |
| .modal-header { |
| padding: 20px 24px 12px; |
| } |
| .modal-title { font-size: 16px; font-weight: 600; } |
| .modal-desc { font-size: 13px; color: var(--text-muted); margin-top: 4px; } |
| .modal-body { padding: 12px 24px 20px; } |
| .modal-footer { |
| padding: 14px 24px; |
| background: var(--surface-2); |
| display: flex; |
| justify-content: flex-end; |
| gap: 8px; |
| border-top: 1px solid var(--border); |
| } |
| |
| |
| .login-overlay { |
| position: fixed; |
| inset: 0; |
| background: radial-gradient(ellipse at center, rgba(99,102,241,.08) 0%, var(--bg) 70%); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| z-index: 200; |
| } |
| .login-box { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-lg); |
| padding: 36px 32px; |
| width: 380px; |
| box-shadow: var(--shadow-lg); |
| } |
| .login-box .login-logo { |
| width: 56px; height: 56px; |
| margin: 0 auto 18px; |
| border-radius: 14px; |
| background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%); |
| display: flex; align-items: center; justify-content: center; |
| font-weight: 800; font-size: 22px; color: #fff; |
| box-shadow: 0 10px 30px rgba(99,102,241,.35); |
| } |
| .login-box h3 { text-align: center; font-size: 18px; margin-bottom: 8px; } |
| .login-box p { text-align: center; font-size: 13px; color: var(--text-muted); margin-bottom: 22px; } |
| .login-box .input { margin-bottom: 14px; } |
| .login-box .btn { width: 100%; padding: 11px; font-weight: 600; } |
| |
| |
| .spinner { |
| display: inline-block; |
| width: 14px; height: 14px; |
| border: 2px solid rgba(255,255,255,.2); |
| border-top-color: currentColor; |
| border-radius: 50%; |
| animation: spin .7s linear infinite; |
| vertical-align: middle; |
| } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| |
| |
| .cap-list { display: inline-flex; flex-wrap: wrap; gap: 4px; } |
| .cap-item { |
| font-size: 10px; |
| padding: 1px 7px; |
| border-radius: 4px; |
| font-family: var(--mono); |
| font-weight: 600; |
| } |
| .cap-item.ok { background: var(--success-soft); color: var(--success); } |
| .cap-item.fail { background: var(--error-soft); color: var(--error); text-decoration: line-through; } |
| |
| |
| .flex { display: flex; } |
| .flex-col { display: flex; flex-direction: column; } |
| .items-center { align-items: center; } |
| .justify-between { justify-content: space-between; } |
| .gap-2 { gap: 8px; } .gap-3 { gap: 12px; } .gap-4 { gap: 16px; } |
| .mt-2 { margin-top: 8px; } .mt-3 { margin-top: 12px; } .mt-4 { margin-top: 16px; } |
| .text-muted { color: var(--text-muted); } |
| .text-dim { color: var(--text-dim); } |
| .text-sm { font-size: 12px; } |
| .text-xs { font-size: 11px; } |
| .nowrap { white-space: nowrap; } |
| .hidden { display: none !important; } |
| .break-all { word-break: break-all; } |
| |
| |
| @media (max-width: 900px) { |
| .sidebar { width: 60px; } |
| .sidebar .brand-name, .sidebar .brand-sub, .sidebar nav a span, .sidebar .footer .ver, .sidebar .nav-group-label { display: none; } |
| .sidebar .brand { justify-content: center; padding: 20px 0; } |
| .sidebar nav a { justify-content: center; padding: 10px; } |
| .main { margin-left: 60px; padding: 20px 16px; } |
| } |
| .contributor-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 12px; } |
| .contributor-card { display: flex; gap: 14px; padding: 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); transition: border-color .15s; min-width: 0; } |
| .contributor-card:hover { border-color: var(--accent); } |
| .contributor-card .avatar { width: 48px; height: 48px; border-radius: 50%; flex: none; border: 2px solid var(--border); background: var(--surface-2); object-fit: cover; } |
| .contributor-card .meta { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; } |
| .contributor-card .meta-top { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: start; } |
| .contributor-card .identity { display: flex; align-items: center; gap: 8px; min-width: 0; flex-wrap: wrap; } |
| .contributor-card .links { display: flex; align-items: center; gap: 6px; justify-content: flex-end; flex-wrap: wrap; } |
| .contributor-card .meta-top .name { color: var(--accent); font-weight: 600; font-size: 15px; text-decoration: none; } |
| .contributor-card .meta-top .name:hover { text-decoration: underline; } |
| .contributor-card .meta-top .pr-link { font-size: 12px; color: var(--text-dim); text-decoration: none; padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; } |
| .contributor-card .meta-top .pr-link:hover { color: var(--accent); border-color: var(--accent); } |
| .contributor-card .meta-top .merged-at { font-size: 11px; color: var(--text-dim); } |
| .contributor-card .pr-title { font-weight: 500; margin-top: 2px; font-size: 13px; line-height: 1.45; } |
| .contributor-card .summary-toggle { margin-top: 6px; font-size: 12px; color: var(--text-dim); } |
| .contributor-card .summary-toggle summary { cursor: pointer; width: fit-content; user-select: none; } |
| .contributor-card .summary-toggle summary:hover { color: var(--accent); } |
| .contributor-card .summary { font-size: 13px; color: var(--text-muted); line-height: 1.65; margin-top: 6px; } |
| .contributor-card .rarity-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 38px; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 800; letter-spacing: 0.7px; font-family: var(--font-mono, ui-monospace, monospace); line-height: 1.5; } |
| |
| |
| |
| .contributor-card .rarity-GOD { |
| background: linear-gradient(110deg, #ffd700 0%, #ff6b9d 25%, #c084fc 50%, #60a5fa 75%, #34d399 100%); |
| background-size: 220% 220%; |
| color: #1a1208; |
| text-shadow: 0 0 6px rgba(255,255,255,0.55); |
| font-weight: 900; |
| box-shadow: 0 0 0 1px rgba(255,215,0,0.55), 0 0 14px rgba(192,132,252,0.4); |
| animation: rarity-god-pulse 4s ease-in-out infinite; |
| } |
| @keyframes rarity-god-pulse { |
| 0%, 100% { background-position: 0% 50%; } |
| 50% { background-position: 100% 50%; } |
| } |
| .contributor-card .rarity-MR { |
| background: linear-gradient(135deg, #06b6d4 0%, #8b5cf6 50%, #ec4899 100%); |
| color: #fff; |
| font-weight: 800; |
| box-shadow: 0 0 0 1px rgba(139,92,246,0.45), 0 0 10px rgba(6,182,212,0.25); |
| } |
| .contributor-card .rarity-LR { |
| background: linear-gradient(135deg, #a855f7 0%, #6366f1 50%, #06b6d4 100%); |
| color: #fff; |
| font-weight: 800; |
| box-shadow: 0 0 0 1px rgba(99,102,241,0.42); |
| } |
| .contributor-card .rarity-UR { background: linear-gradient(135deg, #f5d06f, #e11d48 52%, #7c3aed); color: #fff; box-shadow: 0 0 0 1px rgba(225,29,72,0.28); } |
| .contributor-card .rarity-SSR { background: linear-gradient(135deg, #f59e0b, #b45309); color: #fff7ed; box-shadow: 0 0 0 1px rgba(180,83,9,0.25); } |
| .contributor-card .rarity-SR { background: var(--info-soft); color: var(--info); border: 1px solid rgba(59,130,246,0.28); } |
| .contributor-card .rarity-R { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); } |
| .contributor-card .contribution-count { font-size: 11px; color: var(--text-dim); padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; line-height: 1.5; } |
| .contributor-card .history-list { list-style: none; padding: 0; margin: 6px 0 0; display: flex; flex-direction: column; gap: 8px; } |
| .contributor-card .history-item { padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); } |
| .contributor-card .history-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } |
| .contributor-card .history-head .pr-link { font-size: 12px; color: var(--text-dim); text-decoration: none; padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; } |
| .contributor-card .history-head .pr-link:hover { color: var(--accent); border-color: var(--accent); } |
| .contributor-card .history-head .merged-at { font-size: 11px; color: var(--text-dim); } |
| .contributor-card .history-rarity-label { font-size: 11px; color: var(--text-dim); font-style: italic; flex: 1; min-width: 0; } |
| .contributor-card .history-title { font-size: 13px; font-weight: 500; margin-top: 6px; line-height: 1.45; } |
| .contributor-card .history-summary { font-size: 12px; color: var(--text-muted); line-height: 1.65; margin-top: 6px; } |
| @media (max-width: 640px) { |
| .contributor-list { grid-template-columns: 1fr; } |
| .contributor-card { padding: 12px; gap: 12px; } |
| .contributor-card .avatar { width: 40px; height: 40px; } |
| .contributor-card .meta-top { grid-template-columns: 1fr; } |
| .contributor-card .links { justify-content: flex-start; } |
| } |
| </style> |
| <script type="module"> |
| import { initializeApp } from 'https://www.gstatic.com/firebasejs/11.6.0/firebase-app.js'; |
| import { getAuth, signInWithPopup, GoogleAuthProvider, GithubAuthProvider } from 'https://www.gstatic.com/firebasejs/11.6.0/firebase-auth.js'; |
| const _fbApp = initializeApp({ |
| apiKey: 'AIzaSyDsOl-1XpT5err0Tcnx8FFod1H8gVGIycY', |
| authDomain: 'exa2-fb170.firebaseapp.com', |
| projectId: 'exa2-fb170', |
| }); |
| const _fbAuth = getAuth(_fbApp); |
| window._firebaseOAuth = async function(provider) { |
| const p = provider === 'github' ? new GithubAuthProvider() : new GoogleAuthProvider(); |
| if (provider === 'google') p.addScope('email'); |
| const result = await signInWithPopup(_fbAuth, p); |
| const idToken = await result.user.getIdToken(); |
| return { |
| idToken, |
| refreshToken: result.user.stsTokenManager?.refreshToken || '', |
| email: result.user.email || '', |
| provider, |
| }; |
| }; |
| </script> |
| </head> |
| <body> |
|
|
| |
| <aside class="sidebar"> |
| <div class="brand"> |
| <div class="brand-logo">W</div> |
| <div> |
| <div class="brand-name">WindsurfAPI <span style="font-size:10px;opacity:.6;vertical-align:top;margin-left:2px">bydwgx1337</span></div> |
| <div class="brand-sub" data-i18n="brand.sub">管理控制台</div> |
| </div> |
| </div> |
| <nav> |
| <div class="nav-group"> |
| <div class="nav-group-label" data-i18n="nav.group.overview">概览</div> |
| <a href="#overview" class="active" data-panel="overview"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg> |
| <span data-i18n="nav.overview">仪表盘</span> |
| </a> |
| <a href="#stats" data-panel="stats"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg> |
| <span data-i18n="nav.stats">统计分析</span> |
| </a> |
| </div> |
| <div class="nav-group"> |
| <div class="nav-group-label" data-i18n="nav.group.account">账号</div> |
| <a href="#windsurf-login" data-panel="windsurf-login"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg> |
| <span data-i18n="nav.windsurf-login">登录取号</span> |
| </a> |
| <a href="#accounts" data-panel="accounts"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg> |
| <span data-i18n="nav.accounts">账号管理</span> |
| </a> |
| <a href="#bans" data-panel="bans"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> |
| <span data-i18n="nav.bans">异常监测</span> |
| </a> |
| </div> |
| <div class="nav-group"> |
| <div class="nav-group-label" data-i18n="nav.group.system">系统</div> |
| <a href="#models" data-panel="models"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg> |
| <span data-i18n="nav.models">模型控制</span> |
| </a> |
| <a href="#proxy" data-panel="proxy"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> |
| <span data-i18n="nav.proxy">代理配置</span> |
| </a> |
| <a href="#logs" data-panel="logs"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg> |
| <span data-i18n="nav.logs">运行日志</span> |
| </a> |
| <a href="#experimental" data-panel="experimental"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10 2v7.31"/><path d="M14 9.3V2"/><path d="M8.5 2h7"/><path d="M14 9.3a6.5 6.5 0 1 1-4 0"/></svg> |
| <span data-i18n="nav.experimental">实验性功能</span> |
| </a> |
| </div> |
| <div class="nav-group"> |
| <div class="nav-group-label" data-i18n="nav.group.about">关于</div> |
| <a href="#credits" data-panel="credits"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg> |
| <span data-i18n="nav.credits">致谢</span> |
| </a> |
| </div> |
| </nav> |
| <div class="footer"> |
| <button id="lang-toggle-btn" class="btn btn-ghost btn-xs" onclick="App.toggleLang()" title="Switch language" style="margin-right:8px"> |
| <span id="lang-indicator">中文</span> |
| </button> |
| <span>© Windsurf</span> |
| <span class="ver" id="sidebar-ver" data-i18n-title="footer.version" title="版本">v1.2.0</span> |
| </div> |
| </aside> |
|
|
| |
| <main class="main"> |
| |
| <section class="panel active" id="p-overview"> |
| <div class="page-header"> |
| <div> |
| <h1 class="page-title" data-i18n="page.overview">仪表盘</h1> |
| <div class="page-subtitle" data-i18n="pageSubtitle.overview">系统运行状态与关键指标概览</div> |
| </div> |
| <div class="btn-group"> |
| <button class="btn btn-outline btn-sm" id="btn-check-update" onclick="App.checkUpdate()"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg> |
| <span data-i18n="action.checkUpdate">检查更新</span> |
| </button> |
| <button class="btn btn-primary btn-sm hidden" id="btn-apply-update" onclick="App.applyUpdate()"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 2v20M2 12h20"/></svg> |
| <span id="btn-apply-update-text" data-i18n="action.applyUpdate">一键更新并重启</span> |
| </button> |
| </div> |
| </div> |
| <div id="update-status" class="section hidden" style="padding:12px;margin-bottom:12px"></div> |
| <div class="metrics-grid" id="overview-cards"></div> |
| <div class="section"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.lsStatus.title">Language Server</div> |
| <div class="section-desc" data-i18n="section.lsStatus.desc">语言服务器实例状态</div> |
| </div> |
| <div style="display:flex;gap:8px"> |
| <button class="btn btn-outline btn-sm" onclick="App.updateLsBinary()" title="Download latest language_server binary and restart pool"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> |
| <span data-i18n="action.updateLsBinary">更新 LS</span> |
| </button> |
| <button class="btn btn-outline btn-sm" onclick="App.restartLs()"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg> |
| <span data-i18n="action.restart">重启</span> |
| </button> |
| </div> |
| </div> |
| <div class="section-body"> |
| <div id="ls-status"></div> |
| <div id="ls-binary-info" style="margin-top:12px;font-size:12px;color:var(--text-muted)"></div> |
| </div> |
| </div> |
| </section> |
|
|
| |
| <section class="panel" id="p-windsurf-login"> |
| <div class="page-header"> |
| <div> |
| <h1 class="page-title" data-i18n="page.windsurf-login">登录取号</h1> |
| <div class="page-subtitle" data-i18n="pageSubtitle.windsurf-login">支持 Google / GitHub / 邮箱密码 三种方式登录 Windsurf 获取 API Key</div> |
| </div> |
| </div> |
|
|
| <div class="section"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.oauthLogin.title">快捷登录(推荐)</div> |
| <div class="section-desc" data-i18n="section.oauthLogin.desc">用你的 Google 或 GitHub 账号直接登录 Windsurf 无需密码</div> |
| </div> |
| </div> |
| <div class="section-body"> |
| <div style="display:flex;gap:12px;flex-wrap:wrap"> |
| <button class="btn" onclick="App.oauthLogin('google')" id="oauth-google-btn" style="background:#4285F4;color:#fff;padding:10px 24px;font-weight:600;display:flex;align-items:center;gap:8px"> |
| <svg width="18" height="18" viewBox="0 0 48 48"><path fill="#FFC107" d="M43.6 20.1H42V20H24v8h11.3C33.9 33.1 29.3 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6 29.3 4 24 4 13 4 4 13 4 24s9 20 20 20 20-9 20-20c0-1.3-.2-2.7-.4-3.9z"/><path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.5 15.5 18.8 12 24 12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"/><path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.6-11.2-8.5l-6.5 5C9.5 39.6 16.2 44 24 44z"/><path fill="#1976D2" d="M43.6 20.1H42V20H24v8h11.3c-.8 2.2-2.2 4.1-4.1 5.6l6.2 5.2C37 39.2 44 34 44 24c0-1.3-.2-2.7-.4-3.9z"/></svg> |
| <span data-i18n="oauth.google">Google 登录</span> |
| </button> |
| <button class="btn" onclick="App.oauthLogin('github')" id="oauth-github-btn" style="background:#24292e;color:#fff;padding:10px 24px;font-weight:600;display:flex;align-items:center;gap:8px"> |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.2 11.38.6.11.82-.26.82-.58v-2.03c-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.08-.74.08-.73.08-.73 1.2.08 1.84 1.23 1.84 1.23 1.07 1.83 2.81 1.3 3.5 1 .1-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.52.12-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 016.02 0c2.28-1.55 3.29-1.23 3.29-1.23.66 1.66.25 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.8 5.63-5.48 5.92.43.37.82 1.1.82 2.22v3.29c0 .32.21.7.82.58C20.56 21.8 24 17.3 24 12 24 5.37 18.63 0 12 0z"/></svg> |
| <span data-i18n="oauth.github">GitHub 登录</span> |
| </button> |
| </div> |
| <div id="oauth-status" style="margin-top:8px;font-size:12px;color:var(--text-muted)"></div> |
| </div> |
| </div> |
|
|
| <div class="section"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.emailLogin.title">邮箱密码登录</div> |
| <div class="section-desc" data-i18n="section.emailLogin.desc">仅限邮箱+密码注册的账号 第三方登录请用上面的按钮;支持单个登录或按“邮箱 密码”格式批量导入</div> |
| </div> |
| </div> |
| <div class="section-body"> |
| <div class="field-row"> |
| <div class="field"> |
| <label class="field-label" data-i18n="field.email.label">邮箱</label> |
| <input id="wl-email" class="input" data-i18n-placeholder="field.email.placeholder" placeholder="your-email@example.com" autocomplete="off"> |
| </div> |
| <div class="field"> |
| <label class="field-label" data-i18n="field.password.label">密码</label> |
| <input id="wl-password" class="input" type="password" data-i18n-placeholder="field.password.placeholder" placeholder="••••••••" autocomplete="off" onkeydown="if(event.key==='Enter')App.windsurfLogin()"> |
| </div> |
| </div> |
| <div class="field-row-actions"> |
| <button class="btn btn-primary" onclick="App.windsurfLogin()" id="wl-btn"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg> |
| <span data-i18n="action.login">登录</span> |
| </button> |
| <label class="checkbox" style="margin-left:auto"> |
| <input type="checkbox" id="wl-auto-add" checked> |
| <span class="box"></span> |
| <span data-i18n="field.autoAdd">登录成功自动加入账号池</span> |
| </label> |
| </div> |
| <div style="margin-top:16px;padding-top:16px;border-top:1px dashed var(--border)"> |
| <label class="field-label" data-i18n="field.batchInput.label">批量导入</label> |
| <textarea |
| id="wl-batch-input" |
| class="textarea" |
| rows="6" |
| placeholder="user1@mail.com pass123 http://proxy:8080 user2@mail.com pass456 socks5://user:pass@1.2.3.4:1080 user3@mail.com pass789" |
| ></textarea> |
| <div class="field-hint" data-i18n="field.batchInput.hint">每行一个账号。格式:<code>[代理] 邮箱 密码</code>。代理可选,支持 http/socks5。会自动绑定代理到对应账号。</div> |
| <div class="field-row-actions"> |
| <button class="btn btn-outline" onclick="App.pasteWindsurfBatch()"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg> |
| <span data-i18n="action.readClipboard">读取剪贴板</span> |
| </button> |
| <button class="btn btn-primary" onclick="App.windsurfLoginBatch()" id="wl-batch-btn"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></svg> |
| <span data-i18n="action.batchImport">批量导入并登录</span> |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="section hidden" id="wl-result"></div> |
|
|
| <div class="section"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.loginHistory.title">登录历史</div> |
| <div class="section-desc" data-i18n="section.loginHistory.desc">本地记录最近 50 条登录操作</div> |
| </div> |
| </div> |
| <div class="section-body tight"> |
| <div class="table-wrap"> |
| <table id="wl-history-table"> |
| <thead><tr><th data-i18n="table.header.time">时间</th><th data-i18n="table.header.email">邮箱</th><th data-i18n="table.header.status">状态</th><th data-i18n="table.header.proxy">代理</th><th data-i18n="table.header.action">操作</th></tr></thead> |
| <tbody></tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="section" id="wl-proxy-section"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.loginProxy.title">登录代理(可选)</div> |
| <div class="section-desc" data-i18n="section.loginProxy.desc">为本次登录指定代理;留空则使用全局代理设置。代理生效后账号的后续聊天请求也会经此代理出站</div> |
| </div> |
| </div> |
| <div class="section-body"> |
| <div class="field-row"> |
| <div class="field" style="max-width:120px"> |
| <label class="field-label" data-i18n="field.type.label">类型</label> |
| <select id="wl-proxy-type" class="select"> |
| <option value="http">HTTP</option> |
| <option value="https">HTTPS</option> |
| <option value="socks5">SOCKS5</option> |
| </select> |
| </div> |
| <div class="field" style="flex:2"> |
| <label class="field-label" data-i18n="field.proxyHost.label">主机</label> |
| <input id="wl-proxy-host" class="input" data-i18n-placeholder="field.proxyHost.placeholder" placeholder="留空=使用全局"> |
| </div> |
| <div class="field" style="max-width:110px"> |
| <label class="field-label" data-i18n="field.port.label">端口</label> |
| <input id="wl-proxy-port" class="input" data-i18n-placeholder="field.port.placeholder" placeholder="8080" type="number"> |
| </div> |
| <div class="field"> |
| <label class="field-label" data-i18n="field.username.label">用户名</label> |
| <input id="wl-proxy-user" class="input" data-i18n-placeholder="field.username.placeholder" placeholder="可选"> |
| </div> |
| <div class="field"> |
| <label class="field-label" data-i18n="field.passwordProxy.label">密码</label> |
| <input id="wl-proxy-pass" class="input" type="password" data-i18n-placeholder="field.passwordProxy.placeholder" placeholder="可选"> |
| </div> |
| </div> |
| <div style="display:flex;gap:8px;align-items:center;margin-top:10px"> |
| <button class="btn btn-outline btn-sm" onclick="App.testLoginProxy()" id="wl-proxy-test-btn"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="10"/></svg> |
| <span data-i18n="proxy.test.button">测试代理</span> |
| </button> |
| <span id="wl-proxy-test-result" class="text-sm text-muted"></span> |
| </div> |
| </div> |
| </div> |
| </section> |
|
|
| |
| <section class="panel" id="p-accounts"> |
| <div class="page-header"> |
| <div> |
| <h1 class="page-title" data-i18n="page.accounts">账号管理</h1> |
| <div class="page-subtitle" data-i18n="pageSubtitle.accounts">维护账号池,探测各账号订阅层级与可用模型</div> |
| </div> |
| <div class="btn-group"> |
| <button class="btn btn-outline" onclick="App.refreshAllCredits()"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg> |
| <span data-i18n="action.refresh">刷新余额</span> |
| </button> |
| <button class="btn btn-outline" onclick="App.probeAll()"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> |
| <span data-i18n="action.probe">全部探测</span> |
| </button> |
| </div> |
| </div> |
| |
| |
| <div id="drought-banner" class="banner banner-warn" style="display:none;margin:0 0 16px;padding:14px 18px;border-radius:var(--radius);background:#fff7e6;border:1px solid #ffd591;color:#874d00"> |
| <strong data-i18n="drought.title">配额预警:</strong> |
| <span id="drought-message" data-i18n="drought.body">所有账号本周配额都低于阈值,建议补账号或等待重置</span> |
| <span id="drought-detail" class="text-sm text-muted" style="margin-left:8px"></span> |
| </div> |
|
|
| <div class="section"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.addAccount.title">添加账号</div> |
| <div class="section-desc" data-i18n="section.addAccount.desc">支持 API Key 或 Auth Token 两种方式</div> |
| </div> |
| </div> |
| <div class="section-body"> |
| <div style="padding:8px 12px;margin-bottom:12px;background:var(--surface-2);border-radius:var(--radius);font-size:12px;color:var(--text-muted);line-height:1.6"> |
| <span data-i18n="help.tokenGuide">推荐用 Token 方式添加账号 点</span> |
| <a href="https://windsurf.com/show-auth-token" target="_blank" style="color:var(--accent);font-weight:600">windsurf.com/show-auth-token</a> |
| <span data-i18n="help.tokenGuideEnd">登录后复制 Token 粘贴到下面就行 所有登录方式(邮箱/Google/GitHub)都能用</span> |
| </div> |
| <div class="field-row"> |
| <div class="field" style="max-width:180px"> |
| <label class="field-label" data-i18n="field.type.label">类型</label> |
| <select id="acc-type" class="select"> |
| <option value="token" selected>Auth Token</option> |
| <option value="api_key">API Key</option> |
| </select> |
| </div> |
| <div class="field" style="flex:2"> |
| <label class="field-label" data-i18n="field.key.label">Key / Token</label> |
| <input id="acc-key" class="input" data-i18n-placeholder="field.key.placeholder" placeholder="粘贴 Auth Token(从 windsurf.com/show-auth-token 获取)"> |
| </div> |
| <div class="field" style="max-width:200px"> |
| <label class="field-label" data-i18n="field.label.label">标签</label> |
| <input id="acc-label" class="input" data-i18n-placeholder="field.label.placeholder" placeholder="可选"> |
| </div> |
| </div> |
| <div class="field-row" style="margin-top:12px"> |
| <div class="field" style="flex:1"> |
| <label class="field-label" data-i18n="field.proxy.label">代理</label> |
| <input id="acc-proxy" class="input" data-i18n-placeholder="field.proxy.placeholder" placeholder="http://proxy:8080 或 socks5://user:pass@host:port(可选)"> |
| </div> |
| </div> |
| <div class="field-hint" style="margin-top:4px" data-i18n="field.proxy.hint">留空则使用全局代理设置</div> |
| <div class="field-row-actions"> |
| <button class="btn btn-primary" onclick="App.addAccount()" data-i18n="action.add">添加账号</button> |
| <button id="local-import-btn" class="btn btn-outline" onclick="App.discoverLocalWindsurf()" data-i18n="action.importLocal" title="从本地 Windsurf 客户端读取已登录的凭证(仅本机访问)">从本地 Windsurf 导入</button> |
| </div> |
| <div id="local-import-hint" style="margin-top:8px;color:var(--text-muted);font-size:12px;line-height:1.6"></div> |
| <div id="local-windsurf-result" style="margin-top:12px"></div> |
| </div> |
| </div> |
|
|
| <div id="ls-pool-card"></div> |
|
|
| <div class="section"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.accountList.title">账号列表</div> |
| <div class="section-desc" data-i18n="section.accountList.desc">点击探测按钮可单独更新某个账号的能力信息</div> |
| </div> |
| </div> |
| <div class="section-body tight"> |
| <div class="table-wrap"> |
| <table id="accounts-table"> |
| <thead><tr><th data-i18n="table.header.id">ID</th><th data-i18n="table.header.label">标签</th><th data-i18n="table.header.tier">层级</th><th data-i18n="table.header.rpm">RPM</th><th class="th-help" data-i18n="table.header.credits" data-i18n-title="creditsBar.columnTooltip" title="配额使用情况 日 — 每日高级请求剩余百分比(重置频繁) 周 — 每周高级请求剩余百分比(Trial 账号重点看这条) 数值越高 = 剩余越多 = 越安全 悬停单条进度条可查看重置时间">余额</th><th data-i18n="table.header.models">可用模型</th><th data-i18n="table.header.status">状态</th><th data-i18n="table.header.error">错误</th><th data-i18n="table.header.lastUsed">最后使用</th><th data-i18n="table.header.key">Key</th><th data-i18n="table.header.action">操作</th></tr></thead> |
| <tbody></tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| </section> |
|
|
| |
| <section class="panel" id="p-models"> |
| <div class="page-header"> |
| <div> |
| <h1 class="page-title" data-i18n="page.models">模型控制</h1> |
| <div class="page-subtitle" data-i18n="pageSubtitle.models">配置模型访问策略,限制可用模型范围</div> |
| </div> |
| </div> |
|
|
| <div class="section"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.modelPolicy.title">访问策略</div> |
| <div class="section-desc" data-i18n="section.modelPolicy.desc">选择"全部允许"不限制;"白名单"只允许列出的模型;"黑名单"屏蔽列出的模型</div> |
| </div> |
| </div> |
| <div class="section-body"> |
| <div class="segmented" id="model-mode-group"> |
| <label><input type="radio" name="model-mode" value="all" onchange="App.setModelMode(this.value)"><span data-i18n="model.mode.all">全部允许</span></label> |
| <label><input type="radio" name="model-mode" value="allowlist" onchange="App.setModelMode(this.value)"><span data-i18n="model.mode.allowlist">白名单</span></label> |
| <label><input type="radio" name="model-mode" value="blocklist" onchange="App.setModelMode(this.value)"><span data-i18n="model.mode.blocklist">黑名单</span></label> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="section hidden" id="model-list-section"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" id="model-list-title" data-i18n="section.modelList.title">模型清单</div> |
| <div class="section-desc" id="model-list-hint"></div> |
| </div> |
| </div> |
| <div class="section-body"> |
| <div class="field-row"> |
| <div class="field"> |
| <label class="field-label" data-i18n="field.searchModel.label">搜索模型</label> |
| <input id="model-search" class="input" data-i18n-placeholder="field.searchModel.placeholder" placeholder="输入模型名称筛选..." oninput="App.filterModels()"> |
| </div> |
| <div class="field" style="max-width:220px"> |
| <label class="field-label" data-i18n="field.provider.label">供应商</label> |
| <select id="model-provider-filter" class="select" onchange="App.filterModels()"> |
| <option value="" data-i18n="field.provider.all">全部供应商</option> |
| <option value="anthropic">Anthropic</option> |
| <option value="openai">OpenAI</option> |
| <option value="google">Google</option> |
| <option value="deepseek">DeepSeek</option> |
| <option value="xai">xAI</option> |
| <option value="alibaba">Alibaba</option> |
| <option value="moonshot">Moonshot</option> |
| <option value="windsurf">Windsurf</option> |
| </select> |
| </div> |
| </div> |
| <div id="model-list-current" class="mt-4"></div> |
| <div id="model-chips-container" class="mt-4"></div> |
| </div> |
| </div> |
| </section> |
|
|
| |
| <section class="panel" id="p-proxy"> |
| <div class="page-header"> |
| <div> |
| <h1 class="page-title" data-i18n="page.proxy">代理配置</h1> |
| <div class="page-subtitle" data-i18n="pageSubtitle.proxy">全局或按账号配置出口代理,独立代理将启动独立的语言服务器实例</div> |
| </div> |
| </div> |
|
|
| <div class="section"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.globalProxy.title">全局代理</div> |
| <div class="section-desc" data-i18n="section.globalProxy.desc">未配置独立代理的账号将使用此配置</div> |
| </div> |
| </div> |
| <div class="section-body"> |
| <div class="field-row"> |
| <div class="field" style="max-width:140px"> |
| <label class="field-label" data-i18n="field.type.label">类型</label> |
| <select id="proxy-type" class="select"> |
| <option value="http">HTTP</option> |
| <option value="https">HTTPS</option> |
| <option value="socks5">SOCKS5</option> |
| </select> |
| </div> |
| <div class="field"> |
| <label class="field-label" data-i18n="field.host.label">主机</label> |
| <input id="proxy-host" class="input" data-i18n-placeholder="field.host.placeholder" placeholder="proxy.example.com"> |
| </div> |
| <div class="field" style="max-width:140px"> |
| <label class="field-label" data-i18n="field.port.label">端口</label> |
| <input id="proxy-port" class="input" type="number" data-i18n-placeholder="field.port.placeholder" placeholder="8080"> |
| </div> |
| </div> |
| <div class="field-row mt-3"> |
| <div class="field"> |
| <label class="field-label" data-i18n="field.username.label">用户名</label> |
| <input id="proxy-user" class="input" data-i18n-placeholder="field.proxyUser.placeholder" placeholder="代理认证用户名"> |
| </div> |
| <div class="field"> |
| <label class="field-label" data-i18n="field.passwordProxy.label">密码</label> |
| <div class="input-group"> |
| <input id="proxy-pass" class="input" type="password" data-i18n-placeholder="field.proxyPass.placeholder" placeholder="代理认证密码"> |
| <button class="btn btn-outline btn-icon" onclick="const el=document.getElementById('proxy-pass');el.type=el.type==='password'?'text':'password'" data-i18n-title="action.showPassword" title="显示/隐藏密码"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> |
| </button> |
| </div> |
| </div> |
| </div> |
| <div class="field-row-actions"> |
| <button class="btn btn-primary" onclick="App.saveGlobalProxy()" data-i18n="action.save">Save</button> |
| <button class="btn btn-outline" onclick="App.clearGlobalProxy()" data-i18n="action.clear">Clear</button> |
| <span id="proxy-current" class="text-sm text-muted" style="margin-left:auto;align-self:center"></span> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="section"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.accountProxy.title">账号独立代理</div> |
| <div class="section-desc" data-i18n="section.accountProxy.desc">为特定账号设置专属代理;每个独立代理会启动独立 LS 实例</div> |
| </div> |
| </div> |
| <div class="section-body tight"> |
| <div class="table-wrap"> |
| <table id="proxy-accounts-table"> |
| <thead><tr><th data-i18n="table.header.account">账号</th><th data-i18n="table.header.proxy">代理</th><th data-i18n="table.header.action">操作</th></tr></thead> |
| <tbody></tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| </section> |
|
|
| |
| <section class="panel" id="p-logs"> |
| <div class="page-header"> |
| <div> |
| <h1 class="page-title" data-i18n="page.logs">运行日志</h1> |
| <div class="page-subtitle" data-i18n="pageSubtitle.logs">通过 SSE 实时流式接收服务端日志</div> |
| </div> |
| </div> |
| <div class="log-controls"> |
| <select id="log-level" class="select" style="width:130px" onchange="App.filterLogs()"> |
| <option value="" data-i18n="log.level.all">所有级别</option> |
| <option value="debug">Debug</option> |
| <option value="info">Info</option> |
| <option value="warn">Warn</option> |
| <option value="error">Error</option> |
| </select> |
| <input id="log-search" class="input search" data-i18n-placeholder="log.searchPlaceholder" placeholder="搜索日志内容..." oninput="App.debouncedFilterLogs()"> |
| <label class="checkbox"> |
| <input type="checkbox" id="log-autoscroll" checked> |
| <span class="box"></span> |
| <span data-i18n="field.autoScroll">自动滚动</span> |
| </label> |
| <button class="btn btn-outline btn-sm" onclick="App.clearLogView()" data-i18n="log.clearView">清空视图</button> |
| |
| |
| |
| <select id="log-export-type" class="select" style="width:130px"> |
| <option value="all" data-i18n="log.export.all">全部日志</option> |
| <option value="api" data-i18n="log.export.api">API 请求日志</option> |
| <option value="system" data-i18n="log.export.system">系统运行日志</option> |
| </select> |
| <select id="log-export-format" class="select" style="width:120px"> |
| <option value="jsonl">JSONL</option> |
| <option value="txt" data-i18n="log.export.txt">纯文本</option> |
| </select> |
| <button class="btn btn-primary btn-sm" onclick="App.exportLogs()" data-i18n="log.export.btn">下载日志</button> |
| </div> |
| <div class="log-container" id="log-container"></div> |
| </section> |
|
|
| |
| <section class="panel" id="p-stats"> |
| <div class="page-header"> |
| <div> |
| <h1 class="page-title" data-i18n="page.stats">统计分析</h1> |
| <div class="page-subtitle" data-i18n="pageSubtitle.stats">请求量、成功率、延迟分位数与账号/模型维度统计</div> |
| </div> |
| <div class="btn-group"> |
| <button class="btn btn-ghost btn-sm" onclick="App.loadStats()" data-i18n-title="action.refresh" title="刷新"> |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg> |
| <span data-i18n="action.refresh">刷新</span> |
| </button> |
| <button class="btn btn-danger btn-sm" onclick="App.resetStats()" data-i18n="action.reset">重置统计</button> |
| </div> |
| </div> |
| <div class="metrics-grid" id="stats-cards"></div> |
| <div class="chart chart-main"> |
| <div class="chart-header"> |
| <div class="chart-title-group"> |
| <h3 data-i18n="section.requestChart.title">请求量时间序列</h3> |
| <div class="chart-subtitle" data-i18n="section.requestChart.subtitle">切换图表类型和时间范围 · 悬停查看具体数值</div> |
| </div> |
| <div class="chart-controls"> |
| <div class="seg-group" role="tablist"> |
| <button class="seg-btn stats-type-btn" data-type="area" onclick="App.setChartType('area')" data-i18n="section.requestChart.typeArea">面积图</button> |
| <button class="seg-btn stats-type-btn" data-type="line" onclick="App.setChartType('line')" data-i18n="section.requestChart.typeLine">折线图</button> |
| <button class="seg-btn stats-type-btn active" data-type="bar" onclick="App.setChartType('bar')" data-i18n="section.requestChart.typeBar">柱状图</button> |
| <button class="seg-btn stats-type-btn" data-type="stacked" onclick="App.setChartType('stacked')" data-i18n="section.requestChart.typeStacked">堆叠图</button> |
| </div> |
| <div class="seg-group" role="tablist"> |
| <button class="seg-btn stats-range-btn" data-range="6" onclick="App.setStatsRange(6)" data-i18n="range.6h">6小时</button> |
| <button class="seg-btn stats-range-btn active" data-range="24" onclick="App.setStatsRange(24)" data-i18n="range.24h">24小时</button> |
| <button class="seg-btn stats-range-btn" data-range="168" onclick="App.setStatsRange(168)" data-i18n="range.7d">7天</button> |
| <button class="seg-btn stats-range-btn" data-range="720" onclick="App.setStatsRange(720)" data-i18n="range.30d">30天</button> |
| <button class="seg-btn stats-range-btn" data-range="all" onclick="App.setStatsRange('all')" data-i18n="range.all">全部</button> |
| <button class="seg-btn stats-range-btn" data-range="custom" onclick="App.openCustomRange()" data-i18n="range.custom">自定义</button> |
| </div> |
| </div> |
| </div> |
| <div class="chart-canvas-wrap"> |
| <canvas id="stats-canvas"></canvas> |
| <div class="chart-tooltip" id="stats-tooltip"></div> |
| <div class="chart-range-indicator" id="stats-range-indicator"></div> |
| </div> |
| <div class="custom-range-popup" id="custom-range-popup" style="display:none"> |
| <div class="custom-range-title" data-i18n="range.customTitle">自定义日期范围</div> |
| <div class="custom-range-fields"> |
| <label> |
| <span data-i18n="range.customStart">起始日期</span> |
| <input type="date" id="range-start" class="input"> |
| </label> |
| <label> |
| <span data-i18n="range.customEnd">结束日期</span> |
| <input type="date" id="range-end" class="input"> |
| </label> |
| </div> |
| <div class="custom-range-hint" data-i18n="range.customHint">最多显示最近 30 天数据</div> |
| <div class="custom-range-actions"> |
| <button class="btn btn-ghost btn-sm" onclick="App.closeCustomRange()" data-i18n="range.customCancel">取消</button> |
| <button class="btn btn-primary btn-sm" onclick="App.applyCustomRange()" data-i18n="range.customApply">应用</button> |
| </div> |
| </div> |
| <div class="chart-legend" id="stats-legend"></div> |
| </div> |
| <div class="chart-grid"> |
| <div class="chart chart-pie"> |
| <div class="chart-header"> |
| <div class="chart-title-group"> |
| <h3 data-i18n="section.modelPie.title">模型请求量分布</h3> |
| <div class="chart-subtitle" data-i18n="section.modelPie.desc">前 8 个模型的请求量占比</div> |
| </div> |
| </div> |
| <div class="chart-pie-body" style="position:relative"> |
| <canvas id="model-pie-canvas"></canvas> |
| <div class="chart-tooltip" id="model-pie-tooltip"></div> |
| <div class="pie-legend" id="model-pie-legend"></div> |
| </div> |
| </div> |
| </div> |
| <div class="section"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.modelStats.title">模型使用统计</div> |
| <div class="section-desc" data-i18n="section.modelStats.desc">按模型聚合:请求量、成功率、平均耗时与 p50 / p95 延迟分位数</div> |
| </div> |
| </div> |
| <div class="section-body tight"> |
| <div class="table-wrap"> |
| <table id="model-stats-table"> |
| <thead><tr><th data-i18n="table.header.model">模型</th><th data-i18n="table.header.requests">请求</th><th data-i18n="table.header.success">成功</th><th data-i18n="table.header.errors">错误</th><th data-i18n="table.header.successRate">成功率</th><th data-i18n="table.header.avg">平均</th><th data-i18n="table.header.p50">p50</th><th data-i18n="table.header.p95">p95</th></tr></thead> |
| <tbody></tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| <div class="section"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.accountStats.title">账号维度统计</div> |
| <div class="section-desc" data-i18n="section.accountStats.desc">每个账号 ID 前 8 位的请求量与成功率</div> |
| </div> |
| </div> |
| <div class="section-body tight"> |
| <div class="table-wrap"> |
| <table id="account-stats-table"> |
| <thead><tr><th data-i18n="table.header.accountId">账号 ID</th><th data-i18n="table.header.requests">请求</th><th data-i18n="table.header.success">成功</th><th data-i18n="table.header.errors">错误</th><th data-i18n="table.header.successRate">成功率</th></tr></thead> |
| <tbody></tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| </section> |
|
|
| |
| <section class="panel" id="p-bans"> |
| <div class="page-header"> |
| <div> |
| <h1 class="page-title" data-i18n="page.bans">异常监测</h1> |
| <div class="page-subtitle" data-i18n="pageSubtitle.bans">追踪错误账号和异常状态</div> |
| </div> |
| </div> |
| <div class="metrics-grid" id="ban-cards"></div> |
| <div class="section"> |
| <div class="section-header"> |
| <div class="section-title" data-i18n="section.banStatus.title">账号健康状况</div> |
| </div> |
| <div class="section-body tight"> |
| <div class="table-wrap"> |
| <table id="ban-table"> |
| <thead><tr><th data-i18n="table.header.account">账号</th><th data-i18n="table.header.status">状态</th><th data-i18n="table.header.errorCount">错误数</th><th data-i18n="table.header.lastUsed">最后使用</th><th data-i18n="table.header.action">操作</th></tr></thead> |
| <tbody></tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| </section> |
|
|
| <section class="panel" id="p-credits"> |
| <div class="page-header"> |
| <div> |
| <h1 class="page-title" data-i18n="page.credits">致谢</h1> |
| <div class="page-subtitle" data-i18n="page.credits.sub">感谢以下朋友为项目提交 PR / 审计代码 / 修复问题</div> |
| </div> |
| </div> |
| <div class="section"> |
| <div class="section-header"> |
| <div class="section-title" data-i18n="credits.contributors">核心贡献者</div> |
| </div> |
| <div class="section-body" id="credits-body"> |
| <div class="text-sm text-dim" data-i18n="status.loading">加载中…</div> |
| </div> |
| </div> |
| <div class="section"> |
| <div class="section-header"> |
| <div class="section-title" data-i18n="credits.cta">想加入这份名单?</div> |
| </div> |
| <div class="section-body"> |
| <div class="section-desc" data-i18n="credits.cta.desc">欢迎到 GitHub 提 issue 或 pull request — 不管是修 bug、加模型、改 UI 还是发现安全问题,都会被记录在这里。</div> |
| <div style="margin-top:12px"> |
| <a class="btn btn-outline btn-sm" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/issues"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:6px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> |
| <span data-i18n="credits.cta.issue">提 issue</span> |
| </a> |
| <a class="btn btn-outline btn-sm" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/pulls" style="margin-left:8px"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:6px"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" y1="9" x2="6" y2="21"/></svg> |
| <span data-i18n="credits.cta.pr">提 PR</span> |
| </a> |
| </div> |
| </div> |
| </div> |
| </section> |
|
|
| <section class="panel" id="p-experimental"> |
| <div class="page-header"> |
| <div> |
| <h1 class="page-title" data-i18n="page.experimental">实验性功能</h1> |
| <div class="page-subtitle" data-i18n="pageSubtitle.experimental">尚未稳定的优化项;如果发现异常可以随时关闭</div> |
| </div> |
| </div> |
| <div class="section"> |
| <div class="section-header"> |
| <div class="section-title" data-i18n="section.cascadeReuse.title">Cascade 对话复用</div> |
| </div> |
| <div class="section-body"> |
| <div class="section-desc" style="margin-bottom:12px" data-i18n="section.cascadeReuse.desc">多轮对话时复用同一个 cascade_id,只把最新一条 user 消息发给 Windsurf,让服务端维持上下文缓存。命中时可显著降低 TTFB 与上传体积;未命中或账号/LS 变更会自动回退到新会话。需要客户端保留完整历史并按顺序追加(如 new-api、OpenWebUI)。</div> |
| <div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;padding:12px;background:var(--surface-2);border-radius:var(--radius)"> |
| <label class="switch"> |
| <input type="checkbox" id="exp-cascade-reuse" onchange="App.toggleExperimental('cascadeConversationReuse', this.checked)"> |
| <span class="switch-slider"></span> |
| </label> |
| <div> |
| <div style="font-weight:500" data-i18n="section.cascadeReuse.enable">启用 Cascade 对话复用</div> |
| <div class="text-sm text-muted" data-i18n="section.cascadeReuse.hint">默认关闭。开启后对当前对话池立即生效。</div> |
| </div> |
| </div> |
| <div class="metrics-grid" id="exp-pool-cards"></div> |
| <div style="margin-top:12px"> |
| <button class="btn btn-outline btn-sm" onclick="App.clearConversationPool()" data-i18n="action.clearPool">清空对话池</button> |
| <button class="btn btn-ghost btn-sm" onclick="App.loadExperimental()" data-i18n="action.refresh">刷新</button> |
| </div> |
| </div> |
| </div> |
| <div class="section" style="margin-top:24px"> |
| <div class="section-header"> |
| <div class="section-title" data-i18n="section.droughtRestrict.title">配额低水位时屏蔽 premium 模型</div> |
| </div> |
| <div class="section-body"> |
| <div class="section-desc" style="margin-bottom:12px" data-i18n="section.droughtRestrict.desc">所有账号本周配额都低于 5% 时,对 premium 模型(claude-opus / claude-sonnet 等)请求直接返 503,避免上游 rate-limit 烧最后一点配额。免费层模型(gemini-2.5-flash 等)保持可用。关闭后即使在 drought mode 也照常下发,accept 上游可能立即 429。</div> |
| <div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)"> |
| <label class="switch"> |
| <input type="checkbox" id="exp-drought-restrict" onchange="App.toggleExperimental('droughtRestrictPremium', this.checked)"> |
| <span class="switch-slider"></span> |
| </label> |
| <div> |
| <div style="font-weight:500" data-i18n="section.droughtRestrict.enable">启用 drought-mode premium 屏蔽</div> |
| <div class="text-sm text-muted" data-i18n="section.droughtRestrict.hint">默认开启。drought 状态由账号池自动判定(GET /dashboard/api/drought 看实时值)。</div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| <div class="section" style="margin-top:24px"> |
| <div class="section-header"> |
| <div class="section-title" data-i18n="section.quietWindow.title">空档自动更新</div> |
| </div> |
| <div class="section-body"> |
| <div class="section-desc" style="margin-bottom:12px" data-i18n="section.quietWindow.desc">每分钟看一次最近 5 分钟内的请求量,连续在阈值以下就自动 docker compose pull + recreate。冷启动 10 分钟内不动;成功后 24 小时冷却;失败不冷却。需要 /var/run/docker.sock 挂进容器(默认 docker-compose 已挂)。</div> |
| <div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)"> |
| <label class="switch"> |
| <input type="checkbox" id="exp-quiet-window" onchange="App.toggleExperimental('autoUpdateQuietWindow', this.checked)"> |
| <span class="switch-slider"></span> |
| </label> |
| <div> |
| <div style="font-weight:500" data-i18n="section.quietWindow.enable">启用空档自动更新</div> |
| <div class="text-sm text-muted" data-i18n="section.quietWindow.hint">默认关闭。当前状态:<span id="quiet-window-status">加载中…</span></div> |
| </div> |
| <div style="margin-left:auto;display:flex;gap:8px"> |
| <button class="btn btn-ghost btn-sm" onclick="App.loadQuietWindowStatus()" data-i18n="action.refresh">刷新</button> |
| <button class="btn btn-outline btn-sm" onclick="App.runQuietWindowNow()" data-i18n="action.runNow">立即测试</button> |
| </div> |
| </div> |
| </div> |
| </div> |
| <div class="section" style="margin-top:24px"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.systemPrompts.title">系统提示词</div> |
| <div class="section-desc" data-i18n="section.systemPrompts.desc">工具注入、对话模式等内置提示词模板。修改后立即生效,点"重置"恢复默认。</div> |
| </div> |
| </div> |
| <div class="section-body" id="system-prompts-editor" style="display:grid;gap:14px"></div> |
| </div> |
| <div class="section" style="margin-top:24px"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.credentials.title">凭证管理</div> |
| <div class="section-desc" data-i18n="section.credentials.desc">运行时改 API_KEY 和 Dashboard 密码,写入 runtime-config.json 立即生效,不用重启容器或重读 .env。改完之后下一次请求需要用新值;当前会话会要求重新登录。</div> |
| </div> |
| </div> |
| <div class="section-body" style="display:grid;gap:14px"> |
| <div style="padding:12px;background:var(--surface-2);border-radius:var(--radius);display:grid;gap:8px"> |
| <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"> |
| <strong data-i18n="credentials.apiKey">API_KEY</strong> |
| <span class="text-sm text-muted" id="credentials-apikey-source"></span> |
| <span class="text-sm" style="font-family:monospace" id="credentials-apikey-masked"></span> |
| </div> |
| <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"> |
| <input type="password" class="input" id="credentials-apikey-input" data-i18n-placeholder="credentials.apiKeyPlaceholder" placeholder="新 API_KEY(≥ 8 字符;留空清除运行时覆盖)" style="flex:1;min-width:240px"> |
| <button class="btn btn-outline btn-sm" onclick="App.toggleApiKeyVisibility()" data-i18n="credentials.show">显示/隐藏</button> |
| <button class="btn btn-primary btn-sm" onclick="App.saveCredential('apiKey')" data-i18n="credentials.save">保存</button> |
| </div> |
| <div class="text-sm text-muted" data-i18n="credentials.apiKeyHint">改完后所有 chat 客户端要换成新 KEY;旧 KEY 立即失效。</div> |
| </div> |
| <div style="padding:12px;background:var(--surface-2);border-radius:var(--radius);display:grid;gap:8px"> |
| <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"> |
| <strong data-i18n="credentials.dashboardPassword">Dashboard 密码</strong> |
| <span class="text-sm text-muted" id="credentials-dashboardpw-source"></span> |
| <span class="text-sm" id="credentials-dashboardpw-status"></span> |
| </div> |
| <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"> |
| <input type="password" class="input" id="credentials-dashboardpw-input" data-i18n-placeholder="credentials.dashboardPwPlaceholder" placeholder="新 Dashboard 密码(≥ 8 字符;留空清除运行时覆盖)" style="flex:1;min-width:240px"> |
| <button class="btn btn-outline btn-sm" onclick="App.toggleDashboardPwVisibility()" data-i18n="credentials.show">显示/隐藏</button> |
| <button class="btn btn-primary btn-sm" onclick="App.saveCredential('dashboardPassword')" data-i18n="credentials.save">保存</button> |
| </div> |
| <div class="text-sm text-muted" data-i18n="credentials.dashboardPwHint">用 scrypt 派生哈希存盘;改完之后会自动登出当前会话,需要用新密码重登。</div> |
| </div> |
| <div class="text-sm text-muted" data-i18n="credentials.bruteForceHint">为防暴破:同一 IP 连续 5 次 dashboard 登录失败会被锁 30 分钟(2.0.56 起)。</div> |
| </div> |
| </div> |
| <div class="section" style="margin-top:24px"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title" data-i18n="section.skin.title">界面风格</div> |
| <div class="section-desc" data-i18n="section.skin.desc">控制台外观。默认现代风;手绘草稿风是实验性 UI,部分功能简化或采用不同布局。切换后页面会刷新。</div> |
| </div> |
| </div> |
| <div class="section-body"> |
| <div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)"> |
| <select id="skin-select" class="input" style="max-width:240px" onchange="App.switchSkin(this.value)"> |
| <option value="modern" data-i18n="skin.modern">默认现代风</option> |
| <option value="sketch" data-i18n="skin.sketch">手绘草稿风(实验)</option> |
| </select> |
| <div class="text-sm text-muted" data-i18n="skin.hint">选择后立即写入 cookie 并刷新</div> |
| </div> |
| </div> |
| </div> |
| </section> |
| </main> |
|
|
| |
| <div class="login-overlay hidden" id="login-overlay"> |
| <div class="login-box"> |
| <div class="login-logo">W</div> |
| <h3 data-i18n="login.title">控制台登录</h3> |
| <p data-i18n="login.subtitle">请输入管理密码以继续</p> |
| <input type="password" id="login-password" class="input" data-i18n-placeholder="login.passwordPlaceholder" placeholder="Dashboard 密码" onkeydown="if(event.key==='Enter')App.login()"> |
| <button class="btn btn-primary" id="login-btn" onclick="App.login()" data-i18n="login.button">登 录</button> |
| <div id="login-error" style="margin-top:12px;color:var(--error);font-size:13px;min-height:18px"></div> |
| </div> |
| </div> |
|
|
| |
| <div id="modal-container"></div> |
|
|
| |
| <div class="toast-stack" id="toast-stack"></div> |
|
|
| <script> |
| |
| const I18n = { |
| locale: 'zh-CN', |
| bundles: {}, |
| loaded: false, |
| |
| |
| async loadLocale(lang) { |
| const code = lang === 'zh' ? 'zh-CN' : (lang === 'en' ? 'en' : lang); |
| try { |
| const res = await fetch(`/dashboard/i18n/${code}.json`); |
| if (!res.ok) throw new Error(`Failed to load ${code}`); |
| this.bundles[code] = await res.json(); |
| this.loaded = true; |
| return true; |
| } catch (e) { |
| console.error('i18n load failed:', e); |
| return false; |
| } |
| }, |
| |
| |
| t(key, vars = {}) { |
| const code = this.locale === 'zh' ? 'zh-CN' : this.locale; |
| const bundle = this.bundles[code] || {}; |
| const keys = key.split('.'); |
| let val = bundle; |
| for (const k of keys) { |
| val = val?.[k]; |
| if (val === undefined) break; |
| } |
| |
| if (typeof val !== 'string') { |
| |
| |
| |
| |
| |
| |
| |
| |
| if (code === 'zh-CN' && /^[A-Za-z0-9_.-]+$/.test(key)) { |
| try { |
| const el = document.querySelector(`[data-i18n="${CSS.escape(key)}"]`); |
| if (el?.dataset.i18nOrig) return el.dataset.i18nOrig; |
| } catch { } |
| } |
| return key; |
| } |
| |
| return val.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? ''); |
| }, |
| |
| |
| apply(root = document) { |
| |
| root.querySelectorAll('[data-i18n]').forEach(el => { |
| const key = el.dataset.i18n; |
| const vars = {}; |
| |
| if (el.dataset.i18nVars) { |
| el.dataset.i18nVars.split(',').forEach(pair => { |
| const [k, v] = pair.split(':'); |
| if (k && v !== undefined) vars[k.trim()] = v.trim(); |
| }); |
| } |
| const text = this.t(key, vars); |
| |
| if (!el.dataset.i18nOrig) el.dataset.i18nOrig = el.textContent; |
| if (this.locale !== 'zh-CN' && this.locale !== 'zh') { |
| el.textContent = text; |
| } else { |
| el.textContent = el.dataset.i18nOrig; |
| } |
| }); |
| |
| |
| root.querySelectorAll('[data-i18n-placeholder]').forEach(el => { |
| const key = el.dataset.i18nPlaceholder; |
| const text = this.t(key); |
| if (!el.dataset.i18nPlaceholderOrig) el.dataset.i18nPlaceholderOrig = el.placeholder; |
| if (this.locale !== 'zh-CN' && this.locale !== 'zh') { |
| el.placeholder = text; |
| } else { |
| el.placeholder = el.dataset.i18nPlaceholderOrig; |
| } |
| }); |
| |
| |
| root.querySelectorAll('[data-i18n-title]').forEach(el => { |
| const key = el.dataset.i18nTitle; |
| const text = this.t(key); |
| if (!el.dataset.i18nTitleOrig) el.dataset.i18nTitleOrig = el.title; |
| if (this.locale !== 'zh-CN' && this.locale !== 'zh') { |
| el.title = text; |
| } else { |
| el.title = el.dataset.i18nTitleOrig; |
| } |
| }); |
| |
| |
| document.documentElement.lang = this.locale === 'zh' || this.locale === 'zh-CN' ? 'zh-CN' : 'en'; |
| |
| |
| const zhMode = this.locale === 'zh' || this.locale === 'zh-CN'; |
| const indicator = document.getElementById('lang-indicator'); |
| if (indicator) indicator.textContent = zhMode ? '中文' : 'English'; |
| const toggleBtn = document.getElementById('lang-toggle-btn'); |
| if (toggleBtn) toggleBtn.title = I18n.t(zhMode ? 'footer.langToggleToEn' : 'footer.langToggleToZh'); |
| }, |
| |
| async setLocale(lang) { |
| const code = lang === 'zh' ? 'zh-CN' : (lang === 'en' ? 'en' : lang); |
| if (!this.bundles[code]) { |
| await this.loadLocale(lang); |
| } |
| this.locale = code; |
| localStorage.setItem('lang', lang); |
| this.apply(); |
| }, |
| |
| async init() { |
| const saved = localStorage.getItem('lang'); |
| const browser = navigator.language; |
| const defaultLang = saved || (browser.startsWith('zh') ? 'zh' : 'en'); |
| await this.setLocale(defaultLang); |
| } |
| }; |
| |
| |
| function t(key, vars) { return I18n.t(key, vars); } |
| |
| const App = { |
| password: localStorage.getItem('dp') || '', |
| lang: localStorage.getItem('lang') || 'zh', |
| sseConn: null, |
| logEntries: [], |
| pollers: {}, |
| allModels: [], |
| modelAccessConfig: { mode: 'all', list: [] }, |
| loginHistory: JSON.parse(localStorage.getItem('wl_history') || '[]'), |
| _filterTimer: null, |
| |
| applyLang() { |
| |
| I18n.apply(); |
| }, |
| |
| |
| |
| |
| translateError(errorCode, fallbackKey = 'error.unknown') { |
| const errKey = errorCode ? `error.${errorCode}` : ''; |
| if (errKey && I18n.t(errKey) !== errKey) return I18n.t(errKey); |
| return errorCode || I18n.t(fallbackKey); |
| }, |
| |
| async toggleLang() { |
| const newLang = I18n.locale === 'zh' || I18n.locale === 'zh-CN' ? 'en' : 'zh'; |
| await I18n.setLocale(newLang); |
| this.lang = newLang; |
| this.refreshActivePanelI18n(); |
| }, |
| |
| refreshActivePanelI18n() { |
| const activePanelId = document.querySelector('.panel.active')?.id?.replace(/^p-/, ''); |
| if (!activePanelId) return; |
| const loaders = { |
| overview: 'loadOverview', 'windsurf-login': 'loadWindsurfLogin', |
| accounts: 'loadAccounts', models: 'loadModels', proxy: 'loadProxy', |
| logs: 'loadLogs', stats: 'loadStats', bans: 'loadBans', |
| experimental: 'loadExperimental', credits: 'loadCredits', |
| }; |
| const loader = loaders[activePanelId]; |
| if (loader && typeof this[loader] === 'function') this[loader](); |
| }, |
| |
| |
| |
| |
| poll(key, fn, intervalMs) { |
| if (this.pollers[key]) clearInterval(this.pollers[key]); |
| this.pollers[key] = setInterval(() => { |
| if (document.hidden) return; |
| fn(); |
| }, intervalMs); |
| }, |
| |
| async init() { |
| |
| await I18n.init(); |
| this.lang = I18n.locale === 'zh-CN' ? 'zh' : 'en'; |
| const auth = await this.api('GET', '/auth'); |
| |
| |
| |
| |
| |
| if (auth.locked) { |
| const overlay = document.getElementById('login-overlay'); |
| const pwEl = document.getElementById('login-password'); |
| const btnEl = document.getElementById('login-btn'); |
| const errEl = document.getElementById('login-error'); |
| overlay.classList.remove('hidden'); |
| if (pwEl) pwEl.style.display = 'none'; |
| if (btnEl) btnEl.style.display = 'none'; |
| if (errEl) { |
| errEl.style.color = 'var(--text-muted)'; |
| errEl.style.fontSize = '13px'; |
| errEl.style.lineHeight = '1.65'; |
| errEl.style.textAlign = 'left'; |
| errEl.innerHTML = ` |
| <strong style="color:var(--warn);display:block;margin-bottom:8px">${I18n.t('login.lockedTitle') || 'Dashboard locked'}</strong> |
| <div>${I18n.t('login.lockedDesc') || 'Server bound to public host without DASHBOARD_PASSWORD. v2.0.55 fail-closed: API_KEY no longer doubles as the dashboard password.'}</div> |
| <div style="margin-top:10px">${I18n.t('login.lockedFix') || 'Fix:'}</div> |
| <pre style="background:var(--surface-2);padding:10px;border-radius:6px;font-size:12px;margin-top:6px;white-space:pre-wrap"># .env |
| DASHBOARD_PASSWORD=YOUR_STRONG_PASSWORD |
| |
| # restart container |
| docker compose up -d --force-recreate</pre> |
| <div style="margin-top:8px;font-size:12px">${I18n.t('login.lockedDocs') || 'See'} <a href="https://github.com/dwgx/WindsurfAPI/blob/master/docs/releases/RELEASE_NOTES_2.0.55.md" target="_blank" style="color:var(--accent)">v2.0.55 release notes</a>.</div> |
| `; |
| } |
| return; |
| } |
| if (auth.required && !auth.valid) { |
| document.getElementById('login-overlay').classList.remove('hidden'); |
| return; |
| } |
| document.getElementById('login-overlay').classList.add('hidden'); |
| document.querySelectorAll('.sidebar nav a').forEach(a => { |
| a.onclick = (e) => { e.preventDefault(); this.navigate(a.dataset.panel); }; |
| }); |
| const hash = location.hash.slice(1); |
| if (hash) this.navigate(hash); |
| else this.loadOverview(); |
| }, |
| |
| async login() { |
| const pw = document.getElementById('login-password').value; |
| const btn = document.getElementById('login-btn'); |
| const errEl = document.getElementById('login-error'); |
| if (errEl) errEl.textContent = ''; |
| if (!pw) { |
| if (errEl) errEl.textContent = I18n.t('login.empty'); |
| return; |
| } |
| if (btn) { btn.disabled = true; btn.dataset.origLabel ||= btn.textContent; btn.textContent = I18n.t('login.checking'); } |
| try { |
| |
| |
| |
| const r = await fetch('/dashboard/api/auth', { headers: { 'X-Dashboard-Password': pw } }); |
| const data = await r.json().catch(() => ({})); |
| if (data.locked) { |
| if (errEl) errEl.textContent = I18n.t('login.locked'); |
| return; |
| } |
| if (data.required && !data.valid) { |
| if (errEl) errEl.textContent = I18n.t('login.wrong'); |
| document.getElementById('login-password')?.focus(); |
| return; |
| } |
| this.password = pw; |
| localStorage.setItem('dp', pw); |
| await this.init(); |
| } catch (e) { |
| if (errEl) errEl.textContent = I18n.t('login.networkError') + ': ' + e.message; |
| } finally { |
| if (btn) { btn.disabled = false; if (btn.dataset.origLabel) btn.textContent = btn.dataset.origLabel; } |
| } |
| }, |
| |
| navigate(panel) { |
| document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); |
| document.querySelectorAll('.sidebar nav a').forEach(a => a.classList.remove('active')); |
| const el = document.getElementById('p-' + panel); |
| const nav = document.querySelector(`[data-panel="${panel}"]`); |
| if (el) el.classList.add('active'); |
| if (nav) nav.classList.add('active'); |
| location.hash = panel; |
| |
| Object.values(this.pollers).forEach(clearInterval); |
| this.pollers = {}; |
| if (this.sseConn) { this.sseConn.close(); this.sseConn = null; } |
| |
| const loaders = { |
| overview: 'loadOverview', 'windsurf-login': 'loadWindsurfLogin', |
| accounts: 'loadAccounts', models: 'loadModels', proxy: 'loadProxy', |
| logs: 'loadLogs', stats: 'loadStats', bans: 'loadBans', |
| experimental: 'loadExperimental', |
| credits: 'loadCredits', |
| }; |
| if (loaders[panel]) this[loaders[panel]](); |
| }, |
| |
| async api(method, path, body) { |
| const opts = { method, headers: { 'Content-Type': 'application/json' } }; |
| if (this.password) opts.headers['X-Dashboard-Password'] = this.password; |
| if (body) opts.body = JSON.stringify(body); |
| try { |
| const r = await fetch('/dashboard/api' + path, opts); |
| const data = await r.json(); |
| if (r.status === 401) { |
| document.getElementById('login-overlay').classList.remove('hidden'); |
| return {}; |
| } |
| return data; |
| } catch (e) { this.toast(e.message, 'error'); return {}; } |
| }, |
| |
| toast(msg, type = 'success') { |
| const t = document.createElement('div'); |
| t.className = 'toast ' + type; |
| t.textContent = msg; |
| document.getElementById('toast-stack').appendChild(t); |
| setTimeout(() => { |
| t.style.opacity = '0'; |
| t.style.transform = 'translateX(20px)'; |
| t.style.transition = 'all .2s'; |
| setTimeout(() => t.remove(), 200); |
| }, 3000); |
| }, |
| |
| |
| confirm(title, desc, opts = {}) { |
| return new Promise(resolve => { |
| const wrap = document.createElement('div'); |
| wrap.className = 'modal-overlay'; |
| |
| |
| |
| const descHtml = desc |
| ? (opts.html |
| ? `<div class="modal-body">${desc}</div>` |
| : `<div class="modal-desc">${this.esc(desc)}</div>`) |
| : ''; |
| const widthStyle = opts.wide ? ' style="max-width:640px;width:92vw"' : ''; |
| |
| |
| wrap.innerHTML = ` |
| <div class="modal"${widthStyle}> |
| <div class="modal-header"> |
| <div class="modal-title">${opts.titleHtml ? title : this.esc(title)}</div> |
| ${opts.html ? '' : (desc ? `<div class="modal-desc">${this.esc(desc)}</div>` : '')} |
| </div> |
| ${opts.html ? descHtml : ''} |
| <div class="modal-footer"> |
| <button class="btn btn-ghost" data-act="cancel">${this.esc(opts.cancelText || I18n.t('button.cancel'))}</button> |
| <button class="btn ${opts.danger ? 'btn-danger' : 'btn-primary'}" data-act="ok">${this.esc(opts.okText || I18n.t('button.confirm'))}</button> |
| </div> |
| </div>`; |
| document.getElementById('modal-container').appendChild(wrap); |
| const close = (v) => { wrap.remove(); resolve(v); }; |
| wrap.querySelector('[data-act=cancel]').onclick = () => close(false); |
| wrap.querySelector('[data-act=ok]').onclick = () => close(true); |
| wrap.onclick = (e) => { if (e.target === wrap) close(false); }; |
| }); |
| }, |
| |
| prompt(title, desc, fields) { |
| return new Promise(resolve => { |
| const wrap = document.createElement('div'); |
| wrap.className = 'modal-overlay'; |
| const fieldHtml = fields.map(f => ` |
| <div class="field mt-3"> |
| <label class="field-label">${this.esc(f.label)}</label> |
| ${f.type === 'select' ? ` |
| <select class="select" data-name="${f.name}"> |
| ${f.options.map(o => `<option value="${this.esc(o.value)}" ${o.value === f.value ? 'selected' : ''}>${this.esc(o.label)}</option>`).join('')} |
| </select> |
| ` : ` |
| <input class="input" data-name="${f.name}" type="${f.type || 'text'}" placeholder="${this.esc(f.placeholder || '')}" value="${this.esc(f.value || '')}"> |
| `} |
| ${f.hint ? `<div class="field-hint">${this.esc(f.hint)}</div>` : ''} |
| </div> |
| `).join(''); |
| wrap.innerHTML = ` |
| <div class="modal"> |
| <div class="modal-header"> |
| <div class="modal-title">${this.esc(title)}</div> |
| ${desc ? `<div class="modal-desc">${this.esc(desc)}</div>` : ''} |
| </div> |
| <div class="modal-body">${fieldHtml}</div> |
| <div class="modal-footer"> |
| <button class="btn btn-ghost" data-act="cancel">${I18n.t('button.cancel')}</button> |
| <button class="btn btn-primary" data-act="ok">${I18n.t('button.confirm')}</button> |
| </div> |
| </div>`; |
| document.getElementById('modal-container').appendChild(wrap); |
| const close = (v) => { wrap.remove(); resolve(v); }; |
| wrap.querySelector('[data-act=cancel]').onclick = () => close(null); |
| wrap.querySelector('[data-act=ok]').onclick = () => { |
| const values = {}; |
| wrap.querySelectorAll('[data-name]').forEach(el => values[el.dataset.name] = el.value); |
| close(values); |
| }; |
| wrap.onclick = (e) => { if (e.target === wrap) close(null); }; |
| setTimeout(() => wrap.querySelector('input, select')?.focus(), 100); |
| }); |
| }, |
| |
| |
| showSelfUpdateUnavailable(status, apply, response) { |
| |
| |
| |
| |
| const dockerHintParts = []; |
| if (response && response.dockerReason && response.dockerReason !== 'no-docker-sock') { |
| dockerHintParts.push(I18n.t('status.dockerSelfUpdateBlocked', { |
| reason: this.esc(response.dockerReason), |
| detail: this.esc(response.dockerDetail || ''), |
| })); |
| } |
| status.innerHTML = ` |
| <div style="color:var(--warning,#f59e0b);font-weight:600;margin-bottom:4px">${I18n.t('status.selfUpdateUnavailable')}</div> |
| <div class="text-sm text-muted">${I18n.t('status.selfUpdateDockerHint')}</div> |
| ${dockerHintParts.join('')}`; |
| if (apply) { |
| apply.classList.add('hidden'); |
| apply.disabled = true; |
| } |
| }, |
| |
| async checkUpdate() { |
| const btn = document.getElementById('btn-check-update'); |
| const status = document.getElementById('update-status'); |
| const apply = document.getElementById('btn-apply-update'); |
| btn.disabled = true; |
| status.classList.remove('hidden'); |
| status.innerHTML = '<span class="text-muted">' + I18n.t('status.checkingUpdate') + '</span>'; |
| try { |
| const r = await this.api('GET', '/self-update/check'); |
| if (!r.ok) { |
| if (r.error === 'ERR_SELF_UPDATE_UNAVAILABLE') { |
| this.showSelfUpdateUnavailable(status, apply, r); |
| return; |
| } |
| if (/not a git repository/i.test(r.error || '')) { |
| status.innerHTML = ` |
| <div style="color:var(--warning,#f59e0b);font-weight:600;margin-bottom:4px">${I18n.t('status.notGitRepo')}</div> |
| <div class="text-sm text-muted">${I18n.t('status.notGitRepoHint')}</div>`; |
| apply.classList.add('hidden'); |
| return; |
| } |
| throw new Error(this.translateError(r.error, 'error.apiError')); |
| } |
| if (r.mode === 'docker') { |
| |
| |
| |
| |
| status.innerHTML = ` |
| <div style="color:var(--accent);font-weight:600;margin-bottom:6px">${I18n.t('status.dockerReady')}</div> |
| <div class="text-sm" style="display:grid;gap:3px"> |
| <div>${I18n.t('status.dockerImage')} <code>${this.esc(r.image || '')}</code></div> |
| <div>${I18n.t('status.dockerProject')} <code>${this.esc(r.project || '')}</code></div> |
| </div>`; |
| apply.classList.remove('hidden'); |
| apply.dataset.mode = 'docker'; |
| return; |
| } |
| apply.dataset.mode = 'git'; |
| if (r.behind) { |
| status.innerHTML = ` |
| <div style="color:var(--accent);font-weight:600;margin-bottom:6px">${I18n.t('status.updateAvailable')}</div> |
| <div class="text-sm" style="display:grid;gap:3px"> |
| <div>${I18n.t('status.currentVersion')} <code>${this.esc(r.commit)}</code> <span class="text-muted">${this.esc(r.localMessage)}</span></div> |
| <div>${I18n.t('status.remoteVersion')} <code>${this.esc(r.remoteCommit)}</code> <span class="text-muted">${this.esc(r.remoteMessage || '')}</span></div> |
| </div>`; |
| apply.classList.remove('hidden'); |
| } else { |
| status.innerHTML = '<span style="color:var(--success)">✓ ' + I18n.t('status.upToDate', {commit: this.esc(r.commit), message: this.esc(r.localMessage)}) + '</span>'; |
| apply.classList.add('hidden'); |
| } |
| } catch (err) { |
| status.innerHTML = `<span style="color:var(--error)">✗ ${this.esc(err.message)}</span>`; |
| } finally { |
| btn.disabled = false; |
| } |
| }, |
| |
| async applyUpdate() { |
| const ok = await this.confirm(I18n.t('button.updateAndRestart'), I18n.t('status.updateAndRestartHint'), { okText: I18n.t('button.updateAndRestart'), danger: true }); |
| if (!ok) return; |
| const status = document.getElementById('update-status'); |
| const apply = document.getElementById('btn-apply-update'); |
| apply.disabled = true; |
| status.innerHTML = '<span class="text-muted">' + I18n.t('status.pullingRestarting') + '</span>'; |
| try { |
| let r = await this.api('POST', '/self-update'); |
| if (r.dirty) { |
| const filesList = (r.dirtyFiles || []).slice(0, 10).map(f => this.esc(f)).join('<br>'); |
| const force = await this.confirm( |
| I18n.t('status.workingDirDirty'), |
| I18n.t('status.workingDirDirtyHint', { files: filesList }), |
| { okText: I18n.t('button.forceOverride'), danger: true, html: true } |
| ); |
| if (!force) { |
| status.innerHTML = '<span class="text-muted">' + I18n.t('status.cancelled') + '</span>'; |
| apply.disabled = false; |
| return; |
| } |
| r = await this.api('POST', '/self-update', { forceReset: true }); |
| } |
| if (r.mode === 'docker') { |
| if (!r.ok) { |
| |
| |
| |
| |
| |
| |
| |
| const localized = this.translateError(r.reason, 'error.updateFailed'); |
| const msg = r.detail ? `${localized} — ${r.detail}` : localized; |
| throw new Error(msg); |
| } |
| const delaySec = r.delaySeconds || 8; |
| status.innerHTML = ` |
| <div style="color:var(--success);font-weight:600;margin-bottom:6px">${I18n.t('status.dockerUpdating', { image: this.esc(r.image || ''), delay: delaySec })}</div> |
| <div class="text-sm text-muted">${I18n.t('status.dockerSidecarStarted', { id: this.esc(r.deployerId || '') })}</div>`; |
| setTimeout(() => location.reload(), (delaySec + 4) * 1000); |
| return; |
| } |
| if (!r.ok) { |
| if (r.error === 'ERR_SELF_UPDATE_UNAVAILABLE') { |
| this.showSelfUpdateUnavailable(status, apply, r); |
| return; |
| } |
| throw new Error(this.translateError(r.error, 'error.updateFailed')); |
| } |
| if (r.changed) { |
| status.innerHTML = ` |
| <div style="color:var(--success);font-weight:600;margin-bottom:6px">${I18n.t('status.updateComplete', { before: this.esc(r.before), after: this.esc(r.after) })}</div> |
| <div class="text-sm text-muted">${I18n.t('status.restarting')}</div> |
| <pre style="margin-top:8px;font-size:11px;max-height:200px;overflow:auto;padding:8px;background:var(--surface-2);border-radius:4px">${this.esc(r.pullOutput || '')}</pre>`; |
| setTimeout(() => location.reload(), 8000); |
| } else { |
| status.innerHTML = '<span class="text-muted">' + I18n.t('status.noUpdateNeeded') + '</span>'; |
| apply.classList.add('hidden'); |
| } |
| } catch (err) { |
| status.innerHTML = `<span style="color:var(--error)">✗ ${this.esc(err.message)}</span>`; |
| } finally { |
| apply.disabled = false; |
| } |
| }, |
| |
| async loadOverview() { |
| const d = await this.api('GET', '/overview'); |
| |
| try { |
| const h = await fetch('/health').then(r => r.json()).catch(() => null); |
| if (h) { |
| const sv = document.getElementById('sidebar-ver'); |
| if (sv) { |
| const label = h.commit ? `v${h.version} · ${h.commit}` : `v${h.version}`; |
| sv.textContent = label; |
| sv.title = h.commitMessage |
| ? `${h.commitMessage}\n${h.commitDate || ''}${h.branch ? ` (${h.branch})` : ''}` |
| : I18n.t('footer.version') + ' ' + h.version; |
| } |
| this._versionInfo = h; |
| } |
| } catch {} |
| const uptime = d.uptime ? this.fmtDuration(d.uptime) : '-'; |
| const ls = d.langServer || {}; |
| const poolCount = (ls.instances || []).length || (ls.running ? 1 : 0); |
| document.getElementById('overview-cards').innerHTML = ` |
| <div class="card success"> |
| <div class="card-header"><div class="card-title">${I18n.t('card.activeAccounts.title')}</div> |
| <svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg> |
| </div> |
| <div class="card-value">${d.accounts?.active || 0}</div> |
| <div class="card-sub">${I18n.t('card.activeAccounts.subtitle', { total: d.accounts?.total || 0, error: d.accounts?.error || 0 })}</div> |
| </div> |
| <div class="card info"> |
| <div class="card-header"><div class="card-title">${I18n.t('card.totalRequests.title')}</div> |
| <svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> |
| </div> |
| <div class="card-value">${d.totalRequests || 0}</div> |
| <div class="card-sub">${I18n.t('card.totalRequests.subtitle', { rate: d.successRate || 0 })}</div> |
| </div> |
| <div class="card"> |
| <div class="card-header"><div class="card-title">${I18n.t('card.uptime.title')}</div> |
| <svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> |
| </div> |
| <div class="card-value" style="font-size:20px">${uptime}</div> |
| <div class="card-sub">${I18n.t('card.uptime.subtitle', { time: d.startedAt ? new Date(d.startedAt).toLocaleString() : '-' })}</div> |
| </div> |
| <div class="card ${ls.running ? 'success' : 'error'}"> |
| <div class="card-header"><div class="card-title">${I18n.t('card.languageServer.title')}</div> |
| <svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg> |
| </div> |
| <div class="card-value" style="font-size:20px">${I18n.t(ls.running ? 'card.languageServer.running' : 'card.languageServer.stopped')}</div> |
| <div class="card-sub">${I18n.t('card.languageServer.subtitle', { count: poolCount, port: ls.port || '-' })}</div> |
| </div> |
| <div class="card info"> |
| <div class="card-header"><div class="card-title">${I18n.t('card.responseCache.title')}</div> |
| <svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/></svg> |
| </div> |
| <div class="card-value">${d.cache?.hitRate || '0.0'}%</div> |
| <div class="card-sub">${I18n.t('card.responseCache.subtitle', { hits: d.cache?.hits || 0, misses: d.cache?.misses || 0, size: d.cache?.size || 0, max: d.cache?.maxSize || 0 })}</div> |
| </div> |
| `; |
| const instances = ls.instances || []; |
| if (instances.length > 0) { |
| document.getElementById('ls-status').innerHTML = ` |
| <div class="ls-pool"> |
| ${instances.map(i => ` |
| <div class="ls-inst"> |
| <div class="ls-key"> |
| <span class="ls-dot ${i.ready ? '' : 'pending'}"></span> |
| ${this.esc(i.key === 'default' ? I18n.t('card.defaultInstance') : i.key.replace(/^px_/, ''))} |
| </div> |
| <div class="ls-meta">${I18n.t('field.port.label')} ${i.port} · PID ${i.pid || '-'}</div> |
| <div class="ls-meta">${this.esc(i.proxy || I18n.t('card.noProxy'))}</div> |
| </div> |
| `).join('')} |
| </div>`; |
| } else { |
| document.getElementById('ls-status').innerHTML = ` |
| <span class="badge ${ls.running ? 'running' : 'stopped'}">${I18n.t(ls.running ? 'card.languageServer.running' : 'card.languageServer.stopped')}</span> |
| <span class="text-sm text-muted" style="margin-left:12px">PID ${ls.pid || '-'} · ${I18n.t('field.port.label')} ${ls.port || '-'} · ${I18n.t('card.restarts')} ${ls.restartCount || 0}</span> |
| `; |
| this.loadLsBinary(); |
| } |
| this.poll('overview', () => this.loadOverview(), 15000); |
| }, |
| |
| async restartLs() { |
| const ok = await this.confirm(I18n.t('confirm.restartTitle'), I18n.t('confirm.restartDesc'), { danger: true, okText: I18n.t('button.restart') }); |
| if (!ok) return; |
| await this.api('POST', '/langserver/restart', { confirm: true }); |
| this.toast(I18n.t('toast.restarting'), 'info'); |
| }, |
| |
| async loadLsBinary() { |
| const el = document.getElementById('ls-binary-info'); |
| if (!el) return; |
| try { |
| const r = await this.api('GET', '/langserver/binary'); |
| if (!r.ok) { |
| const msg = I18n.t('toast.lsBinaryUnreadable', { error: r.error || 'unknown' }); |
| el.innerHTML = `<span style="color:var(--text-error,#ef4444)">${msg}</span> · <code style="font-family:monospace">${r.path || ''}</code>`; |
| return; |
| } |
| const sizeMb = (r.sizeBytes / (1024 * 1024)).toFixed(1); |
| const age = Math.floor((Date.now() - new Date(r.mtime).getTime()) / 86400000); |
| el.textContent = I18n.t('toast.lsBinaryStat', { path: r.path, sizeMb, sha: r.sha256, age }); |
| } catch (e) { |
| el.textContent = I18n.t('toast.lsBinaryReadFailed', { error: e.message }); |
| } |
| }, |
| |
| async updateLsBinary() { |
| const ok = await this.confirm( |
| I18n.t('confirm.updateLsTitle'), |
| I18n.t('confirm.updateLsDesc'), |
| { danger: false, okText: I18n.t('confirm.updateLsButton') }, |
| ); |
| if (!ok) return; |
| this.toast(I18n.t('toast.lsBinaryUpdating'), 'info'); |
| try { |
| const r = await this.api('POST', '/langserver/update', {}); |
| if (r.ok) { |
| |
| |
| |
| |
| |
| const errs = (r.restartErrors || []).length; |
| let msgKey, vars; |
| if (!r.binaryChanged && r.beforeSha) { |
| msgKey = 'toast.lsBinaryAlreadyCurrent'; |
| vars = { sha: r.beforeSha }; |
| } else if (errs) { |
| msgKey = 'toast.lsBinaryUpdatedWithErrors'; |
| vars = { count: r.restarted, errors: errs, before: r.beforeSha || '?', after: r.afterSha || '?' }; |
| } else if (r.poolEmpty) { |
| msgKey = 'toast.lsBinaryUpdatedColdPool'; |
| vars = { before: r.beforeSha || '?', after: r.afterSha || '?' }; |
| } else { |
| msgKey = 'toast.lsBinaryUpdated'; |
| vars = { count: r.restarted, before: r.beforeSha || '?', after: r.afterSha || '?' }; |
| } |
| this.toast(I18n.t(msgKey, vars), 'success'); |
| if (this.loadLsBinary) await this.loadLsBinary(); |
| } else { |
| this.toast(I18n.t('toast.lsBinaryUpdateFailed', { error: r.error || r.stderr || 'unknown' }), 'error'); |
| } |
| } catch (e) { |
| this.toast(I18n.t('toast.lsBinaryUpdateRequestFailed', { error: e.message }), 'error'); |
| } |
| }, |
| |
| |
| loadWindsurfLogin() { |
| this.renderLoginHistory(); |
| |
| |
| this.checkLocalImportAvailability(); |
| }, |
| |
| async oauthLogin(provider) { |
| const btn = document.getElementById(`oauth-${provider}-btn`); |
| const status = document.getElementById('oauth-status'); |
| const label = provider === 'google' ? 'Google' : 'GitHub'; |
| if (btn) btn.disabled = true; |
| status.textContent = I18n.t('oauth.status.loggingIn', { label }); |
| status.style.color = 'var(--text-muted)'; |
| try { |
| if (!window._firebaseOAuth) throw new Error(I18n.t('error.firebaseLoadFailed')); |
| const cred = await window._firebaseOAuth(provider); |
| status.textContent = I18n.t('oauth.status.codeiumRegistering', { label }); |
| const r = await this.api('POST', '/oauth-login', { |
| idToken: cred.idToken, |
| refreshToken: cred.refreshToken, |
| email: cred.email, |
| provider: cred.provider, |
| autoAdd: true, |
| }); |
| if (r.error) throw new Error(r.error); |
| status.style.color = '#22c55e'; |
| status.textContent = I18n.t('oauth.status.loginSuccess', { email: r.email || label }); |
| this.toast(I18n.t('toast.loginSuccess', { label }), 'success'); |
| this.loadAccounts(); |
| const entry = { time: new Date().toISOString(), email: r.email || label, proxy: I18n.t('proxy.direct'), status: 'success', method: label }; |
| this.pushLoginHistory(entry); |
| } catch (err) { |
| status.style.color = '#ef4444'; |
| status.textContent = I18n.t('oauth.status.loginFailed', { label, error: err.message }); |
| const errKey = err.message ? `error.${err.message}` : null; |
| const translatedErr = (errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : err.message; |
| this.toast(I18n.t('toast.loginFailed', { label, error: translatedErr }), 'error'); |
| } finally { |
| if (btn) btn.disabled = false; |
| } |
| }, |
| |
| getWindsurfLoginProxy() { |
| const proxyHost = document.getElementById('wl-proxy-host').value.trim(); |
| return proxyHost ? { |
| type: document.getElementById('wl-proxy-type').value, |
| host: proxyHost, |
| port: parseInt(document.getElementById('wl-proxy-port').value) || 8080, |
| username: document.getElementById('wl-proxy-user').value, |
| password: document.getElementById('wl-proxy-pass').value, |
| } : null; |
| }, |
| |
| getWindsurfProxyLabel(proxy) { |
| if (!proxy) return I18n.t('proxy.global'); |
| if (typeof proxy === 'string') return proxy; |
| return `${proxy.type}://${proxy.host}:${proxy.port}`; |
| }, |
| |
| pushLoginHistory(entry) { |
| this.pushLoginHistoryEntries([entry]); |
| }, |
| |
| pushLoginHistoryEntries(entries) { |
| for (let i = entries.length - 1; i >= 0; i--) this.loginHistory.unshift(entries[i]); |
| if (this.loginHistory.length > 50) this.loginHistory.length = 50; |
| localStorage.setItem('wl_history', JSON.stringify(this.loginHistory)); |
| this.renderLoginHistory(); |
| }, |
| |
| showWindsurfLoginResult(html) { |
| const rd = document.getElementById('wl-result'); |
| rd.classList.remove('hidden'); |
| rd.innerHTML = html; |
| }, |
| |
| getWindsurfLoginFailActions(r) { |
| if (!r.isAuthFail) return ''; |
| const inlineId = 'inline-token-' + Math.random().toString(36).slice(2, 8); |
| const emailAttr = this.esc(r.email || ''); |
| return ` |
| <div style="margin-top:12px;padding:12px;background:var(--surface-2);border-radius:var(--radius);border-left:3px solid var(--accent)"> |
| <div style="font-weight:600;margin-bottom:8px">${I18n.t('oauth.tryAnotherWay')}</div> |
| <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px"> |
| <button class="btn btn-sm" style="background:#4285F4;color:#fff" onclick="App.oauthLogin('google')">${I18n.t('oauth.google')}</button> |
| <button class="btn btn-sm" style="background:#24292e;color:#fff" onclick="App.oauthLogin('github')">${I18n.t('oauth.github')}</button> |
| </div> |
| <div style="border-top:1px dashed var(--border);padding-top:12px"> |
| <div style="font-weight:600;margin-bottom:6px">${I18n.t('oauth.inlineTokenTitle')}</div> |
| <div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">${I18n.t('oauth.inlineTokenDesc')}</div> |
| <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"> |
| <button class="btn btn-sm btn-outline" onclick="App.openWindsurfTokenUrl('${inlineId}')">${I18n.t('oauth.openWindsurfToken')}</button> |
| <input id="${inlineId}" type="text" class="input" style="flex:1;min-width:240px" placeholder="${I18n.t('oauth.tokenPlaceholder')}"> |
| <button class="btn btn-sm btn-primary" onclick="App.addAccountFromInlineToken('${inlineId}','${emailAttr}')">${I18n.t('oauth.addWithToken')}</button> |
| </div> |
| </div> |
| </div>`; |
| }, |
| |
| openWindsurfTokenUrl(inputId) { |
| |
| |
| |
| |
| |
| const state = (crypto.getRandomValues ? Array.from(crypto.getRandomValues(new Uint8Array(8))).map(b => b.toString(16).padStart(2, '0')).join('') : Math.random().toString(36).slice(2)); |
| const url = 'https://windsurf.com/windsurf/signin?response_type=token&client_id=3GUryQ7ldAeKEuD2obYnppsnmj58eP5u&redirect_uri=show-auth-token&state=' + state + '&prompt=login&redirect_parameters_type=query&workflow='; |
| window.open(url, '_blank'); |
| setTimeout(() => document.getElementById(inputId)?.focus(), 100); |
| }, |
| |
| async addAccountFromInlineToken(inputId, label) { |
| const input = document.getElementById(inputId); |
| if (!input) return; |
| const token = input.value.trim(); |
| if (!token) return this.toast(I18n.t('toast.enterKeyOrToken'), 'error'); |
| const btn = input.nextElementSibling; |
| const originalDisabled = btn?.disabled; |
| if (btn) btn.disabled = true; |
| try { |
| const r = await this.api('POST', '/accounts', { token, label }); |
| if (r.success) { |
| this.toast(I18n.t('account.addSuccess'), 'success'); |
| input.value = ''; |
| this.loadAccounts(); |
| } else { |
| this.toast(this.translateError(r.error, 'toast.addFailed'), 'error'); |
| } |
| } catch (err) { |
| this.toast(this.translateError(err.message, 'error.unknown'), 'error'); |
| } finally { |
| if (btn) btn.disabled = originalDisabled || false; |
| } |
| }, |
| |
| renderSingleWindsurfLoginResult(r, autoAdd) { |
| if (r.success) { |
| this.showWindsurfLoginResult(` |
| <div class="section-header"> |
| <div> |
| <div class="section-title" style="color:var(--success)">✓ ${I18n.t('loginResult.success')}</div> |
| <div class="section-desc">${I18n.t('loginResult.apiKeyGenerated', {autoAdd: autoAdd ? I18n.t('loginResult.addedToPool') : ''})}</div> |
| </div> |
| </div> |
| <div class="section-body"> |
| <table style="background:transparent"> |
| <tr><td style="color:var(--text-muted);width:120px">${I18n.t('loginResult.fields.email')}</td><td>${this.esc(r.email)}</td></tr> |
| <tr><td style="color:var(--text-muted)">${I18n.t('loginResult.fields.name')}</td><td>${this.esc(r.name || '-')}</td></tr> |
| <tr><td style="color:var(--text-muted)">${I18n.t('loginResult.fields.apiKey')}</td><td><code class="break-all">${this.esc(r.apiKey_masked || r.apiKey || '')}</code></td></tr> |
| ${r.account ? `<tr><td style="color:var(--text-muted)">${I18n.t('loginResult.fields.accountId')}</td><td><code>${r.account.id}</code> <span class="badge active">${r.account.status}</span></td></tr>` : ''} |
| </table> |
| </div>`); |
| return; |
| } |
| const errKey = r.error ? `error.${r.error}` : null; |
| const errMsg = (errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.error || I18n.t('error.unknown')); |
| this.showWindsurfLoginResult(` |
| <div class="section-header"> |
| <div> |
| <div class="section-title" style="color:var(--error)">${I18n.t('loginResult.fail')}</div> |
| </div> |
| </div> |
| <div class="section-body"><p class="text-sm">${this.esc(errMsg)}</p>${this.getWindsurfLoginFailActions(r)}</div>`); |
| }, |
| |
| renderBatchWindsurfLoginResult(results, autoAdd) { |
| const successCount = results.filter(r => r.success).length; |
| const failCount = results.length - successCount; |
| this.showWindsurfLoginResult(` |
| <div class="section-header"> |
| <div> |
| <div class="section-title" style="color:${failCount ? 'var(--warning, #f59e0b)' : 'var(--success)'}">${I18n.t('loginResult.batchComplete')}</div> |
| <div class="section-desc">${I18n.t('loginResult.batchDesc', {total: results.length, success: successCount, fail: failCount, autoAdd: autoAdd ? I18n.t('loginResult.addedToPool') : ''})}</div> |
| </div> |
| </div> |
| <div class="section-body tight"> |
| <div class="table-wrap"> |
| <table> |
| <thead><tr><th>${I18n.t('table.header.email')}</th><th>${I18n.t('table.header.status')}</th><th>${I18n.t('table.header.note')}</th></tr></thead> |
| <tbody> |
| ${results.map(r => ` |
| <tr> |
| <td>${this.esc(r.email || '-')}</td> |
| <td><span class="badge ${r.success ? 'active' : 'error'}">${r.success ? I18n.t('batch.success') : I18n.t('batch.fail')}</span></td> |
| <td class="text-sm">${r.success |
| ? `<span class="break-all"><code>${this.esc(r.apiKey_masked || (r.apiKey ? r.apiKey.slice(0, 16) + '...' : '-'))}</code>${r.account ? ` · ${I18n.t('table.header.account')} ${this.esc(r.account.id)}` : ''}</span>` |
| : (() => { const errKey = r.error ? `error.${r.error}` : null; return `<span style="color:var(--error)">${this.esc((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.error || I18n.t('error.unknown')))}</span>`; })()}</td> |
| </tr> |
| `).join('')} |
| </tbody> |
| </table> |
| </div> |
| </div>`); |
| }, |
| |
| parseWindsurfBatchInput(text) { |
| const entries = []; |
| const errors = []; |
| String(text || '').replace(/\r\n?/g, '\n').split('\n').forEach((raw, idx) => { |
| const line = raw.trim(); |
| if (!line) return; |
| const m = line.match(/^(\S+)\s+(.+)$/); |
| if (!m) { |
| errors.push(I18n.t('batch.formatError', {line: idx + 1})); |
| return; |
| } |
| const email = m[1].trim(); |
| const password = m[2].trim(); |
| if (!email.includes('@') || !password) { |
| errors.push(I18n.t('batch.formatError', {line: idx + 1})); |
| return; |
| } |
| entries.push({ email, password }); |
| }); |
| return { entries, errors }; |
| }, |
| |
| async pasteWindsurfBatch() { |
| const el = document.getElementById('wl-batch-input'); |
| if (!navigator.clipboard || !window.isSecureContext) { |
| el?.focus(); |
| return this.toast(I18n.t('toast.clipboardUnsupported'), 'info'); |
| } |
| try { |
| const text = await navigator.clipboard.readText(); |
| if (!text) return this.toast(I18n.t('toast.clipboardEmpty'), 'info'); |
| el.value = text; |
| this.toast(I18n.t('toast.clipboardRead'), 'success'); |
| } catch (err) { |
| this.toast(I18n.t('toast.clipboardError', { error: err.message }), 'error'); |
| } |
| }, |
| |
| async windsurfLogin() { |
| const email = document.getElementById('wl-email').value.trim(); |
| const password = document.getElementById('wl-password').value.trim(); |
| if (!email || !password) return this.toast(I18n.t('toast.enterEmailPassword'), 'error'); |
| |
| const proxy = this.getWindsurfLoginProxy(); |
| const autoAdd = document.getElementById('wl-auto-add').checked; |
| |
| const btn = document.getElementById('wl-btn'); |
| btn.disabled = true; |
| btn.innerHTML = '<span class="spinner"></span> ' + I18n.t('status.loading'); |
| |
| const entry = { time: new Date().toISOString(), email, proxy: this.getWindsurfProxyLabel(proxy), status: 'pending' }; |
| |
| try { |
| const r = await this.api('POST', '/windsurf-login', { email, password, proxy, autoAdd }); |
| if (r.success) { |
| entry.status = 'success'; |
| entry.apiKey = r.apiKey_masked || (r.apiKey?.slice(0, 16) + '...'); |
| this.renderSingleWindsurfLoginResult(r, autoAdd); |
| this.toast(autoAdd ? I18n.t('toast.loginSuccessAdded') : I18n.t('toast.loginSuccess'), 'success'); |
| this.loadAccounts(); |
| } else { |
| entry.status = 'error: ' + (r.error || 'unknown'); |
| this.renderSingleWindsurfLoginResult(r, autoAdd); |
| const errKey = r.error ? `error.${r.error}` : null; |
| this.toast((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.error || I18n.t('toast.loginFailed')), 'error'); |
| } |
| } catch (err) { |
| entry.status = 'error: ' + err.message; |
| const errKey = err.message ? `error.${err.message}` : null; |
| this.toast((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : err.message, 'error'); |
| } |
| |
| this.pushLoginHistory(entry); |
| btn.disabled = false; |
| btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg> ' + I18n.t('action.login'); |
| }, |
| |
| async windsurfLoginBatch() { |
| const input = document.getElementById('wl-batch-input').value; |
| if (!input.trim()) return this.toast(I18n.t('batch.pasteFirst'), 'error'); |
| |
| const autoAdd = document.getElementById('wl-auto-add').checked; |
| const btn = document.getElementById('wl-batch-btn'); |
| const lineCount = input.trim().split('\n').filter(l => l.trim()).length; |
| btn.disabled = true; |
| btn.innerHTML = '<span class="spinner"></span> ' + I18n.t('batch.importing'); |
| this.showWindsurfLoginResult(` |
| <div class="section-header"> |
| <div> |
| <div class="section-title">${I18n.t('batch.importing')}</div> |
| <div class="section-desc">${I18n.t('batch.importingDesc', {count: lineCount})}</div> |
| </div> |
| </div>`); |
| |
| try { |
| const r = await this.api('POST', '/batch-import', { text: input, autoAdd }); |
| if (!r.success || !Array.isArray(r.results)) { |
| const errKey = r.error ? `error.${r.error}` : null; |
| this.showWindsurfLoginResult(` |
| <div class="section-header"> |
| <div><div class="section-title" style="color:var(--error)">${I18n.t('loginResult.fail')}</div></div> |
| </div> |
| <div class="section-body"><p class="text-sm">${this.esc((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.error || I18n.t('error.unknown')))}</p></div>`); |
| const translatedErr = errKey && I18n.t(errKey) !== errKey ? I18n.t(errKey) : r.error; |
| return this.toast(translatedErr || I18n.t('batch.importFailed'), 'error'); |
| } |
| |
| this.renderBatchWindsurfLoginResult(r.results, autoAdd); |
| this.pushLoginHistoryEntries(r.results.map(item => ({ |
| time: new Date().toISOString(), |
| email: item.email, |
| proxy: this.getWindsurfProxyLabel(item.proxy), |
| status: item.success ? 'success' : `error: ${item.error || 'unknown'}`, |
| apiKey: item.success ? (item.apiKey_masked || (item.apiKey ? item.apiKey.slice(0, 16) + '...' : undefined)) : undefined, |
| }))); |
| this.toast(I18n.t('batch.importComplete', {success: r.successCount || 0, fail: r.failCount || 0}), (r.failCount || 0) ? 'info' : 'success'); |
| if ((r.successCount || 0) > 0) { |
| this.loadAccounts(); |
| document.getElementById('wl-batch-input').value = ''; |
| } |
| } catch (err) { |
| const errKey = err.message ? `error.${err.message}` : null; |
| this.showWindsurfLoginResult(` |
| <div class="section-header"> |
| <div><div class="section-title" style="color:var(--error)">${I18n.t('loginResult.fail')}</div></div> |
| </div> |
| <div class="section-body"><p class="text-sm">${this.esc((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : err.message)}</p></div>`); |
| const translatedErr = (errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : err.message; |
| this.toast(translatedErr, 'error'); |
| } finally { |
| btn.disabled = false; |
| btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></svg> ' + I18n.t('action.batchImport'); |
| } |
| }, |
| |
| renderLoginHistory() { |
| const tbody = document.querySelector('#wl-history-table tbody'); |
| if (!tbody) return; |
| tbody.innerHTML = this.loginHistory.map((h, i) => { |
| const ok = h.status === 'success'; |
| return `<tr> |
| <td class="text-sm nowrap">${new Date(h.time).toLocaleString()}</td> |
| <td>${this.esc(h.email)}</td> |
| <td> |
| <span class="badge ${ok ? 'active' : 'error'}">${ok ? I18n.t('batch.success') : I18n.t('batch.fail')}</span> |
| ${!ok ? (() => { const errCode = (h.status||'').replace('error: ',''); const errKey = `error.${errCode}`; const errMsg = I18n.t(errKey) !== errKey ? I18n.t(errKey) : errCode; return `<div class="text-xs" style="color:var(--error);margin-top:3px;max-width:240px;overflow:hidden;text-overflow:ellipsis" title="${this.esc(errCode)}">${this.esc(errMsg)}</div>`; })() : ''} |
| </td> |
| <td class="text-sm">${this.esc(h.proxy || '-')}</td> |
| <td><button class="btn btn-ghost btn-xs" onclick="App.removeLoginHistory(${i})">${I18n.t('action.delete')}</button></td> |
| </tr>`; |
| }).join('') || `<tr class="empty-row"><td colspan="5">${I18n.t('table.empty.loginHistory')}</td></tr>`; |
| }, |
| |
| removeLoginHistory(idx) { |
| this.loginHistory.splice(idx, 1); |
| localStorage.setItem('wl_history', JSON.stringify(this.loginHistory)); |
| this.renderLoginHistory(); |
| }, |
| |
| |
| async loadAccounts() { |
| |
| |
| this.loadDrought(); |
| const [d, ov] = await Promise.all([ |
| this.api('GET', '/accounts'), |
| this.api('GET', '/overview').catch(() => null), |
| ]); |
| const instances = ov?.langServer?.instances || []; |
| const poolCard = document.getElementById('ls-pool-card'); |
| if (poolCard) { |
| if (instances.length > 0) { |
| poolCard.innerHTML = ` |
| <div class="section"> |
| <div class="section-header"> |
| <div> |
| <div class="section-title">${I18n.t('section.lsPool.sectionTitle')}</div> |
| <div class="section-desc">${I18n.t('section.lsPool.sectionDesc', { count: instances.length })}</div> |
| </div> |
| </div> |
| <div class="section-body"> |
| <div class="ls-pool"> |
| ${instances.map(i => ` |
| <div class="ls-inst"> |
| <div class="ls-key"> |
| <span class="ls-dot ${i.ready ? '' : 'pending'}"></span> |
| ${this.esc(i.key === 'default' ? I18n.t('section.lsPool.defaultInstance') : i.key.replace(/^px_/, ''))} |
| </div> |
| <div class="ls-meta">${I18n.t('section.lsPool.port')} ${i.port} · PID ${i.pid || '-'}</div> |
| <div class="ls-meta">${this.esc(i.proxy || I18n.t('section.lsPool.noProxy'))}</div> |
| </div> |
| `).join('')} |
| </div> |
| </div> |
| </div>`; |
| } else { |
| poolCard.innerHTML = ''; |
| } |
| } |
| const tbody = document.querySelector('#accounts-table tbody'); |
| const tierLabel = { pro: 'PRO', free: 'FREE', expired: I18n.t('tier.expiredLabel'), unknown: I18n.t('tier.unknown') }; |
| tbody.innerHTML = (d.accounts || []).map(a => { |
| const tier = a.tier || 'unknown'; |
| |
| const us = a.userStatus || null; |
| const usTipLines = []; |
| let tierSubline = ''; |
| if (us) { |
| if (us.planName) usTipLines.push(`Plan: ${us.planName}`); |
| if (us.trialEndMs) { |
| const daysLeft = Math.max(0, Math.ceil((us.trialEndMs - Date.now()) / 86400000)); |
| usTipLines.push(`Trial ends: ${new Date(us.trialEndMs).toLocaleDateString()} (${daysLeft}d left)`); |
| tierSubline = `${daysLeft}d trial`; |
| } else if (us.planName) { |
| tierSubline = us.planName.length > 12 ? us.planName.slice(0, 12) + '…' : us.planName; |
| } |
| if (us.monthlyPromptCredits > 0) { |
| usTipLines.push(`Prompt: ${us.promptCreditsUsed}/${us.monthlyPromptCredits}`); |
| } |
| if (us.monthlyFlowCredits > 0) { |
| usTipLines.push(`Flow: ${us.flowCreditsUsed}/${us.monthlyFlowCredits}`); |
| } |
| if (us.allowedModels?.length) { |
| usTipLines.push(`Allowed cascade models: ${us.allowedModels.length}`); |
| } |
| if (a.userStatusLastFetched) { |
| const ago = Math.round((Date.now() - a.userStatusLastFetched) / 60000); |
| usTipLines.push(`Fetched: ${ago}m ago`); |
| } |
| } |
| const tierTooltip = usTipLines.length |
| ? usTipLines.join('\n') |
| : I18n.t('status.clickToEditTier'); |
| |
| |
| const tierModels = a.tierModels || []; |
| const blockedCount = (a.blockedModels || []).length; |
| const availCount = tierModels.length - blockedCount; |
| const capsHtml = tierModels.length |
| ? `<button class="btn btn-ghost btn-xs" style="min-width:96px" onclick="App.openBlockedModal('${this.escJsAttr(a.id)}')" title="${I18n.t('account.editModels')}"> |
| <span style="color:var(--success,#10b981);font-weight:600">${availCount}</span> |
| <span style="color:var(--text-dim)">/${tierModels.length}</span> |
| ${blockedCount > 0 ? `<span class="badge warn" style="margin-left:4px">-${blockedCount}</span>` : ''} |
| </button>` |
| : `<span class="text-xs text-dim">-</span>`; |
| const rateLimitBadge = a.rateLimited ? ` <span class="badge warn">${I18n.t('card.rateLimit.badge')}</span>` : ''; |
| const rpmUsed = a.rpmUsed ?? 0; |
| const rpmLimit = a.rpmLimit ?? 0; |
| const rpmPct = rpmLimit > 0 ? Math.min(100, Math.round((rpmUsed / rpmLimit) * 100)) : 0; |
| const rpmColor = rpmPct >= 90 ? 'var(--error)' : rpmPct >= 60 ? 'var(--warn, #f59e0b)' : 'var(--success, #10b981)'; |
| const rpmCell = rpmLimit > 0 |
| ? `<div style="min-width:90px"> |
| <div class="text-xs" style="display:flex;justify-content:space-between;margin-bottom:2px"> |
| <span>${rpmUsed}/${rpmLimit}</span><span style="color:${rpmColor}">${rpmPct}%</span> |
| </div> |
| <div style="height:4px;background:var(--surface-3);border-radius:2px;overflow:hidden"> |
| <div style="height:100%;width:${rpmPct}%;background:${rpmColor};transition:width .3s"></div> |
| </div> |
| </div>` |
| : `<span class="text-xs text-dim">-</span>`; |
| |
| |
| |
| const cr = a.credits || null; |
| let creditCell; |
| if (!cr) { |
| creditCell = `<span class="text-xs text-dim">${I18n.t('status.notFetched')}</span>`; |
| } else if (cr.lastError && cr.percent == null && !cr.prompt?.limit) { |
| creditCell = `<span class="text-xs" style="color:var(--error)" title="${this.esc(cr.lastError)}">${I18n.t('status.fetchFailed')}</span>`; |
| } else { |
| const planName = cr.planName || '-'; |
| const fetchedAgo = cr.fetchedAt ? Math.round((Date.now() - cr.fetchedAt) / 60000) + 'm ago' : '-'; |
| const barColor = (v) => v == null ? 'var(--text-dim)' : v <= 10 ? 'var(--error)' : v <= 30 ? 'var(--warn, #f59e0b)' : 'var(--success, #10b981)'; |
| const bar = (label, pct, detail) => { |
| const noData = pct == null; |
| const v = noData ? 0 : Math.max(0, Math.min(100, pct)); |
| const c = noData ? 'var(--text-dim)' : barColor(v); |
| |
| |
| |
| |
| |
| |
| return `<div style="display:flex;align-items:center;gap:3px;margin-top:2px" title="${detail}"> |
| <span class="text-xs" style="width:14px;flex-shrink:0;color:var(--text-dim);font-size:10px;font-weight:600">${label}</span> |
| <div style="flex:1;height:4px;background:var(--surface-3);border-radius:2px;overflow:hidden;min-width:40px${noData ? ';outline:1px dashed var(--text-dim);outline-offset:-1px' : ''}"> |
| <div style="height:100%;width:${noData ? 0 : v}%;background:${c};border-radius:2px;transition:width .4s ease"></div> |
| </div> |
| <span class="text-xs" style="width:28px;text-align:right;color:${c};font-size:10px;font-style:${noData ? 'italic' : 'normal'}">${noData ? 'N/A' : v.toFixed(0)+'%'}</span> |
| </div>`; |
| }; |
| const fmtReset = (unix) => unix ? new Date(unix * 1000).toLocaleString() : I18n.t('creditsBar.noResetTime'); |
| const dailyTip = cr.dailyPercent != null |
| ? I18n.t('creditsBar.dailyDetail', { pct: cr.dailyPercent.toFixed(0), reset: fmtReset(cr.dailyResetAt) }) |
| : I18n.t('creditsBar.dailyNA'); |
| const weeklyTip = cr.weeklyPercent != null |
| ? I18n.t('creditsBar.weeklyDetail', { pct: cr.weeklyPercent.toFixed(0), reset: fmtReset(cr.weeklyResetAt) }) |
| : I18n.t('creditsBar.weeklyNA'); |
| const shortD = I18n.t('creditsBar.dailyShort'); |
| const shortW = I18n.t('creditsBar.weeklyShort'); |
| creditCell = `<div style="min-width:120px;max-width:160px"> |
| <div class="text-xs" style="margin-bottom:2px"><b>${this.esc(planName.slice(0, 12))}</b> <span style="color:var(--text-dim)">${fetchedAgo}</span></div> |
| ${bar(shortD, cr.dailyPercent, dailyTip)} |
| ${bar(shortW, cr.weeklyPercent, weeklyTip)} |
| </div>`; |
| } |
| const isExpanded = this._expandedAccounts?.has(a.id); |
| return ` |
| <tr${isExpanded ? ' class="expanded"' : ''}> |
| <td> |
| <button class="expand-chevron ${isExpanded ? 'open' : ''}" onclick="App.toggleAccountDetail('${this.escJsAttr(a.id)}')" title="${I18n.t('account.detail.toggle')}">▸</button> |
| <code>${a.id}</code> |
| </td> |
| <td>${this.esc(a.email)}</td> |
| <td> |
| <div style="display:flex;flex-direction:column;gap:2px"> |
| <span class="tier ${this.esc(tier)}" style="cursor:pointer" title="${this.esc(tierTooltip)}" onclick="App.overrideTier('${this.escJsAttr(a.id)}','${this.escJsAttr(tier)}')">${tierLabel[tier] || this.esc(tier)}${a.tierManual ? ' ✎' : ''}</span> |
| ${tierSubline ? `<span class="text-xs text-dim" style="font-size:10px">${this.esc(tierSubline)}</span>` : ''} |
| </div> |
| </td> |
| <td>${rpmCell}</td> |
| <td>${creditCell}</td> |
| <td>${capsHtml}</td> |
| <td><span class="badge ${a.status}">${a.status}</span>${rateLimitBadge}</td> |
| <td style="color:${a.errorCount > 0 ? 'var(--error)' : 'inherit'}">${a.errorCount}</td> |
| <td class="text-sm nowrap">${a.lastUsed ? new Date(a.lastUsed).toLocaleString() : '-'}</td> |
| <td class="nowrap"> |
| <code title="${I18n.t('action.revealKey')}" style="cursor:pointer" onclick="App.revealAndCopyKey('${this.escJsAttr(a.id)}')">${this.esc(a.apiKey_masked || a.keyPrefix || '')}</code> |
| </td> |
| <td class="nowrap"> |
| <div class="btn-group"> |
| <button class="btn btn-ghost btn-xs" onclick="App.probeAccount('${this.escJsAttr(a.id)}')" title="${I18n.t('action.probeTitle')}">${I18n.t('action.probe')}</button> |
| <button class="btn btn-ghost btn-xs" onclick="App.refreshCredits('${this.escJsAttr(a.id)}')" title="${I18n.t('account.credits.refreshTitle')}">${I18n.t('table.header.credits')}</button> |
| ${a.status === 'active' |
| ? `<button class="btn btn-ghost btn-xs" onclick="App.toggleAccount('${this.escJsAttr(a.id)}','disabled')">${I18n.t('action.disable')}</button>` |
| : `<button class="btn btn-success btn-xs" onclick="App.toggleAccount('${this.escJsAttr(a.id)}','active')">${I18n.t('action.enable')}</button>`} |
| <button class="btn btn-ghost btn-xs" onclick="App.resetErrors('${this.escJsAttr(a.id)}')">${I18n.t('action.reset')}</button> |
| <button class="btn btn-ghost btn-xs" style="color:var(--error)" onclick="App.deleteAccount('${this.escJsAttr(a.id)}')">${I18n.t('action.delete')}</button> |
| </div> |
| </td> |
| </tr>${isExpanded ? `<tr class="account-detail-row"><td colspan="11">${this.renderAccountDetail(a)}</td></tr>` : ''}`; |
| }).join('') || `<tr class="empty-row"><td colspan="11">${I18n.t('table.empty.accounts')}</td></tr>`; |
| }, |
| |
| _expandedAccounts: new Set(), |
| _modelsCatalog: null, |
| |
| async _ensureModelsCatalog() { |
| if (this._modelsCatalog) return this._modelsCatalog; |
| try { |
| const r = await this.api('GET', '/models'); |
| this._modelsCatalog = {}; |
| for (const m of (r.models || [])) this._modelsCatalog[m.id] = m; |
| } catch { |
| this._modelsCatalog = {}; |
| } |
| return this._modelsCatalog; |
| }, |
| |
| toggleAccountDetail(id) { |
| if (!this._expandedAccounts) this._expandedAccounts = new Set(); |
| if (this._expandedAccounts.has(id)) { |
| this._expandedAccounts.delete(id); |
| } else { |
| this._expandedAccounts.add(id); |
| this._ensureModelsCatalog().then(() => this.loadAccounts()); |
| return; |
| } |
| this.loadAccounts(); |
| }, |
| |
| renderAccountDetail(a) { |
| const cr = a.credits || {}; |
| const us = a.userStatus || {}; |
| const catalog = this._modelsCatalog || {}; |
| const T = (k, v) => I18n.t('account.detail.' + k, v); |
| const barColor = (v) => v == null ? 'var(--text-dim)' : v <= 10 ? 'var(--error)' : v <= 30 ? 'var(--warn, #f59e0b)' : 'var(--success, #10b981)'; |
| const fmtUnix = (u) => u ? new Date(u * 1000).toLocaleString() : '—'; |
| const fmtDate = (v) => v ? new Date(v).toLocaleDateString() : '—'; |
| const fmtAgo = (ts) => { |
| if (!ts) return '—'; |
| const s = Math.floor((Date.now() - ts) / 1000); |
| if (s < 60) return `${s}s ${T('ago')}`; |
| if (s < 3600) return `${Math.floor(s/60)}m ${T('ago')}`; |
| if (s < 86400) return `${Math.floor(s/3600)}h ${T('ago')}`; |
| return `${Math.floor(s/86400)}d ${T('ago')}`; |
| }; |
| const renderBar = (label, pct, resetAt, noDataText) => { |
| const isN = pct == null; |
| const v = isN ? 0 : Math.max(0, Math.min(100, pct)); |
| const c = isN ? 'var(--text-dim)' : barColor(v); |
| const track = `<div class="bar-track"><div class="bar-fill" style="width:${v}%;background:${c}"></div></div>`; |
| const val = isN ? '—' : v.toFixed(0) + '%'; |
| const reset = resetAt |
| ? `<div class="bar-reset">⟳ ${T('resetAt')} ${fmtUnix(resetAt)}</div>` |
| : (isN && noDataText ? `<div class="bar-reset" style="color:var(--warn)">${noDataText}</div>` : ''); |
| return `<div class="detail-bar"> |
| <span class="bar-label">${label}</span> |
| ${track} |
| <span class="bar-pct" style="color:${c}">${val}</span> |
| ${reset} |
| </div>`; |
| }; |
| |
| |
| const tierModels = a.tierModels || []; |
| const blocked = new Set(a.blockedModels || []); |
| const providerOrder = ['anthropic', 'openai', 'google', 'deepseek', 'xai', 'alibaba', 'moonshot', 'zhipu', 'minimax', 'windsurf', 'other']; |
| const providerColors = { |
| anthropic: '#d97706', openai: '#10b981', google: '#4285f4', deepseek: '#7c3aed', |
| xai: '#e11d48', alibaba: '#ec4899', moonshot: '#06b6d4', zhipu: '#8b5cf6', |
| minimax: '#f43f5e', windsurf: '#6366f1', other: '#71717a', |
| }; |
| const providerLabels = { |
| anthropic: 'Anthropic · Claude', openai: 'OpenAI · GPT / o', google: 'Google · Gemini', |
| deepseek: 'DeepSeek', xai: 'xAI · Grok', alibaba: 'Alibaba · Qwen', |
| moonshot: 'Moonshot · Kimi', zhipu: 'Zhipu · GLM', minimax: 'MiniMax', |
| windsurf: 'Windsurf · SWE', other: 'Other', |
| }; |
| const groups = {}; |
| for (const m of tierModels) { |
| const info = catalog[m] || {}; |
| const prov = info.provider || 'other'; |
| (groups[prov] = groups[prov] || []).push({ id: m, ...info }); |
| } |
| const availCount = tierModels.length - blocked.size; |
| const modelGroupsHtml = providerOrder |
| .filter(p => groups[p]) |
| .map(p => { |
| const models = groups[p].sort((a, b) => (a.credit || 0) - (b.credit || 0)); |
| const blockedIn = models.filter(m => blocked.has(m.id)).length; |
| const availIn = models.length - blockedIn; |
| return `<div class="model-group"> |
| <div class="model-group-head"> |
| <span class="model-group-name"><span class="provider-swatch" style="background:${providerColors[p]}"></span>${providerLabels[p] || p}</span> |
| <span class="model-group-count">${availIn}/${models.length}</span> |
| </div> |
| <div class="model-chips"> |
| ${models.map(m => `<div class="model-chip ${blocked.has(m.id) ? 'blocked' : ''}" title="${m.id}"> |
| <span class="model-chip-dot"></span> |
| <span class="model-chip-name">${this.esc(m.id)}</span> |
| ${m.credit != null ? `<span class="model-chip-cost" title="${T('creditPerCall')}">${m.credit}${T('creditUnit')}</span>` : `<span class="model-chip-cost" style="color:var(--text-dim);background:transparent;border-color:var(--border)">—</span>`} |
| </div>`).join('')} |
| </div> |
| </div>`; |
| }).join(''); |
| |
| |
| let trialDaysLeft = null; |
| if (us.trialEndMs) trialDaysLeft = Math.max(0, Math.ceil((us.trialEndMs - Date.now()) / 86400000)); |
| |
| return ` |
| <div class="account-detail-wrap"> |
| <div class="detail-grid"> |
| <div class="detail-card"> |
| <div class="detail-card-head"><span class="dot"></span>${T('plan.title')}</div> |
| <div class="detail-kv"> |
| <span class="k">${T('plan.name')}</span><span class="v"><b>${this.esc(cr.planName || us.planName || '—')}</b></span> |
| <span class="k">${T('plan.tier')}</span><span class="v"><span class="tier ${this.esc(a.tier || 'unknown')}" style="padding:1px 8px;font-size:10px">${this.esc(a.tier || 'unknown')}</span>${a.tierManual ? ' <span style="color:var(--text-dim);font-size:10px">✎ manual</span>' : ''}</span> |
| <span class="k">${T('plan.start')}</span><span class="v">${fmtDate(cr.planStart || us.planStart)}</span> |
| <span class="k">${T('plan.end')}</span><span class="v">${fmtDate(cr.planEnd || us.planEnd)}${trialDaysLeft != null ? ` <span style="color:${trialDaysLeft <= 3 ? 'var(--error)' : 'var(--text-dim)'};font-size:11px">(${trialDaysLeft}d left)</span>` : ''}</span> |
| ${cr.overageBalance != null ? `<span class="k">${T('plan.overage')}</span><span class="v">$${cr.overageBalance.toFixed(4)}</span>` : ''} |
| </div> |
| </div> |
| |
| <div class="detail-card"> |
| <div class="detail-card-head"><span class="dot" style="background:var(--warn);box-shadow:0 0 6px var(--warn)"></span>${T('quota.title')}</div> |
| ${renderBar(T('quota.daily'), cr.dailyPercent, cr.dailyResetAt, I18n.t('creditsBar.dailyNA'))} |
| ${renderBar(T('quota.weekly'), cr.weeklyPercent, cr.weeklyResetAt, I18n.t('creditsBar.weeklyNA'))} |
| <div style="margin-top:10px;padding-top:10px;border-top:1px dashed var(--border)" class="detail-kv"> |
| ${cr.prompt?.limit ? `<span class="k">${T('quota.promptCredits')}</span><span class="v"><b>${cr.prompt.remaining ?? 0}</b> / ${cr.prompt.limit} <span style="color:var(--text-dim);font-size:10px">(${T('quota.used')} ${cr.prompt.used ?? 0})</span></span>` : `<span class="k">${T('quota.promptCredits')}</span><span class="v" style="color:var(--text-dim)">${T('quota.notApplicable')}</span>`} |
| ${cr.flex?.limit ? `<span class="k">${T('quota.flexCredits')}</span><span class="v"><b>${cr.flex.remaining ?? 0}</b> / ${cr.flex.limit} <span style="color:var(--text-dim);font-size:10px">(${T('quota.used')} ${cr.flex.used ?? 0})</span></span>` : `<span class="k">${T('quota.flexCredits')}</span><span class="v" style="color:var(--text-dim)">${T('quota.notApplicable')}</span>`} |
| <span class="k">${T('quota.fetchedAt')}</span><span class="v">${fmtAgo(cr.fetchedAt)}</span> |
| </div> |
| </div> |
| |
| <div class="detail-card" style="grid-column:1/-1"> |
| <div class="detail-card-head"> |
| <span class="dot" style="background:var(--success);box-shadow:0 0 6px var(--success)"></span> |
| ${T('models.title')} |
| <span style="color:var(--text-dim);font-weight:400;font-size:11px;margin-left:6px">${availCount} / ${tierModels.length} ${T('models.available')}${blocked.size > 0 ? ` · ${blocked.size} ${T('models.blocked')}` : ''}</span> |
| <div style="margin-left:auto;display:flex;gap:4px"> |
| <button class="btn btn-ghost btn-xs" onclick="App.openBlockedModal('${this.escJsAttr(a.id)}')" style="font-size:10px">${I18n.t('account.editModels')}</button> |
| </div> |
| </div> |
| ${tierModels.length === 0 ? `<div style="color:var(--text-dim);font-size:12px;padding:10px 0">${I18n.t('toast.noTierModels')}</div>` : modelGroupsHtml} |
| </div> |
| |
| <div class="detail-card" style="grid-column:1/-1"> |
| <div class="detail-card-head"><span class="dot" style="background:var(--info);box-shadow:0 0 6px var(--info)"></span>${T('runtime.title')}</div> |
| <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:20px"> |
| <div class="detail-kv"> |
| <span class="k">${T('runtime.status')}</span><span class="v"><span class="badge ${a.status}">${a.status}</span>${a.rateLimited ? ` <span class="badge warn">${I18n.t('card.rateLimit.badge')}</span>` : ''}</span> |
| <span class="k">${T('runtime.errors')}</span><span class="v" style="color:${a.errorCount > 0 ? 'var(--error)' : 'inherit'}">${a.errorCount || 0}</span> |
| <span class="k">${T('runtime.rpm')}</span><span class="v">${a.rpmUsed ?? 0} / ${a.rpmLimit ?? 0}</span> |
| </div> |
| <div class="detail-kv"> |
| <span class="k">${T('runtime.lastUsed')}</span><span class="v">${fmtAgo(a.lastUsed)}</span> |
| <span class="k">${T('runtime.lastProbed')}</span><span class="v">${fmtAgo(a.lastProbed || a.userStatusLastFetched)}</span> |
| <span class="k">${T('runtime.creditsFetched')}</span><span class="v">${fmtAgo(cr.fetchedAt)}</span> |
| </div> |
| <div class="detail-kv"> |
| <span class="k">${T('runtime.accountId')}</span><span class="v"><code>${a.id}</code></span> |
| <span class="k">${T('runtime.apiKey')}</span><span class="v"><code>${this.esc(a.apiKey_masked || a.keyPrefix || '')}</code></span> |
| ${cr.lastError ? `<span class="k">${T('runtime.lastError')}</span><span class="v" style="color:var(--error);font-size:11px">${this.esc(cr.lastError).slice(0, 80)}</span>` : ''} |
| </div> |
| </div> |
| </div> |
| </div> |
| </div>`; |
| }, |
| |
| async refreshCredits(id) { |
| try { |
| const r = await this.api('POST', `/accounts/${id}/refresh-credits`, {}); |
| if (r.ok) { |
| this.toast(I18n.t('toast.credits.refreshed')); |
| this.loadAccounts(); |
| } else { |
| this.toast(this.translateError(r.error, 'toast.credits.refreshFailed'), 'error'); |
| } |
| } catch (e) { this.toast(I18n.t('toast.credits.refreshFailed') + ': ' + e.message, 'error'); } |
| }, |
| |
| async refreshAllCredits() { |
| this.toast(I18n.t('toast.credits.refreshing'), 'info'); |
| try { |
| const r = await this.api('POST', '/accounts/refresh-credits', {}); |
| if (r.success) { |
| const okCount = (r.results || []).filter(x => x.ok).length; |
| const failCount = (r.results || []).length - okCount; |
| this.toast(I18n.t('toast.credits.allRefreshed', { ok: okCount, fail: failCount })); |
| this.loadAccounts(); |
| } else { |
| this.toast(this.translateError(r.error, 'toast.credits.refreshFailed'), 'error'); |
| } |
| } catch (e) { this.toast(I18n.t('toast.credits.refreshFailed') + ': ' + e.message, 'error'); } |
| }, |
| |
| async probeAll() { |
| this.toast(I18n.t('toast.probe.allDoing'), 'info'); |
| try { |
| const r = await this.api('POST', '/accounts/probe-all', {}); |
| if (r.success) { |
| const tierLabel = { pro: 'Pro', free: 'Free', expired: I18n.t('tier.expiredLabel'), unknown: I18n.t('tier.unknown') }; |
| const summary = (r.results || []).map(x => `${x.email}: ${tierLabel[x.tier] || x.tier || x.error}`).join('; '); |
| this.toast(I18n.t('toast.probeAllComplete', { summary })); |
| this.loadAccounts(); |
| } else { |
| this.toast(this.translateError(r.error, 'error.probeFailed'), 'error'); |
| } |
| } catch (e) { this.toast(I18n.t('error.probeFailed') + ': ' + e.message, 'error'); } |
| }, |
| |
| async probeAccount(id) { |
| this.toast(I18n.t('toast.probe.doing'), 'info'); |
| try { |
| const r = await this.api('POST', `/accounts/${id}/probe`, {}); |
| if (r.success) { |
| const tierLabel = { pro: 'Pro', free: 'Free', expired: I18n.t('tier.expiredLabel'), unknown: I18n.t('tier.unknown') }; |
| this.toast(I18n.t('toast.probeComplete', { tier: tierLabel[r.tier] || r.tier })); |
| this.loadAccounts(); |
| } else { |
| this.toast(this.translateError(r.error, 'error.probeFailed'), 'error'); |
| } |
| } catch (e) { this.toast(I18n.t('error.probeFailed') + ': ' + e.message, 'error'); } |
| }, |
| |
| async openBlockedModal(id) { |
| try { |
| const d = await this.api('GET', '/accounts', null); |
| const acct = (d.accounts || []).find(a => a.id === id); |
| if (!acct) return this.toast(I18n.t('error.accountNotFound'), 'error'); |
| const tierModels = acct.tierModels || []; |
| const blocked = new Set(acct.blockedModels || []); |
| if (!tierModels.length) return this.toast(I18n.t('toast.noTierModels'), 'info'); |
| const providerOf = (m) => { |
| if (m.startsWith('claude')) return 'anthropic'; |
| if (m.startsWith('gpt') || m.startsWith('o3') || m.startsWith('o4')) return 'openai'; |
| if (m.startsWith('gemini')) return 'google'; |
| if (m.startsWith('grok')) return 'xai'; |
| if (m.startsWith('deepseek')) return 'deepseek'; |
| if (m.startsWith('qwen')) return 'alibaba'; |
| if (m.startsWith('kimi')) return 'moonshot'; |
| if (m.startsWith('swe') || m.startsWith('arena')) return 'windsurf'; |
| return 'other'; |
| }; |
| const groups = {}; |
| for (const m of tierModels) (groups[providerOf(m)] = groups[providerOf(m)] || []).push(m); |
| const total = tierModels.length; |
| const availInit = total - blocked.size; |
| const html = ` |
| <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;padding:8px 10px;background:var(--surface-2);border-radius:6px"> |
| <div class="text-xs text-dim">${I18n.t('modal.blockedModels.hint')}</div> |
| <div style="font-size:13px"> |
| ${I18n.t('modal.blockedModels.enabled')} <span id="bm-avail" style="color:var(--success,#10b981);font-weight:600">${availInit}</span> |
| <span style="color:var(--text-dim)"> / ${total}</span> |
| </div> |
| </div> |
| <div style="display:flex;gap:6px;margin-bottom:10px"> |
| <button class="btn btn-ghost btn-xs" onclick="App.blockedModalToggleAll(true)">${I18n.t('action.selectAll')}</button> |
| <button class="btn btn-ghost btn-xs" onclick="App.blockedModalToggleAll(false)">${I18n.t('action.selectNone')}</button> |
| <button class="btn btn-ghost btn-xs" onclick="App.blockedModalInvert()">${I18n.t('action.invert')}</button> |
| </div> |
| <div style="max-height:50vh;overflow:auto;padding:2px 2px 2px 0"> |
| ${Object.entries(groups).map(([provider, models]) => ` |
| <div class="bm-group" data-provider="${provider}" style="margin-bottom:12px;border:1px solid var(--border);border-radius:6px;padding:8px 10px"> |
| <label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin-bottom:6px;padding-bottom:6px;border-bottom:1px solid var(--border)"> |
| <input type="checkbox" class="bm-group-cb" data-provider="${provider}"> |
| <span style="text-transform:uppercase;letter-spacing:.5px;font-size:11px;color:var(--text-muted);font-weight:600">${provider}</span> |
| <span class="text-xs text-dim" data-role="count">(${models.filter(m => !blocked.has(m)).length}/${models.length})</span> |
| </label> |
| <div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:2px 10px"> |
| ${models.map(m => ` |
| <label style="display:flex;align-items:center;gap:6px;padding:3px 4px;border-radius:3px;cursor:pointer;font-size:12px" onmouseover="this.style.background='var(--surface-2)'" onmouseout="this.style.background=''"> |
| <input type="checkbox" class="blocked-model-cb" data-model="${this.esc(m)}" data-provider="${provider}" ${blocked.has(m) ? '' : 'checked'}> |
| <span>${this.esc(m)}</span> |
| </label> |
| `).join('')} |
| </div> |
| </div> |
| `).join('')} |
| </div>`; |
| |
| |
| |
| |
| |
| this.confirm(I18n.t('modal.blockedModels.title', {email: acct.email}), html, { okText: I18n.t('action.save'), html: true, wide: true }); |
| setTimeout(() => this._bindBlockedModal(id), 0); |
| } catch (e) { this.toast(I18n.t('error.openFailed') + ': ' + e.message, 'error'); } |
| }, |
| |
| |
| |
| |
| |
| _bindBlockedModal(accountId) { |
| const root = document.querySelector('.modal-overlay:last-child'); |
| if (!root) return; |
| const updateGroup = (provider) => { |
| const group = root.querySelector(`.bm-group[data-provider="${provider}"]`); |
| if (!group) return; |
| const boxes = group.querySelectorAll('.blocked-model-cb'); |
| const checked = [...boxes].filter(b => b.checked).length; |
| const groupCb = group.querySelector('.bm-group-cb'); |
| groupCb.checked = checked === boxes.length; |
| groupCb.indeterminate = checked > 0 && checked < boxes.length; |
| group.querySelector('[data-role=count]').textContent = `(${checked}/${boxes.length})`; |
| }; |
| const updateTotal = () => { |
| const all = root.querySelectorAll('.blocked-model-cb'); |
| const checked = [...all].filter(b => b.checked).length; |
| const el = root.querySelector('#bm-avail'); |
| if (el) el.textContent = checked; |
| }; |
| root.querySelectorAll('.bm-group').forEach(g => updateGroup(g.dataset.provider)); |
| updateTotal(); |
| root.querySelectorAll('.blocked-model-cb').forEach(cb => { |
| cb.addEventListener('change', () => { updateGroup(cb.dataset.provider); updateTotal(); }); |
| }); |
| root.querySelectorAll('.bm-group-cb').forEach(gcb => { |
| gcb.addEventListener('change', () => { |
| const provider = gcb.dataset.provider; |
| root.querySelectorAll(`.blocked-model-cb[data-provider="${provider}"]`).forEach(cb => { cb.checked = gcb.checked; }); |
| updateGroup(provider); |
| updateTotal(); |
| }); |
| }); |
| |
| |
| const okBtn = root.querySelector('[data-act=ok]'); |
| const cancelBtn = root.querySelector('[data-act=cancel]'); |
| if (!okBtn || okBtn.dataset.bmBound) return; |
| okBtn.dataset.bmBound = '1'; |
| const id = accountId; |
| const self = this; |
| okBtn.onclick = async () => { |
| const newBlocked = []; |
| root.querySelectorAll('.blocked-model-cb').forEach(cb => { if (!cb.checked) newBlocked.push(cb.dataset.model); }); |
| okBtn.disabled = true; cancelBtn.disabled = true; |
| okBtn.textContent = I18n.t('status.saving'); |
| try { |
| const r = await self.api('PATCH', `/accounts/${id}`, { blockedModels: newBlocked }); |
| if (r.success) { |
| self.toast(I18n.t('modal.blockedModels.saved', {enabled: root.querySelectorAll('.blocked-model-cb:checked').length, disabled: newBlocked.length}), 'success'); |
| root.remove(); |
| self.loadAccounts(); |
| } else { |
| self.toast(self.translateError(r.error, 'error.saveFailed'), 'error'); |
| okBtn.disabled = false; cancelBtn.disabled = false; okBtn.textContent = I18n.t('action.save'); |
| } |
| } catch (e) { |
| self.toast(I18n.t('error.saveFailed') + ': ' + e.message, 'error'); |
| okBtn.disabled = false; cancelBtn.disabled = false; okBtn.textContent = I18n.t('action.save'); |
| } |
| }; |
| }, |
| |
| blockedModalToggleAll(checked) { |
| const root = document.querySelector('.modal-overlay:last-child'); |
| if (!root) return; |
| root.querySelectorAll('.blocked-model-cb').forEach(cb => { cb.checked = checked; }); |
| root.querySelectorAll('.blocked-model-cb').forEach(cb => cb.dispatchEvent(new Event('change'))); |
| }, |
| |
| blockedModalInvert() { |
| const root = document.querySelector('.modal-overlay:last-child'); |
| if (!root) return; |
| root.querySelectorAll('.blocked-model-cb').forEach(cb => { |
| cb.checked = !cb.checked; |
| cb.dispatchEvent(new Event('change')); |
| }); |
| }, |
| |
| async addAccount() { |
| const type = document.getElementById('acc-type').value; |
| const key = document.getElementById('acc-key').value.trim(); |
| const label = document.getElementById('acc-label').value.trim(); |
| const proxy = document.getElementById('acc-proxy').value.trim(); |
| if (!key) return this.toast(I18n.t('toast.enterKeyOrToken'), 'error'); |
| const body = type === 'api_key' ? { api_key: key, label, proxy } : { token: key, label, proxy }; |
| const r = await this.api('POST', '/accounts', body); |
| if (r.success) { this.toast(I18n.t('account.addSuccess')); document.getElementById('acc-key').value = ''; document.getElementById('acc-label').value = ''; document.getElementById('acc-proxy').value = ''; this.loadAccounts(); } |
| else this.toast(this.translateError(r.error, 'toast.addFailed'), 'error'); |
| }, |
| |
| |
| |
| |
| |
| async checkLocalImportAvailability() { |
| const btn = document.getElementById('local-import-btn'); |
| const hint = document.getElementById('local-import-hint'); |
| if (!btn) return; |
| try { |
| const a = await this.api('GET', '/accounts/import-local-availability'); |
| if (a.available) { |
| btn.disabled = false; |
| btn.classList.remove('disabled'); |
| if (hint) hint.textContent = ''; |
| return; |
| } |
| btn.disabled = true; |
| btn.classList.add('disabled'); |
| if (hint) { |
| hint.textContent = a.hint || I18n.t('localImport.unavailable'); |
| hint.style.color = 'var(--text-muted)'; |
| hint.style.fontSize = '12px'; |
| hint.style.lineHeight = '1.6'; |
| } |
| } catch (e) { |
| |
| |
| } |
| }, |
| |
| async exportLogs() { |
| const type = document.getElementById('log-export-type')?.value || 'all'; |
| const fmt = document.getElementById('log-export-format')?.value || 'jsonl'; |
| const level = document.getElementById('log-level')?.value || ''; |
| try { |
| const params = new URLSearchParams({ type, format: fmt }); |
| if (level) params.set('level', level); |
| const url = '/dashboard/api/logs/export?' + params.toString(); |
| |
| |
| const resp = await fetch(url, { |
| headers: { 'X-Dashboard-Password': sessionStorage.getItem('dashboard_password') || '' }, |
| }); |
| if (!resp.ok) { |
| this.toast(I18n.t('toast.exportFailed') + ': HTTP ' + resp.status, 'error'); |
| return; |
| } |
| const blob = await resp.blob(); |
| const cd = resp.headers.get('Content-Disposition') || ''; |
| const m = cd.match(/filename="([^"]+)"/); |
| const filename = m ? m[1] : `logs.${fmt}`; |
| const a = document.createElement('a'); |
| a.href = URL.createObjectURL(blob); |
| a.download = filename; |
| document.body.appendChild(a); |
| a.click(); |
| setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 100); |
| this.toast(I18n.t('toast.exportOk', { filename }) || 'downloaded', 'success'); |
| } catch (e) { |
| this.toast(I18n.t('toast.exportFailed') + ': ' + (e.message || ''), 'error'); |
| } |
| }, |
| |
| async discoverLocalWindsurf() { |
| const root = document.getElementById('local-windsurf-result'); |
| if (!root) return; |
| root.innerHTML = `<div class="text-sm" style="color:var(--text-muted)">${I18n.t('localImport.scanning')}</div>`; |
| try { |
| const r = await this.api('GET', '/accounts/import-local'); |
| if (r.error) { |
| const errKey = r.error ? `error.${r.error}` : null; |
| const msg = (errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.message || r.error); |
| root.innerHTML = `<div class="text-sm" style="color:var(--error)">${this.esc(msg)}</div>`; |
| return; |
| } |
| if (!r.accounts || r.accounts.length === 0) { |
| const sourcesHtml = (r.sources || []).map(s => `<li>${this.esc(s.path)} — ${s.ok ? `${s.accountCount} ${I18n.t('localImport.found')}` : this.esc(s.reason || 'unknown')}</li>`).join(''); |
| root.innerHTML = ` |
| <div style="padding:12px;background:var(--surface-2);border-radius:var(--radius);font-size:13px;line-height:1.6"> |
| <div style="color:var(--warn);font-weight:600">${I18n.t('localImport.empty')}</div> |
| <div style="color:var(--text-muted);margin-top:4px">${I18n.t('localImport.scannedPaths')}:</div> |
| <ul style="margin:6px 0 0 18px;color:var(--text-muted);font-size:12px">${sourcesHtml}</ul> |
| <div style="color:var(--text-muted);margin-top:6px">SQLite: ${r.sqliteSupport} · ${r.platform}</div> |
| </div>`; |
| return; |
| } |
| const rows = r.accounts.map((a, i) => ` |
| <tr> |
| <td>${this.esc(a.email || a.label || '-')}</td> |
| <td><code>${this.esc(a.apiKeyMasked)}</code></td> |
| <td class="text-xs" style="color:var(--text-muted)">${this.esc(a.source)}</td> |
| <td> |
| <button class="btn btn-sm btn-primary" onclick="App.importLocalWindsurfAccount(${i})">${I18n.t('localImport.importBtn')}</button> |
| </td> |
| </tr>`).join(''); |
| this._localWindsurfAccounts = r.accounts; |
| root.innerHTML = ` |
| <div style="padding:12px;background:var(--surface-2);border-radius:var(--radius)"> |
| <div style="font-weight:600;margin-bottom:8px">${I18n.t('localImport.foundCount', { count: r.accounts.length })}</div> |
| <table class="table table-compact"> |
| <thead><tr><th>${I18n.t('localImport.account')}</th><th>${I18n.t('localImport.key')}</th><th>${I18n.t('localImport.source')}</th><th></th></tr></thead> |
| <tbody>${rows}</tbody> |
| </table> |
| </div>`; |
| } catch (e) { |
| root.innerHTML = `<div class="text-sm" style="color:var(--error)">${this.esc(e.message || String(e))}</div>`; |
| } |
| }, |
| |
| async importLocalWindsurfAccount(idx) { |
| const acc = this._localWindsurfAccounts?.[idx]; |
| if (!acc) return; |
| const r = await this.api('POST', '/accounts', { api_key: acc.apiKey, label: acc.label }); |
| if (r.success) { |
| this.toast(I18n.t('localImport.importSuccess', { email: acc.email || acc.label })); |
| this.loadAccounts(); |
| } else { |
| const errKey = r.error ? `error.${r.error}` : null; |
| const msg = (errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.error || I18n.t('toast.addFailed')); |
| this.toast(msg, 'error'); |
| } |
| }, |
| |
| async toggleAccount(id, status) { await this.api('PATCH', `/accounts/${id}`, { status }); this.loadAccounts(); }, |
| |
| async overrideTier(id, current) { |
| const newTier = await this.prompt( |
| I18n.t('modal.overrideTier.title'), |
| I18n.t('modal.overrideTier.desc', {current}), |
| [{ name: 'tier', label: I18n.t('field.tier.label'), type: 'select', options: [ |
| { value: 'pro', label: I18n.t('modal.overrideTier.options.pro') }, |
| { value: 'free', label: I18n.t('modal.overrideTier.options.free') }, |
| { value: 'unknown', label: I18n.t('modal.overrideTier.options.unknown') }, |
| ], value: current }] |
| ); |
| if (!newTier) return; |
| await this.api('PATCH', `/accounts/${id}`, { tier: newTier.tier }); |
| this.toast(I18n.t('account.tierSet', {tier: newTier.tier}), 'success'); |
| this.loadAccounts(); |
| }, |
| async resetErrors(id) { await this.api('PATCH', `/accounts/${id}`, { resetErrors: true }); this.loadAccounts(); this.toast(I18n.t('account.resetErrors')); }, |
| async deleteAccount(id) { |
| const ok = await this.confirm(I18n.t('modal.deleteAccount.title'), I18n.t('modal.deleteAccount.desc'), { danger: true, okText: I18n.t('action.delete') }); |
| if (!ok) return; |
| await this.api('DELETE', `/accounts/${id}`); |
| this.loadAccounts(); |
| this.toast(I18n.t('account.deleteSuccess')); |
| }, |
| |
| |
| async loadModels() { |
| const [modelData, accessData] = await Promise.all([ |
| this.api('GET', '/models'), |
| this.api('GET', '/model-access'), |
| ]); |
| this.allModels = modelData.models || []; |
| this.modelAccessConfig = accessData.mode ? accessData : { mode: 'all', list: [] }; |
| document.querySelector(`input[name="model-mode"][value="${this.modelAccessConfig.mode}"]`).checked = true; |
| this.updateModelListUI(); |
| }, |
| |
| updateModelListUI() { |
| const sec = document.getElementById('model-list-section'); |
| if (this.modelAccessConfig.mode === 'all') { |
| sec.classList.add('hidden'); |
| return; |
| } |
| sec.classList.remove('hidden'); |
| const isAllow = this.modelAccessConfig.mode === 'allowlist'; |
| document.getElementById('model-list-title').textContent = I18n.t(isAllow ? 'section.modelList.titleAllow' : 'section.modelList.titleBlock'); |
| document.getElementById('model-list-hint').textContent = I18n.t(isAllow ? 'section.modelList.descAllow' : 'section.modelList.descBlock'); |
| |
| const current = this.modelAccessConfig.list; |
| if (current.length > 0) { |
| document.getElementById('model-list-current').innerHTML = ` |
| <div class="text-xs text-muted" style="margin-bottom:6px">${I18n.t('model.list.current', {count: current.length})}</div> |
| <div class="model-chips">${current.map(m => `<span class="model-chip selected">${this.esc(m)}<span class="remove" onclick="App.removeModelFromList('${this.escJsAttr(m)}')">×</span></span>`).join('')}</div>`; |
| } else { |
| document.getElementById('model-list-current').innerHTML = `<div class="text-xs text-dim">${I18n.t('model.list.empty')}</div>`; |
| } |
| this.filterModels(); |
| }, |
| |
| filterModels() { |
| const search = (document.getElementById('model-search')?.value || '').toLowerCase(); |
| const provider = document.getElementById('model-provider-filter')?.value || ''; |
| const list = this.modelAccessConfig.list; |
| |
| const filtered = this.allModels.filter(m => { |
| if (search && !m.name.toLowerCase().includes(search) && !m.provider.toLowerCase().includes(search)) return false; |
| if (provider && m.provider !== provider) return false; |
| return true; |
| }); |
| |
| const grouped = {}; |
| for (const m of filtered) { |
| if (!grouped[m.provider]) grouped[m.provider] = []; |
| grouped[m.provider].push(m); |
| } |
| |
| const container = document.getElementById('model-chips-container'); |
| container.innerHTML = Object.entries(grouped).map(([prov, models]) => ` |
| <div class="provider-group"> |
| <div class="provider-label">${prov}</div> |
| <div class="model-chips">${models.map(m => { |
| const inList = list.includes(m.id); |
| return `<span class="model-chip ${inList ? 'selected' : ''}" onclick="App.toggleModelInList('${this.escJsAttr(m.id)}')">${this.esc(m.name)}</span>`; |
| }).join('')}</div> |
| </div> |
| `).join('') || `<div class="text-sm text-dim">${I18n.t('table.empty.models')}</div>`; |
| }, |
| |
| async setModelMode(mode) { |
| await this.api('PUT', '/model-access', { mode }); |
| this.modelAccessConfig.mode = mode; |
| this.updateModelListUI(); |
| this.toast(I18n.t('toast.modeUpdated')); |
| }, |
| |
| async toggleModelInList(modelId) { |
| const idx = this.modelAccessConfig.list.indexOf(modelId); |
| if (idx > -1) { |
| await this.api('POST', '/model-access/remove', { model: modelId }); |
| this.modelAccessConfig.list.splice(idx, 1); |
| } else { |
| await this.api('POST', '/model-access/add', { model: modelId }); |
| this.modelAccessConfig.list.push(modelId); |
| } |
| this.updateModelListUI(); |
| }, |
| |
| async removeModelFromList(modelId) { |
| await this.api('POST', '/model-access/remove', { model: modelId }); |
| this.modelAccessConfig.list = this.modelAccessConfig.list.filter(m => m !== modelId); |
| this.updateModelListUI(); |
| }, |
| |
| |
| async loadProxy() { |
| const d = await this.api('GET', '/proxy'); |
| if (d.global) { |
| document.getElementById('proxy-type').value = d.global.type || 'http'; |
| document.getElementById('proxy-host').value = (d.global.host || '').replace(/:\d+$/, ''); |
| document.getElementById('proxy-port').value = d.global.port || ''; |
| document.getElementById('proxy-user').value = d.global.username || ''; |
| |
| |
| document.getElementById('proxy-pass').value = ''; |
| document.getElementById('proxy-pass').placeholder = d.global.hasPassword ? I18n.t('proxy.placeholderSaved') : I18n.t('proxy.placeholderPassword'); |
| this._globalProxyHasPassword = !!d.global.hasPassword; |
| const h = (d.global.host || '').replace(/:\d+$/, ''); |
| const authInfo = d.global.username ? `(${I18n.t('proxy.auth')}: ${d.global.username})` : ''; |
| document.getElementById('proxy-current').textContent = `${I18n.t('proxy.current')}: ${d.global.type}://${h}:${d.global.port}${authInfo}`; |
| } else { |
| this._globalProxyHasPassword = false; |
| document.getElementById('proxy-pass').placeholder = I18n.t('proxy.placeholderPassword'); |
| document.getElementById('proxy-current').textContent = I18n.t('proxy.notConfigured'); |
| } |
| const accts = await this.api('GET', '/accounts'); |
| const tbody = document.querySelector('#proxy-accounts-table tbody'); |
| const pa = d.perAccount || {}; |
| tbody.innerHTML = (accts.accounts || []).map(a => { |
| const p = pa[a.id]; |
| return `<tr> |
| <td>${this.esc(a.email)} <code class="text-xs">${a.id}</code></td> |
| <td>${p ? `<code>${this.esc(p.type)}://${p.username ? this.esc(p.username) + '@' : ''}${this.esc(p.host)}:${this.esc(String(p.port))}</code>` : `<span class="text-sm text-dim">${I18n.t('proxy.none')}</span>`}</td> |
| <td class="nowrap"> |
| <div class="btn-group"> |
| <button class="btn btn-outline btn-xs" onclick="App.editAccountProxy('${this.escJsAttr(a.id)}','${this.escJsAttr(a.email)}')">${I18n.t('action.configure')}</button> |
| ${p ? `<button class="btn btn-ghost btn-xs" style="color:var(--error)" onclick="App.clearAccountProxy('${this.escJsAttr(a.id)}')">${I18n.t('action.clear')}</button>` : ''} |
| </div> |
| </td> |
| </tr>`; |
| }).join('') || `<tr class="empty-row"><td colspan="3">${I18n.t('table.empty.accountsProxy')}</td></tr>`; |
| }, |
| |
| async saveGlobalProxy() { |
| const cfg = { |
| type: document.getElementById('proxy-type').value, |
| host: document.getElementById('proxy-host').value.trim(), |
| port: document.getElementById('proxy-port').value, |
| username: document.getElementById('proxy-user').value, |
| }; |
| |
| |
| |
| const pwInput = document.getElementById('proxy-pass').value; |
| if (pwInput || !this._globalProxyHasPassword) cfg.password = pwInput; |
| if (!cfg.host) return this.toast(I18n.t('toast.enterProxyHost'), 'error'); |
| const r = await this.api('PUT', '/proxy/global', cfg); |
| if (r && r.error) return this.toast(r.error, 'error'); |
| this.toast(I18n.t('toast.globalProxySaved')); |
| this.loadProxy(); |
| }, |
| |
| async clearGlobalProxy() { |
| await this.api('DELETE', '/proxy/global'); |
| ['proxy-host','proxy-port','proxy-user','proxy-pass'].forEach(id => document.getElementById(id).value = ''); |
| this.toast(I18n.t('toast.globalProxyCleared')); |
| this.loadProxy(); |
| }, |
| |
| async editAccountProxy(id, label) { |
| const values = await this.prompt( |
| I18n.t('modal.configureProxy.title'), |
| I18n.t('modal.configureProxy.desc', {label}), |
| [ |
| { name: 'type', label: I18n.t('field.type.label'), type: 'select', value: 'http', options: [ |
| { value: 'http', label: 'HTTP' }, |
| { value: 'https', label: 'HTTPS' }, |
| { value: 'socks5', label: 'SOCKS5' }, |
| ]}, |
| { name: 'host', label: I18n.t('field.host.label'), placeholder: I18n.t('field.host.placeholder') }, |
| { name: 'port', label: I18n.t('field.port.label'), type: 'number', placeholder: I18n.t('field.port.placeholder') }, |
| { name: 'username', label: I18n.t('field.username.label'), placeholder: I18n.t('field.username.placeholder') }, |
| { name: 'password', label: I18n.t('field.passwordProxy.label'), type: 'password', placeholder: I18n.t('field.passwordProxy.placeholder') }, |
| ] |
| ); |
| if (!values || !values.host) return; |
| await this.api('PUT', `/proxy/accounts/${id}`, { |
| type: values.type || 'http', |
| host: values.host, |
| port: parseInt(values.port) || 8080, |
| username: values.username || '', |
| password: values.password || '', |
| }); |
| this.toast(I18n.t('toast.proxyConfigured')); |
| this.loadProxy(); |
| }, |
| |
| async clearAccountProxy(id) { |
| await this.api('DELETE', `/proxy/accounts/${id}`); |
| this.toast(I18n.t('toast.proxyCleared')); |
| this.loadProxy(); |
| }, |
| |
| |
| |
| |
| |
| |
| |
| loadLogs() { |
| if (this.sseConn) { try { this.sseConn.close(); } catch {} this.sseConn = null; } |
| this.logEntries = []; |
| document.getElementById('log-container').innerHTML = ''; |
| |
| const headers = { 'Accept': 'text/event-stream' }; |
| if (this.password) headers['X-Dashboard-Password'] = this.password; |
| |
| const controller = new AbortController(); |
| this.sseConn = { close: () => controller.abort() }; |
| |
| fetch('/dashboard/api/logs/stream', { headers, signal: controller.signal }) |
| .then(response => { |
| if (!response.ok || !response.body) throw new Error('SSE connection failed'); |
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ''; |
| const pump = () => reader.read().then(({ done, value }) => { |
| if (done) return; |
| buffer += decoder.decode(value, { stream: true }); |
| |
| |
| let idx; |
| while ((idx = buffer.indexOf('\n\n')) !== -1) { |
| const frame = buffer.slice(0, idx); |
| buffer = buffer.slice(idx + 2); |
| const payload = frame.split('\n') |
| .filter(l => l.startsWith('data: ')) |
| .map(l => l.slice(6)) |
| .join('\n'); |
| if (!payload) continue; |
| try { |
| const entry = JSON.parse(payload); |
| this.logEntries.push(entry); |
| if (this.logEntries.length > 500) this.logEntries.shift(); |
| this.renderLogEntry(entry); |
| } catch {} |
| } |
| return pump(); |
| }); |
| return pump(); |
| }) |
| .catch((err) => { |
| if (err?.name !== 'AbortError') { |
| |
| |
| } |
| }); |
| }, |
| |
| renderLogEntry(entry) { |
| const container = document.getElementById('log-container'); |
| const levelFilter = document.getElementById('log-level').value; |
| const search = document.getElementById('log-search').value.toLowerCase(); |
| if (levelFilter && entry.level !== levelFilter) return; |
| if (search && !entry.msg.toLowerCase().includes(search)) return; |
| const div = document.createElement('div'); |
| div.className = 'log-entry ' + entry.level; |
| const ts = new Date(entry.ts).toLocaleTimeString(); |
| div.innerHTML = `<span class="ts">${ts}</span><span class="lvl">${entry.level.toUpperCase()}</span><span>${this.esc(entry.msg)}</span>`; |
| container.appendChild(div); |
| if (container.children.length > 500) container.removeChild(container.firstChild); |
| if (document.getElementById('log-autoscroll').checked) container.scrollTop = container.scrollHeight; |
| }, |
| |
| debouncedFilterLogs() { |
| clearTimeout(this._filterTimer); |
| this._filterTimer = setTimeout(() => this.filterLogs(), 200); |
| }, |
| |
| filterLogs() { |
| const container = document.getElementById('log-container'); |
| const frag = document.createDocumentFragment(); |
| const levelFilter = document.getElementById('log-level').value; |
| const search = document.getElementById('log-search').value.toLowerCase(); |
| for (const entry of this.logEntries) { |
| if (levelFilter && entry.level !== levelFilter) continue; |
| if (search && !entry.msg.toLowerCase().includes(search)) continue; |
| const div = document.createElement('div'); |
| div.className = 'log-entry ' + entry.level; |
| const ts = new Date(entry.ts).toLocaleTimeString(); |
| div.innerHTML = `<span class="ts">${ts}</span><span class="lvl">${entry.level.toUpperCase()}</span><span>${this.esc(entry.msg)}</span>`; |
| frag.appendChild(div); |
| } |
| container.innerHTML = ''; |
| container.appendChild(frag); |
| }, |
| |
| clearLogView() { |
| this.logEntries = []; |
| document.getElementById('log-container').innerHTML = ''; |
| }, |
| |
| |
| statsRange: 24, |
| statsCustomRange: null, |
| statsChartType: 'bar', |
| statsMetrics: { requests: true, success: true, errors: true }, |
| _lastStatsData: null, |
| |
| |
| |
| |
| _chartColors: { |
| requests: '#e0b482', |
| success: '#7cc8a6', |
| errors: '#e08a8a', |
| }, |
| _pieColors: ['#e0b482', '#7cc8a6', '#e08a8a', '#f5d491', '#9bb8d4', '#c9a6cf', '#84c5c9', '#dda5b3'], |
| |
| setStatsRange(value) { |
| this.statsRange = value; |
| this.statsCustomRange = null; |
| document.querySelectorAll('.stats-range-btn').forEach(b => { |
| b.classList.toggle('active', String(b.dataset.range) === String(value)); |
| }); |
| this.closeCustomRange(); |
| if (this._lastStatsData) this.renderMainChart(this._lastStatsData); |
| }, |
| |
| openCustomRange() { |
| const popup = document.getElementById('custom-range-popup'); |
| if (!popup) return; |
| popup.style.display = 'block'; |
| |
| const end = new Date(); |
| const start = new Date(end.getTime() - 7 * 86400_000); |
| const fmt = d => d.toISOString().slice(0, 10); |
| const sEl = document.getElementById('range-start'); |
| const eEl = document.getElementById('range-end'); |
| if (sEl && !sEl.value) sEl.value = fmt(start); |
| if (eEl && !eEl.value) eEl.value = fmt(end); |
| document.querySelectorAll('.stats-range-btn').forEach(b => { |
| b.classList.toggle('active', b.dataset.range === 'custom'); |
| }); |
| }, |
| |
| closeCustomRange() { |
| const popup = document.getElementById('custom-range-popup'); |
| if (popup) popup.style.display = 'none'; |
| }, |
| |
| applyCustomRange() { |
| const sEl = document.getElementById('range-start'); |
| const eEl = document.getElementById('range-end'); |
| if (!sEl?.value || !eEl?.value) { this.toast(I18n.t('range.customStart'), 'error'); return; } |
| const start = new Date(sEl.value + 'T00:00:00'); |
| const end = new Date(eEl.value + 'T23:59:59'); |
| if (isNaN(start) || isNaN(end) || start > end) { this.toast('Invalid range', 'error'); return; } |
| this.statsRange = 'custom'; |
| this.statsCustomRange = { start, end }; |
| this.closeCustomRange(); |
| document.querySelectorAll('.stats-range-btn').forEach(b => { |
| b.classList.toggle('active', b.dataset.range === 'custom'); |
| }); |
| if (this._lastStatsData) this.renderMainChart(this._lastStatsData); |
| }, |
| |
| setChartType(type) { |
| this.statsChartType = type; |
| document.querySelectorAll('.stats-type-btn').forEach(b => { |
| b.classList.toggle('active', b.dataset.type === type); |
| }); |
| if (this._lastStatsData) this.renderMainChart(this._lastStatsData); |
| }, |
| |
| toggleChartMetric(metric) { |
| const m = this.statsMetrics; |
| const tryOff = { ...m, [metric]: !m[metric] }; |
| if (!tryOff.requests && !tryOff.success && !tryOff.errors) return; |
| this.statsMetrics = tryOff; |
| if (this._lastStatsData) this.renderMainChart(this._lastStatsData); |
| }, |
| |
| async loadStats() { |
| const d = await this.api('GET', '/stats'); |
| this._lastStatsData = d; |
| const totalReq = d.totalRequests || 0; |
| const successRate = totalReq > 0 ? ((d.successCount/totalReq)*100).toFixed(1) : '0.0'; |
| const allP95 = Object.values(d.modelCounts || {}) |
| .filter(m => m.p95Ms > 0).map(m => m.p95Ms); |
| const avgP95 = allP95.length ? Math.round(allP95.reduce((a,b)=>a+b,0) / allP95.length) : 0; |
| const uptimeSec = d.startedAt ? Math.floor((Date.now() - d.startedAt) / 1000) : 0; |
| const fmtUptime = s => s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m` : s < 86400 ? `${(s/3600).toFixed(1)}h` : `${(s/86400).toFixed(1)}d`; |
| |
| |
| |
| |
| |
| const tt = d.tokenTotals || { fresh_input: 0, cache_read: 0, cache_write: 0, output: 0, total: 0, requests_with_usage: 0 }; |
| const fmtTok = (n) => n >= 1_000_000 ? (n/1_000_000).toFixed(1) + 'M' |
| : n >= 1_000 ? (n/1_000).toFixed(1) + 'K' |
| : String(n); |
| const tokensCardSub = tt.requests_with_usage > 0 |
| ? `${tt.requests_with_usage} req · total ${fmtTok(tt.total)}` |
| : (I18n.t('card.tokens.subtitle.empty') || 'no usage data yet'); |
| |
| document.getElementById('stats-cards').innerHTML = ` |
| <div class="card info"><div class="card-header"><div class="card-title">${I18n.t('card.stats.title')}</div></div><div class="card-value">${totalReq}</div><div class="card-sub">${I18n.t('card.stats.subtitle', { time: fmtUptime(uptimeSec) })}</div></div> |
| <div class="card success"><div class="card-header"><div class="card-title">${I18n.t('card.success.title')}</div></div><div class="card-value">${d.successCount || 0}</div><div class="card-sub">${I18n.t('card.success.subtitle', { rate: successRate })}</div></div> |
| <div class="card error"><div class="card-header"><div class="card-title">${I18n.t('card.errors.title')}</div></div><div class="card-value">${d.errorCount || 0}</div><div class="card-sub">${I18n.t('card.errors.subtitle', { rate: totalReq > 0 ? (100-Number(successRate)).toFixed(1) : '0.0' })}</div></div> |
| <div class="card accent"><div class="card-header"><div class="card-title">${I18n.t('card.p95Latency.title')}</div></div><div class="card-value">${avgP95}<span style="font-size:14px;font-weight:400;margin-left:4px">ms</span></div><div class="card-sub">${I18n.t('card.p95Latency.subtitle', { count: allP95.length })}</div></div> |
| <div class="card info" style="grid-column: span 2;"> |
| <div class="card-header"><div class="card-title">${I18n.t('card.tokens.title') || 'Token usage breakdown'}</div></div> |
| <div style="display:grid;grid-template-columns:repeat(4, 1fr);gap:12px;margin-top:8px;"> |
| <div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">fresh input</div><div style="font-size:18px;font-weight:600;">${fmtTok(tt.fresh_input)}</div></div> |
| <div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">cache read</div><div style="font-size:18px;font-weight:600;">${fmtTok(tt.cache_read)}</div></div> |
| <div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">cache write</div><div style="font-size:18px;font-weight:600;">${fmtTok(tt.cache_write)}</div></div> |
| <div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;">output</div><div style="font-size:18px;font-weight:600;">${fmtTok(tt.output)}</div></div> |
| </div> |
| <div class="card-sub" style="margin-top:8px;">${tokensCardSub}</div> |
| </div> |
| `; |
| |
| const models = d.modelCounts || {}; |
| const sortedModels = Object.entries(models).sort(([,a],[,b]) => b.requests - a.requests); |
| const tbody = document.querySelector('#model-stats-table tbody'); |
| tbody.innerHTML = sortedModels.map(([m, s]) => { |
| const rate = s.requests > 0 ? ((s.success/s.requests)*100).toFixed(1) : '0.0'; |
| const rateColor = Number(rate) >= 95 ? 'var(--success)' : Number(rate) >= 80 ? 'var(--warning, #f59e0b)' : 'var(--error)'; |
| return ` |
| <tr> |
| <td><code>${this.esc(m)}</code></td> |
| <td>${s.requests}</td> |
| <td style="color:var(--success)">${s.success}</td> |
| <td style="color:${s.errors > 0 ? 'var(--error)' : 'inherit'}">${s.errors}</td> |
| <td style="color:${rateColor};font-weight:600">${rate}%</td> |
| <td class="text-sm">${s.avgMs || 0} ms</td> |
| <td class="text-sm">${s.p50Ms || 0} ms</td> |
| <td class="text-sm">${s.p95Ms || 0} ms</td> |
| </tr>`; |
| }).join('') || `<tr class="empty-row"><td colspan="8">${I18n.t('table.empty.noRequestData')}</td></tr>`; |
| |
| const accounts = d.accountCounts || {}; |
| const sortedAccts = Object.entries(accounts).sort(([,a],[,b]) => b.requests - a.requests); |
| const acctBody = document.querySelector('#account-stats-table tbody'); |
| if (acctBody) { |
| acctBody.innerHTML = sortedAccts.map(([aid, s]) => { |
| const rate = s.requests > 0 ? ((s.success/s.requests)*100).toFixed(1) : '0.0'; |
| return `<tr> |
| <td><code>${this.esc(aid)}</code></td> |
| <td>${s.requests}</td> |
| <td style="color:var(--success)">${s.success}</td> |
| <td style="color:${s.errors > 0 ? 'var(--error)' : 'inherit'}">${s.errors}</td> |
| <td>${rate}%</td> |
| </tr>`; |
| }).join('') || `<tr class="empty-row"><td colspan="5">${I18n.t('table.empty.noAccountRequestData')}</td></tr>`; |
| } |
| |
| this.renderMainChart(d); |
| this.renderModelPie(d); |
| |
| this.poll('stats', () => this.loadStats(), 30000); |
| }, |
| |
| _setupCanvas(canvas) { |
| const dpr = window.devicePixelRatio || 1; |
| const rect = canvas.getBoundingClientRect(); |
| canvas.width = rect.width * dpr; |
| canvas.height = rect.height * dpr; |
| const ctx = canvas.getContext('2d'); |
| ctx.setTransform(1, 0, 0, 1, 0, 0); |
| ctx.scale(dpr, dpr); |
| return { ctx, W: rect.width, H: rect.height }; |
| }, |
| |
| _filterBuckets(raw) { |
| const all = (raw || []).map(b => ({ |
| hour: b.hour, |
| t: new Date(b.hour).getTime(), |
| requests: b.requests || 0, |
| errors: b.errors || 0, |
| success: Math.max(0, (b.requests || 0) - (b.errors || 0)), |
| })); |
| if (this.statsRange === 'custom' && this.statsCustomRange) { |
| const { start, end } = this.statsCustomRange; |
| const inRange = all.filter(b => b.t >= start.getTime() && b.t <= end.getTime()); |
| return this._padTimeline(inRange, start.getTime(), end.getTime()); |
| } |
| if (this.statsRange === 'all' || this.statsRange === -1) return all; |
| |
| |
| |
| |
| const n = Number(this.statsRange); |
| if (!Number.isFinite(n) || n <= 0) return all.slice(-n); |
| const endMs = Math.floor(Date.now() / 3600_000) * 3600_000; |
| const startMs = endMs - (n - 1) * 3600_000; |
| return this._padTimeline(all.filter(b => b.t >= startMs && b.t <= endMs), startMs, endMs); |
| }, |
| |
| |
| |
| |
| |
| |
| _padTimeline(present, startMs, endMs) { |
| const map = new Map(); |
| for (const b of present) map.set(Math.floor(b.t / 3600_000) * 3600_000, b); |
| const out = []; |
| const startHour = Math.floor(startMs / 3600_000) * 3600_000; |
| const endHour = Math.floor(endMs / 3600_000) * 3600_000; |
| for (let t = startHour; t <= endHour; t += 3600_000) { |
| const hit = map.get(t); |
| if (hit) { out.push(hit); continue; } |
| out.push({ |
| hour: new Date(t).toISOString(), |
| t, |
| requests: 0, |
| errors: 0, |
| success: 0, |
| }); |
| } |
| return out; |
| }, |
| |
| |
| |
| _downsample(buckets, maxPoints) { |
| if (buckets.length <= maxPoints) return buckets; |
| const step = Math.ceil(buckets.length / maxPoints); |
| const out = []; |
| for (let i = 0; i < buckets.length; i += step) { |
| const g = buckets.slice(i, i + step); |
| const mid = g[Math.floor(g.length / 2)]; |
| out.push({ |
| hour: mid.hour, |
| t: mid.t, |
| requests: g.reduce((a, b) => a + b.requests, 0), |
| errors: g.reduce((a, b) => a + b.errors, 0), |
| success: g.reduce((a, b) => a + b.success, 0), |
| }); |
| } |
| return out; |
| }, |
| |
| |
| |
| _smoothPath(ctx, points, move = true) { |
| if (points.length < 1) return; |
| if (move) ctx.moveTo(points[0].x, points[0].y); |
| else ctx.lineTo(points[0].x, points[0].y); |
| for (let i = 0; i < points.length - 1; i++) { |
| const p0 = points[i - 1] || points[i]; |
| const p1 = points[i]; |
| const p2 = points[i + 1]; |
| const p3 = points[i + 2] || p2; |
| const tension = 0.28; |
| const cp1x = p1.x + (p2.x - p0.x) * tension; |
| const cp1y = p1.y + (p2.y - p0.y) * tension; |
| const cp2x = p2.x - (p3.x - p1.x) * tension; |
| const cp2y = p2.y - (p3.y - p1.y) * tension; |
| ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y); |
| } |
| }, |
| |
| renderMainChart(d) { |
| const canvas = document.getElementById('stats-canvas'); |
| const legend = document.getElementById('stats-legend'); |
| const rangeIndicator = document.getElementById('stats-range-indicator'); |
| if (!canvas) return; |
| |
| let buckets = this._filterBuckets(d.hourlyBuckets); |
| const MAX_POINTS = 160; |
| const originalLength = buckets.length; |
| buckets = this._downsample(buckets, MAX_POINTS); |
| |
| const { ctx, W, H } = this._setupCanvas(canvas); |
| |
| if (rangeIndicator) { |
| const rangeText = this._rangeLabel(originalLength, buckets.length); |
| rangeIndicator.textContent = rangeText; |
| rangeIndicator.style.display = rangeText ? 'block' : 'none'; |
| } |
| |
| if (buckets.length < 1) { |
| ctx.clearRect(0, 0, W, H); |
| ctx.fillStyle = 'rgba(255,255,255,.25)'; |
| ctx.font = '500 13px var(--font), sans-serif'; |
| ctx.textAlign = 'center'; |
| ctx.textBaseline = 'middle'; |
| ctx.fillText(I18n.t('table.empty.noData'), W / 2, H / 2); |
| if (legend) legend.innerHTML = ''; |
| this._chartState = null; |
| this._chartHitMap = null; |
| return; |
| } |
| |
| const pad = { t: 18, r: 14, b: 30, l: 44 }; |
| const cW = W - pad.l - pad.r, cH = H - pad.t - pad.b; |
| const step = buckets.length > 1 ? cW / (buckets.length - 1) : cW; |
| const slotW = buckets.length > 1 ? cW / buckets.length : cW; |
| const m = { ...this.statsMetrics }; |
| const type = this.statsChartType; |
| |
| let maxY; |
| if (type === 'stacked') { |
| maxY = Math.max(1, ...buckets.map(b => (m.success ? b.success : 0) + (m.errors ? b.errors : 0) || (m.requests ? b.requests : 0))); |
| } else if (type === 'bar') { |
| maxY = Math.max(1, ...buckets.map(b => b.requests)); |
| } else { |
| const vals = []; |
| if (m.requests) vals.push(...buckets.map(b => b.requests)); |
| if (m.success) vals.push(...buckets.map(b => b.success)); |
| if (m.errors) vals.push(...buckets.map(b => b.errors)); |
| maxY = Math.max(1, ...vals); |
| } |
| const niceY = (n) => { |
| const exp = Math.pow(10, Math.floor(Math.log10(n))); |
| const frac = n / exp; |
| const fr = frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 2.5 ? 2.5 : frac <= 5 ? 5 : 10; |
| return fr * exp; |
| }; |
| maxY = niceY(maxY); |
| |
| const xAt = (i) => buckets.length > 1 ? pad.l + i * step : pad.l + cW / 2; |
| const yAt = (v) => pad.t + cH - (v / maxY) * cH; |
| |
| |
| this._chartState = { ctx, W, H, buckets, pad, cH, cW, slotW, step, maxY, m, type, xAt, yAt }; |
| this._chartHitMap = { buckets, xAt, yAt, pad, cH, slotW, W, H }; |
| |
| |
| this._bindChartHover(); |
| |
| |
| cancelAnimationFrame(this._chartAnim || 0); |
| const start = performance.now(); |
| const dur = 480; |
| const loop = (now) => { |
| const t = Math.min(1, (now - start) / dur); |
| const p = 1 - Math.pow(1 - t, 3); |
| this._paintChart(p); |
| if (t < 1) this._chartAnim = requestAnimationFrame(loop); |
| }; |
| this._chartAnim = requestAnimationFrame(loop); |
| |
| |
| if (legend) { |
| const totals = { |
| requests: buckets.reduce((a, b) => a + b.requests, 0), |
| success: buckets.reduce((a, b) => a + b.success, 0), |
| errors: buckets.reduce((a, b) => a + b.errors, 0), |
| }; |
| const items = [ |
| { key: 'requests', label: I18n.t('section.requestChart.legendRequests'), color: this._chartColors.requests }, |
| { key: 'success', label: I18n.t('section.requestChart.legendSuccess'), color: this._chartColors.success }, |
| { key: 'errors', label: I18n.t('section.requestChart.legendErrors'), color: this._chartColors.errors }, |
| ]; |
| legend.innerHTML = items.map(it => ` |
| <div class="chart-legend-item ${m[it.key] ? '' : 'muted'}" onclick="App.toggleChartMetric('${it.key}')"> |
| <span class="chart-legend-swatch dot" style="background:${it.color}"></span> |
| <span>${it.label}</span> |
| <span class="chart-legend-value">${totals[it.key]}</span> |
| </div> |
| `).join(''); |
| } |
| }, |
| |
| _paintChart(progress) { |
| const s = this._chartState; |
| if (!s) return; |
| const { ctx, W, H, buckets, pad, cH, cW, slotW, maxY, m, type, xAt, yAt } = s; |
| const p = progress; |
| |
| ctx.clearRect(0, 0, W, H); |
| |
| |
| const bgGrad = ctx.createLinearGradient(0, pad.t, 0, pad.t + cH); |
| bgGrad.addColorStop(0, 'rgba(129,140,248,.02)'); |
| bgGrad.addColorStop(1, 'rgba(129,140,248,0)'); |
| ctx.fillStyle = bgGrad; |
| ctx.fillRect(pad.l, pad.t, cW, cH); |
| |
| |
| ctx.save(); |
| ctx.strokeStyle = 'rgba(255,255,255,.045)'; |
| ctx.lineWidth = 1; |
| ctx.setLineDash([2, 4]); |
| for (let i = 0; i <= 4; i++) { |
| const y = pad.t + (cH / 4) * i; |
| ctx.beginPath(); |
| ctx.moveTo(pad.l, y); |
| ctx.lineTo(W - pad.r, y); |
| ctx.stroke(); |
| } |
| ctx.restore(); |
| |
| |
| ctx.fillStyle = 'rgba(255,255,255,.38)'; |
| ctx.font = '500 10px "JetBrains Mono", "Consolas", monospace'; |
| ctx.textAlign = 'right'; |
| ctx.textBaseline = 'middle'; |
| for (let i = 0; i <= 4; i++) { |
| const v = Math.round(maxY * (4 - i) / 4); |
| const y = pad.t + (cH / 4) * i; |
| ctx.fillText(this._fmtNum(v), pad.l - 10, y); |
| } |
| |
| const base = pad.t + cH; |
| |
| const drawSmoothSeries = (values, color, opts = {}) => { |
| |
| const points = values.map((v, i) => { |
| const targetY = yAt(v); |
| return { x: xAt(i), y: base + (targetY - base) * p }; |
| }); |
| if (points.length < 1) return; |
| if (opts.fill && points.length >= 2) { |
| const grad = ctx.createLinearGradient(0, pad.t, 0, pad.t + cH); |
| grad.addColorStop(0, color + '4d'); |
| grad.addColorStop(1, color + '00'); |
| ctx.fillStyle = grad; |
| ctx.beginPath(); |
| ctx.moveTo(points[0].x, base); |
| this._smoothPath(ctx, points, false); |
| ctx.lineTo(points[points.length - 1].x, base); |
| ctx.closePath(); |
| ctx.fill(); |
| } |
| ctx.save(); |
| ctx.strokeStyle = color; |
| ctx.lineWidth = opts.thin ? 1.5 : 2.25; |
| ctx.lineCap = 'round'; |
| ctx.lineJoin = 'round'; |
| if (opts.dash) ctx.setLineDash([5, 4]); |
| ctx.shadowColor = color; |
| ctx.shadowBlur = opts.fill ? 8 : 4; |
| ctx.beginPath(); |
| if (points.length >= 2) { |
| this._smoothPath(ctx, points); |
| } else { |
| ctx.moveTo(points[0].x, points[0].y); |
| } |
| ctx.stroke(); |
| ctx.restore(); |
| |
| |
| if (points.length <= 80 && p > 0.6) { |
| const dotOpacity = Math.min(1, (p - 0.6) / 0.4); |
| ctx.save(); |
| ctx.globalAlpha = dotOpacity; |
| points.forEach((pt, i) => { |
| if (values[i] === 0) return; |
| ctx.beginPath(); |
| ctx.arc(pt.x, pt.y, 2.5, 0, Math.PI * 2); |
| ctx.fillStyle = '#09090b'; |
| ctx.fill(); |
| ctx.strokeStyle = color; |
| ctx.lineWidth = 1.8; |
| ctx.stroke(); |
| }); |
| ctx.restore(); |
| } |
| }; |
| |
| const drawBars = () => { |
| const gap = Math.min(3, slotW * 0.2); |
| const barW = Math.max(2, slotW - gap); |
| buckets.forEach((b, i) => { |
| const v = b.requests; |
| const x = xAt(i) - barW / 2; |
| |
| |
| if (v === 0) { |
| ctx.fillStyle = 'rgba(255,255,255,.08)'; |
| ctx.fillRect(x, base - 1, barW, 1); |
| return; |
| } |
| const h = (v / maxY) * cH * p; |
| const errRatio = v > 0 ? b.errors / v : 0; |
| const color = this._errorTintedColor(errRatio); |
| const y = base - h; |
| const grad = ctx.createLinearGradient(0, y, 0, y + h); |
| grad.addColorStop(0, color); |
| grad.addColorStop(1, color + 'cc'); |
| ctx.fillStyle = grad; |
| const rr = Math.min(barW / 2, 3); |
| this._roundRect(ctx, x, y, barW, h, rr); |
| ctx.fill(); |
| if (barW > 4 && h > 2) { |
| ctx.fillStyle = 'rgba(255,255,255,.1)'; |
| this._roundRect(ctx, x, y, barW, Math.min(2, h), rr); |
| ctx.fill(); |
| } |
| }); |
| }; |
| |
| const drawStacked = () => { |
| const gap = Math.min(3, slotW * 0.2); |
| const barW = Math.max(2, slotW - gap); |
| buckets.forEach((b, i) => { |
| const cx = xAt(i); |
| |
| |
| |
| if ((m.success ? b.success : 0) + (m.errors ? b.errors : 0) === 0 |
| && (m.requests ? b.requests : 0) === 0) { |
| ctx.fillStyle = 'rgba(255,255,255,.08)'; |
| ctx.fillRect(cx - barW / 2, base - 1, barW, 1); |
| return; |
| } |
| let stackV = 0; |
| const segments = []; |
| if (m.success && b.success > 0) segments.push({ v: b.success, color: this._chartColors.success }); |
| if (m.errors && b.errors > 0) segments.push({ v: b.errors, color: this._chartColors.errors }); |
| if (!segments.length && m.requests && b.requests > 0) segments.push({ v: b.requests, color: this._chartColors.requests }); |
| segments.forEach((seg, si) => { |
| const segBottomY = base - (stackV / maxY) * cH * p; |
| const segTopY = base - ((stackV + seg.v) / maxY) * cH * p; |
| const h = segBottomY - segTopY; |
| if (h <= 0) { stackV += seg.v; return; } |
| const isTop = si === segments.length - 1; |
| const rr = Math.min(barW / 2, 3); |
| const grad = ctx.createLinearGradient(0, segTopY, 0, segBottomY); |
| grad.addColorStop(0, seg.color); |
| grad.addColorStop(1, seg.color + 'b3'); |
| ctx.fillStyle = grad; |
| if (isTop) { |
| this._roundRectTop(ctx, cx - barW / 2, segTopY, barW, h, rr); |
| } else { |
| ctx.fillRect(cx - barW / 2, segTopY, barW, h); |
| } |
| ctx.fill(); |
| stackV += seg.v; |
| }); |
| }); |
| }; |
| |
| |
| if (type === 'bar') { |
| drawBars(); |
| } else if (type === 'stacked') { |
| drawStacked(); |
| } else { |
| |
| const fill = type === 'area'; |
| |
| if (m.success) drawSmoothSeries(buckets.map(b => b.success), this._chartColors.success, { fill: fill, thin: false }); |
| if (m.errors) drawSmoothSeries(buckets.map(b => b.errors), this._chartColors.errors, { fill: false, thin: true, dash: false }); |
| if (m.requests && type === 'line') drawSmoothSeries(buckets.map(b => b.requests), this._chartColors.requests, { fill: false, thin: false }); |
| if (m.requests && type === 'area') drawSmoothSeries(buckets.map(b => b.requests), this._chartColors.requests, { fill: true, thin: false }); |
| } |
| |
| |
| |
| |
| if (p === 1 && Number.isInteger(this._chartCursorIdx) && this._chartCursorIdx >= 0 && this._chartCursorIdx < buckets.length) { |
| const cx = xAt(this._chartCursorIdx); |
| ctx.save(); |
| ctx.strokeStyle = 'rgba(224,180,130,.42)'; |
| ctx.lineWidth = 1; |
| ctx.setLineDash([3, 3]); |
| ctx.beginPath(); |
| ctx.moveTo(cx, pad.t); |
| ctx.lineTo(cx, pad.t + cH); |
| ctx.stroke(); |
| ctx.restore(); |
| |
| const hb = buckets[this._chartCursorIdx]; |
| const total = (m.success ? hb.success : 0) + (m.errors ? hb.errors : 0) || hb.requests; |
| if (total > 0) { |
| const yT = yAt(total); |
| ctx.save(); |
| ctx.fillStyle = 'rgba(255,255,255,0.95)'; |
| ctx.beginPath(); |
| ctx.arc(cx, yT, 4, 0, Math.PI * 2); |
| ctx.fill(); |
| ctx.strokeStyle = 'rgba(224,180,130,.85)'; |
| ctx.lineWidth = 1.5; |
| ctx.stroke(); |
| ctx.restore(); |
| } else { |
| |
| ctx.save(); |
| ctx.fillStyle = 'rgba(224,180,130,.6)'; |
| ctx.beginPath(); |
| ctx.arc(cx, base, 3, 0, Math.PI * 2); |
| ctx.fill(); |
| ctx.restore(); |
| } |
| } |
| |
| |
| ctx.fillStyle = 'rgba(255,255,255,.38)'; |
| ctx.font = '500 10px "JetBrains Mono", "Consolas", monospace'; |
| ctx.textAlign = 'center'; |
| ctx.textBaseline = 'top'; |
| const spanHours = buckets.length > 1 ? (buckets[buckets.length - 1].t - buckets[0].t) / 3600_000 : 0; |
| const useDate = spanHours >= 48; |
| const labelCount = Math.min(8, buckets.length); |
| const labelStep = Math.max(1, Math.floor(buckets.length / labelCount)); |
| let lastLabel = null; |
| let lastX = -Infinity; |
| const minGapPx = useDate ? 48 : 36; |
| buckets.forEach((b, i) => { |
| if (i % labelStep !== 0 && i !== buckets.length - 1) return; |
| const dt = new Date(b.hour); |
| const txt = useDate |
| ? `${dt.getMonth() + 1}/${dt.getDate()}` |
| : dt.getHours().toString().padStart(2, '0') + ':00'; |
| const x = xAt(i); |
| if (txt === lastLabel) return; |
| if (x - lastX < minGapPx && i !== buckets.length - 1) return; |
| lastLabel = txt; |
| lastX = x; |
| ctx.fillText(txt, x, pad.t + cH + 10); |
| }); |
| }, |
| |
| _bindChartHover() { |
| const canvas = document.getElementById('stats-canvas'); |
| const tipEl = document.getElementById('stats-tooltip'); |
| if (!canvas || !tipEl || canvas._hoverBound) return; |
| canvas._hoverBound = true; |
| canvas.addEventListener('mousemove', (ev) => { |
| const hm = this._chartHitMap; |
| if (!hm) return; |
| const rect = canvas.getBoundingClientRect(); |
| const x = ev.clientX - rect.left; |
| const y = ev.clientY - rect.top; |
| if (x < hm.pad.l || x > hm.W - 8 || y < hm.pad.t || y > hm.pad.t + hm.cH) { |
| tipEl.classList.remove('show'); |
| if (this._chartCursorIdx !== -1) { |
| this._chartCursorIdx = -1; |
| this._paintChart(1); |
| } |
| return; |
| } |
| let best = 0, bestD = Infinity; |
| hm.buckets.forEach((_, i) => { |
| const d = Math.abs(hm.xAt(i) - x); |
| if (d < bestD) { bestD = d; best = i; } |
| }); |
| |
| |
| |
| if (this._chartCursorIdx !== best) { |
| this._chartCursorIdx = best; |
| this._paintChart(1); |
| } |
| const b = hm.buckets[best]; |
| const rate = b.requests > 0 ? ((b.success / b.requests) * 100).toFixed(1) : '—'; |
| const time = new Date(b.hour).toLocaleString(); |
| const c = this._chartColors; |
| tipEl.innerHTML = ` |
| <div class="tt-time">${this.esc(time)}</div> |
| <div class="tt-row"><span class="tt-dot" style="background:${c.requests}"></span><span class="tt-label">${I18n.t('section.requestChart.tooltipRequests')}</span><span class="tt-value">${b.requests}</span></div> |
| <div class="tt-row"><span class="tt-dot" style="background:${c.success}"></span><span class="tt-label">${I18n.t('section.requestChart.tooltipSuccess')}</span><span class="tt-value">${b.success}</span></div> |
| <div class="tt-row"><span class="tt-dot" style="background:${c.errors}"></span><span class="tt-label">${I18n.t('section.requestChart.tooltipErrors')}</span><span class="tt-value">${b.errors}</span></div> |
| <div class="tt-row"><span class="tt-label">${I18n.t('section.requestChart.tooltipRate')}</span><span class="tt-value">${rate}%</span></div> |
| `; |
| tipEl.classList.add('show'); |
| const tipRect = tipEl.getBoundingClientRect(); |
| let left = hm.xAt(best); |
| const halfW = tipRect.width / 2; |
| left = Math.min(hm.W - halfW - 6, Math.max(halfW + 6, left)); |
| const top = hm.pad.t + 4; |
| tipEl.style.left = left + 'px'; |
| tipEl.style.top = top + 'px'; |
| }); |
| canvas.addEventListener('mouseleave', () => { |
| tipEl.classList.remove('show'); |
| if (this._chartCursorIdx !== -1) { |
| this._chartCursorIdx = -1; |
| this._paintChart(1); |
| } |
| }); |
| }, |
| |
| _bindPieHover() { |
| const canvas = document.getElementById('model-pie-canvas'); |
| const tipEl = document.getElementById('model-pie-tooltip'); |
| if (!canvas || !tipEl || canvas._pieHoverBound) return; |
| canvas._pieHoverBound = true; |
| canvas.addEventListener('mousemove', (ev) => { |
| const hm = this._pieHitMap; |
| if (!hm) { tipEl.classList.remove('show'); return; } |
| const rect = canvas.getBoundingClientRect(); |
| const x = ev.clientX - rect.left; |
| const y = ev.clientY - rect.top; |
| const dx = x - hm.cx, dy = y - hm.cy; |
| const r = Math.hypot(dx, dy); |
| if (r < hm.rInner || r > hm.rOuter) { tipEl.classList.remove('show'); return; } |
| |
| |
| |
| let a = Math.atan2(dy, dx) + Math.PI / 2; |
| if (a < 0) a += Math.PI * 2; |
| const startAngle0 = -Math.PI / 2; |
| const seg = hm.segments.find(s => { |
| const sNorm = s.startAngle - startAngle0; |
| const eNorm = s.endAngle - startAngle0; |
| return a >= sNorm && a < eNorm; |
| }); |
| if (!seg) { tipEl.classList.remove('show'); return; } |
| const pct = (seg.count / hm.total * 100).toFixed(1); |
| const successRate = seg.count > 0 ? (seg.success / seg.count * 100).toFixed(1) : '—'; |
| tipEl.innerHTML = ` |
| <div class="tt-time">${this.esc(seg.name)}</div> |
| <div class="tt-row"><span class="tt-dot" style="background:${seg.color}"></span><span class="tt-label">${I18n.t('section.requestChart.tooltipRequests')}</span><span class="tt-value">${seg.count} (${pct}%)</span></div> |
| <div class="tt-row"><span class="tt-label">${I18n.t('section.requestChart.tooltipSuccess')}</span><span class="tt-value">${seg.success}</span></div> |
| <div class="tt-row"><span class="tt-label">${I18n.t('section.requestChart.tooltipErrors')}</span><span class="tt-value">${seg.errors}</span></div> |
| <div class="tt-row"><span class="tt-label">${I18n.t('section.requestChart.tooltipRate')}</span><span class="tt-value">${successRate}%</span></div> |
| `; |
| tipEl.classList.add('show'); |
| |
| |
| tipEl.style.left = x + 'px'; |
| tipEl.style.top = Math.max(0, y - 12) + 'px'; |
| }); |
| canvas.addEventListener('mouseleave', () => { tipEl.classList.remove('show'); }); |
| }, |
| |
| _roundRect(ctx, x, y, w, h, r) { |
| if (h <= 0 || w <= 0) return; |
| const rr = Math.min(r, w / 2, h / 2); |
| ctx.beginPath(); |
| ctx.moveTo(x + rr, y); |
| ctx.lineTo(x + w - rr, y); |
| ctx.quadraticCurveTo(x + w, y, x + w, y + rr); |
| ctx.lineTo(x + w, y + h); |
| ctx.lineTo(x, y + h); |
| ctx.lineTo(x, y + rr); |
| ctx.quadraticCurveTo(x, y, x + rr, y); |
| ctx.closePath(); |
| }, |
| |
| _roundRectTop(ctx, x, y, w, h, r) { |
| if (h <= 0 || w <= 0) return; |
| const rr = Math.min(r, w / 2, h); |
| ctx.beginPath(); |
| ctx.moveTo(x + rr, y); |
| ctx.lineTo(x + w - rr, y); |
| ctx.quadraticCurveTo(x + w, y, x + w, y + rr); |
| ctx.lineTo(x + w, y + h); |
| ctx.lineTo(x, y + h); |
| ctx.lineTo(x, y + rr); |
| ctx.quadraticCurveTo(x, y, x + rr, y); |
| ctx.closePath(); |
| }, |
| |
| |
| |
| _errorTintedColor(ratio) { |
| const clamp01 = (x) => Math.max(0, Math.min(1, x)); |
| const r = clamp01(ratio); |
| if (r === 0) return this._chartColors.requests; |
| const mix = (c1, c2, t) => { |
| const p1 = this._hexToRgb(c1), p2 = this._hexToRgb(c2); |
| const rr = Math.round(p1.r + (p2.r - p1.r) * t); |
| const gg = Math.round(p1.g + (p2.g - p1.g) * t); |
| const bb = Math.round(p1.b + (p2.b - p1.b) * t); |
| const to2 = (n) => n.toString(16).padStart(2, '0'); |
| return '#' + to2(rr) + to2(gg) + to2(bb); |
| }; |
| if (r <= 0.3) return mix('#818cf8', '#fbbf24', r / 0.3); |
| return mix('#fbbf24', '#fb7185', (r - 0.3) / 0.7); |
| }, |
| |
| _hexToRgb(h) { |
| const s = h.replace('#', ''); |
| return { |
| r: parseInt(s.slice(0, 2), 16), |
| g: parseInt(s.slice(2, 4), 16), |
| b: parseInt(s.slice(4, 6), 16), |
| }; |
| }, |
| |
| _fmtNum(n) { |
| if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'; |
| if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'k'; |
| return String(n); |
| }, |
| |
| _rangeLabel(originalPts, shownPts) { |
| if (!originalPts) return ''; |
| const fmt = (d) => `${d.getMonth()+1}/${d.getDate()}`; |
| if (this.statsRange === 'custom' && this.statsCustomRange) { |
| const { start, end } = this.statsCustomRange; |
| return `${fmt(start)} – ${fmt(end)} · ${originalPts} pts`; |
| } |
| if (this.statsRange === 'all' || this.statsRange === -1) { |
| return `All · ${originalPts} pts`; |
| } |
| const hours = Number(this.statsRange); |
| if (hours <= 72) return `${hours}h · ${originalPts} pts`; |
| const days = Math.round(hours / 24); |
| const note = shownPts < originalPts ? ` (sampled to ${shownPts})` : ''; |
| return `${days}d · ${originalPts} pts${note}`; |
| }, |
| |
| renderModelPie(d) { |
| const canvas = document.getElementById('model-pie-canvas'); |
| const legend = document.getElementById('model-pie-legend'); |
| if (!canvas) return; |
| |
| const entries = Object.entries(d.modelCounts || {}) |
| .map(([name, s]) => ({ name, count: s.requests || 0, success: s.success || 0, errors: s.errors || 0 })) |
| .filter(e => e.count > 0) |
| .sort((a, b) => b.count - a.count); |
| |
| const top = entries.slice(0, 8); |
| const others = entries.slice(8).reduce((acc, e) => ({ |
| count: acc.count + e.count, success: acc.success + e.success, errors: acc.errors + e.errors, |
| }), { count: 0, success: 0, errors: 0 }); |
| if (others.count > 0) top.push({ name: 'others', count: others.count, success: others.success, errors: others.errors }); |
| |
| const total = top.reduce((a, b) => a + b.count, 0); |
| |
| const { ctx, W, H } = this._setupCanvas(canvas); |
| ctx.clearRect(0, 0, W, H); |
| |
| if (total === 0) { |
| ctx.fillStyle = 'rgba(255,255,255,.3)'; |
| ctx.font = '13px sans-serif'; |
| ctx.textAlign = 'center'; |
| ctx.textBaseline = 'middle'; |
| ctx.fillText(I18n.t('table.empty.noData'), W / 2, H / 2); |
| if (legend) legend.innerHTML = ''; |
| this._pieHitMap = null; |
| return; |
| } |
| |
| const cx = W / 2, cy = H / 2; |
| const rOuter = Math.min(W, H) / 2 - 6; |
| const rInner = rOuter * 0.58; |
| |
| const segments = []; |
| let angle = -Math.PI / 2; |
| top.forEach((seg, i) => { |
| const frac = seg.count / total; |
| const a2 = angle + frac * Math.PI * 2; |
| ctx.beginPath(); |
| ctx.moveTo(cx + Math.cos(angle) * rInner, cy + Math.sin(angle) * rInner); |
| ctx.arc(cx, cy, rOuter, angle, a2); |
| ctx.arc(cx, cy, rInner, a2, angle, true); |
| ctx.closePath(); |
| ctx.fillStyle = this._pieColors[i % this._pieColors.length]; |
| ctx.fill(); |
| segments.push({ ...seg, color: this._pieColors[i % this._pieColors.length], startAngle: angle, endAngle: a2 }); |
| angle = a2; |
| }); |
| |
| this._pieHitMap = { cx, cy, rOuter, rInner, total, segments, W, H }; |
| this._bindPieHover(); |
| |
| |
| ctx.fillStyle = 'var(--text)'; |
| ctx.fillStyle = '#f4f4f5'; |
| ctx.textAlign = 'center'; |
| ctx.textBaseline = 'middle'; |
| ctx.font = '600 22px var(--mono), monospace'; |
| ctx.fillText(total, cx, cy - 6); |
| ctx.fillStyle = 'rgba(255,255,255,.4)'; |
| ctx.font = '10px sans-serif'; |
| ctx.fillText(I18n.t('section.requestChart.legendRequests'), cx, cy + 12); |
| |
| if (legend) { |
| legend.innerHTML = top.map((seg, i) => { |
| const pct = (seg.count / total * 100).toFixed(1); |
| return `<div class="pie-legend-item" title="${this.esc(seg.name)}"> |
| <span class="pie-legend-dot" style="background:${this._pieColors[i % this._pieColors.length]}"></span> |
| <span class="pie-legend-name">${this.esc(seg.name)}</span> |
| <span class="pie-legend-count">${seg.count}</span> |
| <span class="pie-legend-pct">${pct}%</span> |
| </div>`; |
| }).join(''); |
| } |
| }, |
| |
| async resetStats() { |
| const ok = await this.confirm(I18n.t('modal.resetStats.title'), I18n.t('modal.resetStats.desc'), { danger: true, okText: I18n.t('action.reset') }); |
| if (!ok) return; |
| await this.api('DELETE', '/stats'); |
| this.toast(I18n.t('toast.statsReset')); |
| this.loadStats(); |
| }, |
| |
| |
| PROVIDER_LABELS: { |
| anthropic: 'Anthropic · Claude', |
| openai: 'OpenAI · GPT / o Series', |
| google: 'Google · Gemini', |
| deepseek: 'DeepSeek', |
| xai: 'xAI · Grok', |
| alibaba: 'Alibaba · Qwen', |
| moonshot: 'Moonshot · Kimi', |
| zhipu: 'Zhipu · GLM', |
| minimax: 'MiniMax', |
| windsurf: 'Windsurf · SWE', |
| }, |
| |
| async loadExperimental() { |
| |
| |
| |
| this.loadCredentials(); |
| const d = await this.api('GET', '/experimental'); |
| const cb = document.getElementById('exp-cascade-reuse'); |
| if (cb) cb.checked = !!d.flags?.cascadeConversationReuse; |
| |
| const cbDrought = document.getElementById('exp-drought-restrict'); |
| if (cbDrought) cbDrought.checked = !!d.flags?.droughtRestrictPremium; |
| |
| const cbQw = document.getElementById('exp-quiet-window'); |
| if (cbQw) cbQw.checked = !!d.flags?.autoUpdateQuietWindow; |
| this.loadQuietWindowStatus(); |
| |
| const skinSel = document.getElementById('skin-select'); |
| if (skinSel) { |
| const m = document.cookie.match(/(?:^|;\s*)dashboard_skin=([^;]+)/); |
| const cur = m ? decodeURIComponent(m[1]) : 'modern'; |
| skinSel.value = (cur === 'sketch') ? 'sketch' : 'modern'; |
| } |
| const pool = d.conversationPool || {}; |
| document.getElementById('exp-pool-cards').innerHTML = ` |
| <div class="card info"> |
| <div class="card-header"><div class="card-title">${I18n.t('card.hitRate.title')}</div></div> |
| <div class="card-value">${pool.hitRate || '0.0'}%</div> |
| <div class="card-sub">${I18n.t('card.hitRate.subtitle', { hits: pool.hits || 0, misses: pool.misses || 0 })}</div> |
| </div> |
| <div class="card"> |
| <div class="card-header"><div class="card-title">${I18n.t('card.poolSize.title')}</div></div> |
| <div class="card-value">${pool.size || 0}</div> |
| <div class="card-sub">${I18n.t('card.poolSize.subtitle', { max: pool.maxSize || 0, ttl: Math.round((pool.ttlMs || 0) / 60000) })}</div> |
| </div> |
| <div class="card"> |
| <div class="card-header"><div class="card-title">${I18n.t('card.stores.title')}</div></div> |
| <div class="card-value">${pool.stores || 0}</div> |
| <div class="card-sub">${I18n.t('card.stores.subtitle', { expired: pool.expired || 0, evicted: pool.evictions || 0 })}</div> |
| </div> |
| `; |
| |
| |
| const sp = await this.api('GET', '/system-prompts'); |
| const spHolder = document.getElementById('system-prompts-editor'); |
| if (spHolder) { |
| const labels = { |
| toolReinforcement: I18n.t('experimental.toolReinforcement'), |
| communicationWithTools: I18n.t('experimental.communicationWithTools'), |
| communicationNoTools: I18n.t('experimental.communicationNoTools') |
| }; |
| spHolder.innerHTML = Object.entries(sp.prompts || {}).map(([key, text]) => { |
| const safeKey = this.esc(key); |
| const keyArg = this.escJsAttr(key); |
| const promptId = this.systemPromptDomId(key); |
| return ` |
| <div style="border:1px solid var(--border);border-radius:var(--radius);padding:12px;background:var(--surface)"> |
| <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px"> |
| <div><code style="color:var(--accent);font-weight:600">${safeKey}</code> <span class="text-sm text-muted">${this.esc(labels[key] || key)}</span></div> |
| <div class="btn-group"> |
| <button class="btn btn-ghost btn-xs" onclick="App.resetSystemPrompt('${keyArg}')">${I18n.t('experimental.default')}</button> |
| <button class="btn btn-primary btn-xs" onclick="App.saveSystemPrompt('${keyArg}')">${I18n.t('experimental.save')}</button> |
| </div> |
| </div> |
| <textarea id="${promptId}" class="input" rows="3" style="width:100%;font-family:var(--mono);font-size:12px;resize:vertical;line-height:1.5">${this.esc(text)}</textarea> |
| </div> |
| `; |
| }).join(''); |
| } |
| }, |
| |
| async saveSystemPrompt(key) { |
| const ta = document.getElementById(this.systemPromptDomId(key)); |
| if (!ta) return; |
| await this.api('PUT', '/system-prompts', { [key]: ta.value.trim() }); |
| this.toast(`${key} ${I18n.t('toast.saved')}`, 'success'); |
| }, |
| |
| async resetSystemPrompt(key) { |
| const ok = await this.confirm(I18n.t('action.reset'), `${key} ${I18n.t('confirm.restoreDefaultDesc') || 'restore to default?'}`, { okText: I18n.t('action.reset') }); |
| if (!ok) return; |
| await this.api('DELETE', `/system-prompts/${encodeURIComponent(key)}`); |
| this.toast(`${key} ${I18n.t('experimental.restored') || 'reset'}`, 'success'); |
| this.loadExperimental(); |
| }, |
| |
| async testLoginProxy() { |
| const host = document.getElementById('wl-proxy-host').value.trim(); |
| const port = parseInt(document.getElementById('wl-proxy-port').value) || 0; |
| if (!host || !port) return this.toast(I18n.t('toast.enterHostPort'), 'error'); |
| const type = document.getElementById('wl-proxy-type').value; |
| const username = document.getElementById('wl-proxy-user').value.trim(); |
| const password = document.getElementById('wl-proxy-pass').value; |
| const btn = document.getElementById('wl-proxy-test-btn'); |
| const out = document.getElementById('wl-proxy-test-result'); |
| btn.disabled = true; |
| out.textContent = I18n.t('status.testing'); |
| out.style.color = 'var(--text-muted)'; |
| try { |
| const r = await this.api('POST', '/test-proxy', { host, port, username, password, type }); |
| if (r.ok) { |
| out.textContent = I18n.t('status.proxySuccess', { ip: r.egressIp, latency: r.latencyMs }); |
| out.style.color = 'var(--success)'; |
| } else { |
| out.textContent = I18n.t('status.proxyFailed', { error: r.error, latency: r.latencyMs }); |
| out.style.color = 'var(--error)'; |
| } |
| } catch (e) { |
| out.textContent = `✗ ${e.message}`; |
| out.style.color = 'var(--error)'; |
| } finally { |
| btn.disabled = false; |
| } |
| }, |
| |
| async toggleExperimental(key, enabled) { |
| try { |
| await this.api('PUT', '/experimental', { [key]: enabled }); |
| this.toast(enabled ? I18n.t('toast.experimentalEnabled') : I18n.t('toast.experimentalDisabled'), 'success'); |
| this.loadExperimental(); |
| } catch (e) { |
| this.toast(I18n.t('toast.toggleFailed', { error: e.message }), 'error'); |
| this.loadExperimental(); |
| } |
| }, |
| |
| |
| async loadQuietWindowStatus() { |
| const el = document.getElementById('quiet-window-status'); |
| if (!el) return; |
| try { |
| const r = await this.api('GET', '/auto-update/quiet-window'); |
| const reason = r.decision?.reason || 'unknown'; |
| const labelKey = ({ |
| disabled: 'quietWindow.status.disabled', |
| 'cold-start': 'quietWindow.status.coldStart', |
| cooldown: 'quietWindow.status.cooldown', |
| busy: 'quietWindow.status.busy', |
| eligible: 'quietWindow.status.eligible', |
| })[reason]; |
| const label = labelKey ? I18n.t(labelKey) : reason; |
| const ring = r.ringSize || 0; |
| const last = r.lastUpdateAt |
| ? new Date(r.lastUpdateAt).toLocaleString() |
| : I18n.t('quietWindow.never'); |
| el.textContent = `${label} - ring=${ring} - ${I18n.t('quietWindow.lastUpdate')} ${last}`; |
| } catch (e) { |
| el.textContent = `${I18n.t('quietWindow.loadFailed')}: ${e.message}`; |
| } |
| }, |
| |
| async runQuietWindowNow() { |
| try { |
| const r = await this.api('POST', '/auto-update/quiet-window/run', {}); |
| const reason = r.result?.reason || 'unknown'; |
| const triggered = r.result?.result?.ok ? ` - ${I18n.t('quietWindow.triggered')}` : ''; |
| this.toast(`tick reason=${reason}${triggered}`, 'info'); |
| this.loadQuietWindowStatus(); |
| } catch (e) { |
| this.toast(`${I18n.t('quietWindow.triggerFailed')}: ${e.message}`, 'error'); |
| } |
| }, |
| |
| async clearConversationPool() { |
| const ok = await this.confirm(I18n.t('action.clearPool'), I18n.t('confirm.clearPoolDesc') || 'All active Cascade sessions will be cleared. Continue?', { okText: I18n.t('action.clearPool') }); |
| if (!ok) return; |
| const r = await this.api('DELETE', '/experimental/conversation-pool'); |
| this.toast(`${I18n.t('toast.cleared')} ${r.cleared || 0}`, 'success'); |
| this.loadExperimental(); |
| }, |
| |
| |
| async loadDrought() { |
| const banner = document.getElementById('drought-banner'); |
| const msg = document.getElementById('drought-message'); |
| const detail = document.getElementById('drought-detail'); |
| if (!banner || !msg || !detail) return; |
| try { |
| const d = await this.api('GET', '/drought'); |
| if (!d?.drought) { |
| banner.style.display = 'none'; |
| return; |
| } |
| banner.style.display = ''; |
| const lw = d.lowestWeeklyPercent; |
| const ld = d.lowestDailyPercent; |
| const summary = (lw == null ? '' : `min weekly=${lw.toFixed(0)}%`) |
| + (ld == null ? '' : ` · min daily=${ld.toFixed(0)}%`) |
| + ` · ${d.knownAccounts}/${d.activeAccounts} accounts known`; |
| msg.textContent = I18n.t('drought.body') || msg.textContent; |
| detail.textContent = summary; |
| } catch (e) { |
| |
| banner.style.display = 'none'; |
| } |
| }, |
| |
| |
| async loadCredentials() { |
| try { |
| const d = await this.api('GET', '/settings/credentials'); |
| document.getElementById('credentials-apikey-source').textContent = '[' + (d.apiKeySource || 'unset') + ']'; |
| document.getElementById('credentials-apikey-masked').textContent = d.apiKey_masked || '(unset)'; |
| document.getElementById('credentials-dashboardpw-source').textContent = '[' + (d.dashboardPasswordSource || 'unset') + ']'; |
| document.getElementById('credentials-dashboardpw-status').textContent = |
| d.dashboardPasswordSet ? (I18n.t('credentials.set') || 'set') : (I18n.t('credentials.unset') || 'unset'); |
| } catch (e) { |
| this.toast('Failed to load credentials: ' + (e.message || ''), 'error'); |
| } |
| }, |
| |
| toggleApiKeyVisibility() { |
| const el = document.getElementById('credentials-apikey-input'); |
| el.type = el.type === 'password' ? 'text' : 'password'; |
| }, |
| |
| toggleDashboardPwVisibility() { |
| const el = document.getElementById('credentials-dashboardpw-input'); |
| el.type = el.type === 'password' ? 'text' : 'password'; |
| }, |
| |
| async saveCredential(field) { |
| const inputId = field === 'apiKey' ? 'credentials-apikey-input' : 'credentials-dashboardpw-input'; |
| const el = document.getElementById(inputId); |
| const value = el.value; |
| const labelMap = { |
| apiKey: I18n.t('credentials.apiKey') || 'API_KEY', |
| dashboardPassword: I18n.t('credentials.dashboardPassword') || 'Dashboard password', |
| }; |
| const cleared = value === ''; |
| const okConfirm = await this.confirm( |
| labelMap[field], |
| cleared |
| ? (I18n.t('credentials.confirmClear') || 'Clear runtime override and fall back to .env value?') |
| : (I18n.t('credentials.confirmSave') || 'After saving, the old value stops working immediately. Continue?'), |
| { okText: I18n.t('credentials.save') || 'Save' } |
| ); |
| if (!okConfirm) return; |
| try { |
| const r = await this.api('PUT', '/settings/credentials', { [field]: value }); |
| el.value = ''; |
| this.toast(r.success ? (I18n.t('credentials.saved') || 'Saved') : 'Saved', 'success'); |
| |
| |
| |
| if (field === 'dashboardPassword' && !cleared) { |
| sessionStorage.removeItem('dashboard_password'); |
| setTimeout(() => location.reload(), 800); |
| return; |
| } |
| |
| |
| if (field === 'apiKey') { |
| |
| |
| |
| |
| this.loadCredentials(); |
| } else { |
| this.loadCredentials(); |
| } |
| } catch (e) { |
| this.toast('Save failed: ' + (e.message || ''), 'error'); |
| } |
| }, |
| |
| |
| switchSkin(skin) { |
| const next = skin === 'sketch' ? 'sketch' : 'modern'; |
| |
| const oneYear = 365 * 24 * 3600; |
| document.cookie = `dashboard_skin=${next}; path=/; max-age=${oneYear}; samesite=lax`; |
| location.reload(); |
| }, |
| |
| |
| async loadBans() { |
| const d = await this.api('GET', '/accounts'); |
| const accounts = d.accounts || []; |
| const errored = accounts.filter(a => a.status === 'error' || a.errorCount > 0); |
| document.getElementById('ban-cards').innerHTML = ` |
| <div class="card ${errored.length > 0 ? 'error' : 'success'}"> |
| <div class="card-header"><div class="card-title">${I18n.t('card.abnormalAccounts.title')}</div></div> |
| <div class="card-value">${errored.length}</div> |
| <div class="card-sub">${I18n.t('card.abnormalAccounts.subtitle', { count: accounts.length })}</div> |
| </div> |
| <div class="card warn"> |
| <div class="card-header"><div class="card-title">${I18n.t('card.disabled.title')}</div></div> |
| <div class="card-value">${accounts.filter(a => a.status === 'error').length}</div> |
| <div class="card-sub">${I18n.t('card.disabled.subtitle')}</div> |
| </div> |
| <div class="card info"> |
| <div class="card-header"><div class="card-title">${I18n.t('card.rateLimit.title')}</div></div> |
| <div class="card-value">${accounts.filter(a => a.rateLimited).length}</div> |
| <div class="card-sub">${I18n.t('card.rateLimit.subtitle')}</div> |
| </div> |
| `; |
| const tbody = document.querySelector('#ban-table tbody'); |
| tbody.innerHTML = accounts.map(a => { |
| const flagged = a.status === 'error' || a.errorCount > 0; |
| return `<tr style="${flagged ? 'background:rgba(239,68,68,.03)' : ''}"> |
| <td>${this.esc(a.email)} <code class="text-xs">${a.id}</code></td> |
| <td><span class="badge ${a.status}">${a.status}</span></td> |
| <td style="color:${a.errorCount > 0 ? 'var(--error)' : 'inherit'}">${a.errorCount}</td> |
| <td class="text-sm">${a.lastUsed ? new Date(a.lastUsed).toLocaleString() : '-'}</td> |
| <td class="nowrap"> |
| <div class="btn-group"> |
| ${a.status === 'error' ? `<button class="btn btn-success btn-xs" onclick="App.toggleAccount('${this.escJsAttr(a.id)}','active');setTimeout(()=>App.loadBans(),500)">${I18n.t('action.enable')}</button>` : ''} |
| ${a.errorCount > 0 ? `<button class="btn btn-outline btn-xs" onclick="App.resetErrors('${this.escJsAttr(a.id)}');setTimeout(()=>App.loadBans(),500)">${I18n.t('action.reset')}</button>` : ''} |
| </div> |
| </td> |
| </tr>`; |
| }).join('') || `<tr class="empty-row"><td colspan="5">${I18n.t('table.empty.banList')}</td></tr>`; |
| |
| this.poll('bans', () => this.loadBans(), 30000); |
| }, |
| |
| |
| fmtDuration(secs) { |
| const d = Math.floor(secs / 86400), h = Math.floor((secs % 86400) / 3600), m = Math.floor((secs % 3600) / 60); |
| const s = Math.floor(secs % 60); |
| if (d > 0) return `${d}${I18n.t('time.day')} ${h}${I18n.t('time.hour')} ${m}${I18n.t('time.minute')}`; |
| if (h > 0) return `${h}${I18n.t('time.hour')} ${m}${I18n.t('time.minute')}`; |
| return `${m}${I18n.t('time.minute')} ${s}${I18n.t('time.second')}`; |
| }, |
| async revealAndCopyKey(accountId) { |
| try { |
| const r = await this.api('POST', `/account/${encodeURIComponent(accountId)}/reveal-key`); |
| if (r?.apiKey) this.copyKey(r.apiKey); |
| else this.toast(I18n.t('toast.copyFailed'), 'error'); |
| } catch (e) { |
| this.toast(e?.message || I18n.t('toast.copyFailed'), 'error'); |
| } |
| }, |
| copyKey(key) { |
| const fallback = () => { |
| try { |
| const ta = document.createElement('textarea'); |
| ta.value = key; |
| ta.style.position = 'fixed'; |
| ta.style.left = '-9999px'; |
| document.body.appendChild(ta); |
| ta.focus(); |
| ta.select(); |
| const ok = document.execCommand('copy'); |
| ta.remove(); |
| this.toast(ok ? I18n.t('toast.apiKeyCopied') : I18n.t('toast.copyFailed'), ok ? 'success' : 'error'); |
| } catch (e) { |
| this.toast(I18n.t('toast.copyFailedError', {error: e.message}), 'error'); |
| } |
| }; |
| if (navigator.clipboard && window.isSecureContext) { |
| navigator.clipboard.writeText(key).then(() => this.toast(I18n.t('toast.apiKeyCopied'))).catch(fallback); |
| } else { |
| fallback(); |
| } |
| }, |
| |
| |
| CONTRIBUTORS_CACHE: null, |
| async fetchContributors() { |
| if (this.CONTRIBUTORS_CACHE) return this.CONTRIBUTORS_CACHE; |
| try { |
| const res = await fetch('/dashboard/data/contributors.json'); |
| if (!res.ok) throw new Error(`status ${res.status}`); |
| const data = await res.json(); |
| this.CONTRIBUTORS_CACHE = Array.isArray(data?.contributors) ? data.contributors : []; |
| } catch (e) { |
| console.error('fetchContributors failed:', e); |
| this.CONTRIBUTORS_CACHE = []; |
| } |
| return this.CONTRIBUTORS_CACHE; |
| }, |
| contributorRarity(weight) { |
| |
| |
| |
| |
| |
| |
| |
| |
| if (weight === 'GOD') return 'GOD'; |
| if (weight === 'MR') return 'MR'; |
| if (weight === 'LR' || weight === 'S++') return 'LR'; |
| if (weight === 'S+') return 'UR'; |
| if (weight === 'S' || weight === 'A+') return 'SSR'; |
| if (weight === 'A' || weight === 'B+') return 'SR'; |
| return 'R'; |
| }, |
| weightRank(w) { |
| return ({ 'GOD': 100, 'MR': 12, 'LR': 10, 'S++': 10, 'S+': 6, 'S': 5, 'A+': 4, 'A': 3, 'B+': 2, 'B': 1 })[w] || 0; |
| }, |
| groupContributors(list) { |
| const map = new Map(); |
| for (const c of list) { |
| if (!map.has(c.login)) map.set(c.login, { login: c.login, prs: [] }); |
| map.get(c.login).prs.push(c); |
| } |
| const groups = []; |
| for (const g of map.values()) { |
| g.prs.sort((a, b) => (b.mergedAt || '').localeCompare(a.mergedAt || '')); |
| g.latest = g.prs[0]; |
| g.topWeight = g.prs.reduce((best, p) => this.weightRank(p.weight) > this.weightRank(best) ? p.weight : best, ''); |
| groups.push(g); |
| } |
| groups.sort((a, b) => |
| this.weightRank(b.topWeight) - this.weightRank(a.topWeight) |
| || (b.latest.mergedAt || '').localeCompare(a.latest.mergedAt || '') |
| ); |
| return groups; |
| }, |
| async loadCredits() { |
| const body = document.getElementById('credits-body'); |
| if (!body) return; |
| const list = await this.fetchContributors(); |
| const groups = this.groupContributors(list); |
| body.innerHTML = `<div class="contributor-list">${groups.map(g => { |
| const latest = g.latest; |
| const count = g.prs.length; |
| const rarity = this.contributorRarity(g.topWeight); |
| const summaryText = count > 1 |
| ? I18n.t('credits.history', { count }) |
| : I18n.t('credits.contributionDetails'); |
| const items = g.prs.map(p => { |
| const r = this.contributorRarity(p.weight); |
| return ` |
| <li class="history-item"> |
| <div class="history-head"> |
| <a class="pr-link" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/pull/${p.pr}">PR #${p.pr}</a> |
| <span class="merged-at">${this.esc(p.mergedAt || '')}</span> |
| ${p.weight ? `<span class="rarity-badge rarity-${this.esc(r)}" title="${this.esc(p.weightLabel || '')}">${this.esc(r)}</span>` : ''} |
| ${p.weightLabel ? `<span class="history-rarity-label">${this.esc(p.weightLabel)}</span>` : ''} |
| </div> |
| <div class="history-title">${this.esc(p.title)}</div> |
| <div class="history-summary">${this.esc(p.summary)}</div> |
| </li>`; |
| }).join(''); |
| return ` |
| <div class="contributor-card"> |
| <img class="avatar" src="https://github.com/${this.esc(g.login)}.png?size=128" alt="${this.esc(g.login)}" loading="lazy" onerror="this.style.visibility='hidden'"> |
| <div class="meta"> |
| <div class="meta-top"> |
| <div class="identity"> |
| <a class="name" target="_blank" rel="noopener" href="https://github.com/${this.esc(g.login)}">@${this.esc(g.login)}</a> |
| ${g.topWeight ? `<span class="rarity-badge rarity-${this.esc(rarity)}" title="${this.esc(latest.weightLabel || '')}">${this.esc(rarity)}</span>` : ''} |
| <span class="contribution-count">×${count}</span> |
| </div> |
| <div class="links"> |
| <a class="pr-link" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/pull/${latest.pr}">PR #${latest.pr}</a> |
| <span class="merged-at">${this.esc(latest.mergedAt || '')}</span> |
| </div> |
| </div> |
| <div class="pr-title">${this.esc(latest.title)}</div> |
| <details class="summary-toggle"> |
| <summary>${this.esc(summaryText)}</summary> |
| <ol class="history-list">${items}</ol> |
| </details> |
| </div> |
| </div>`; |
| }).join('')}</div>`; |
| }, |
| esc(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }, |
| systemPromptDomId(key) { |
| return `sp-${encodeURIComponent(String(key == null ? '' : key)).replace(/%/g, '_')}`; |
| }, |
| |
| |
| |
| |
| |
| |
| escJsAttr(s) { |
| |
| |
| |
| const map = { |
| "\\": "\\\\", "'": "\\'", "\"": "\\\"", |
| "<": "\\x3c", ">": "\\x3e", "&": "\\x26", |
| "\r": "\\r", "\n": "\\n", |
| "\u2028": "\\u2028", "\u2029": "\\u2029", |
| }; |
| return String(s == null ? "" : s).replace(/[\\'"<>&\r\n\u2028\u2029]/g, c => map[c]); |
| }, |
| }; |
| |
| window.addEventListener('DOMContentLoaded', () => App.init()); |
| </script> |
| </body> |
| </html> |
|
|