Merge pull request #93 from Wha1eChai/feature/webui
fix: address issue #92 and refactor frontend architecture for production readiness
This commit is contained in:
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Mark generated CSS file to reduce diff noise
|
||||
public/css/style.css linguist-generated=true -diff
|
||||
139
CLAUDE.md
139
CLAUDE.md
@@ -11,7 +11,7 @@ The proxy translates requests from Anthropic Messages API format → Google Gene
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
# Install dependencies (automatically builds CSS via prepare hook)
|
||||
npm install
|
||||
|
||||
# Start server (runs on port 8080)
|
||||
@@ -23,8 +23,13 @@ npm start -- --fallback
|
||||
# Start with debug logging
|
||||
npm start -- --debug
|
||||
|
||||
# Start with file watching for development
|
||||
npm run dev
|
||||
# Development mode (file watching)
|
||||
npm run dev # Watch server files only
|
||||
npm run dev:full # Watch both CSS and server files (recommended for frontend dev)
|
||||
|
||||
# CSS build commands
|
||||
npm run build:css # Build CSS once (minified)
|
||||
npm run watch:css # Watch CSS files for changes
|
||||
|
||||
# Account management
|
||||
npm run accounts # Interactive account management
|
||||
@@ -113,18 +118,35 @@ src/
|
||||
```
|
||||
public/
|
||||
├── index.html # Main entry point
|
||||
├── css/
|
||||
│ ├── style.css # Compiled Tailwind CSS (generated, do not edit)
|
||||
│ └── src/
|
||||
│ └── input.css # Tailwind source with @apply directives
|
||||
├── js/
|
||||
│ ├── app.js # Main application logic (Alpine.js)
|
||||
│ ├── store.js # Global state management
|
||||
│ ├── data-store.js # Shared data store (accounts, models, quotas)
|
||||
│ ├── settings-store.js # Settings management store
|
||||
│ ├── components/ # UI Components
|
||||
│ │ ├── dashboard.js # Real-time stats & charts
|
||||
│ │ ├── dashboard.js # Main dashboard orchestrator
|
||||
│ │ ├── account-manager.js # Account list & OAuth handling
|
||||
│ │ ├── logs-viewer.js # Live log streaming
|
||||
│ │ └── claude-config.js # CLI settings editor
|
||||
│ │ ├── claude-config.js # CLI settings editor
|
||||
│ │ ├── model-manager.js # Model configuration UI
|
||||
│ │ ├── server-config.js # Server settings UI
|
||||
│ │ └── dashboard/ # Dashboard sub-modules
|
||||
│ │ ├── stats.js # Account statistics calculation
|
||||
│ │ ├── charts.js # Chart.js visualizations
|
||||
│ │ └── filters.js # Chart filter state management
|
||||
│ └── utils/ # Frontend utilities
|
||||
│ ├── error-handler.js # Centralized error handling with ErrorHandler.withLoading
|
||||
│ ├── account-actions.js # Account operations service layer (NEW)
|
||||
│ ├── validators.js # Input validation
|
||||
│ └── model-config.js # Model configuration helpers
|
||||
└── views/ # HTML partials (loaded dynamically)
|
||||
├── dashboard.html
|
||||
├── accounts.html
|
||||
├── models.html
|
||||
├── settings.html
|
||||
└── logs.html
|
||||
```
|
||||
@@ -191,15 +213,29 @@ Each account object in `accounts.json` contains:
|
||||
|
||||
**Web Management UI:**
|
||||
|
||||
- **Stack**: Vanilla JS + Alpine.js + Tailwind CSS (via CDN)
|
||||
- **Stack**: Vanilla JS + Alpine.js + Tailwind CSS (local build with PostCSS)
|
||||
- **Build System**:
|
||||
- Tailwind CLI with JIT compilation
|
||||
- PostCSS + Autoprefixer
|
||||
- DaisyUI component library
|
||||
- Custom `@apply` directives in `public/css/src/input.css`
|
||||
- Compiled output: `public/css/style.css` (auto-generated on `npm install`)
|
||||
- **Architecture**: Single Page Application (SPA) with dynamic view loading
|
||||
- **State Management**: Alpine.store for global state (accounts, settings, logs)
|
||||
- **State Management**:
|
||||
- Alpine.store for global state (accounts, settings, logs)
|
||||
- Layered architecture: Service Layer (`account-actions.js`) → Component Layer → UI
|
||||
- **Features**:
|
||||
- Real-time dashboard with Chart.js visualization and subscription tier distribution
|
||||
- Account list with tier badges (Ultra/Pro/Free) and quota progress bars
|
||||
- OAuth flow handling via popup window
|
||||
- Live log streaming via Server-Sent Events (SSE)
|
||||
- Config editor for both Proxy and Claude CLI (`~/.claude/settings.json`)
|
||||
- Skeleton loading screens for improved perceived performance
|
||||
- Empty state UX with actionable prompts
|
||||
- Loading states for all async operations
|
||||
- **Accessibility**:
|
||||
- ARIA labels on search inputs and icon buttons
|
||||
- Keyboard navigation support (Escape to clear search)
|
||||
- **Security**: Optional password protection via `WEBUI_PASSWORD` env var
|
||||
- **Smart Refresh**: Client-side polling with ±20% jitter and tab visibility detection (3x slower when hidden)
|
||||
|
||||
@@ -263,6 +299,95 @@ Each account object in `accounts.json` contains:
|
||||
- Returns: `{ accounts: [{ email, subscription: { tier, projectId }, limits: {...} }], models: [...] }`
|
||||
- Query params: `?format=table` (ASCII table) or `?includeHistory=true` (adds usage stats)
|
||||
|
||||
## Frontend Development
|
||||
|
||||
### CSS Build System
|
||||
|
||||
**Workflow:**
|
||||
1. Edit styles in `public/css/src/input.css` (Tailwind source with `@apply` directives)
|
||||
2. Run `npm run build:css` to compile (or `npm run watch:css` for auto-rebuild)
|
||||
3. Compiled CSS output: `public/css/style.css` (minified, committed to git)
|
||||
|
||||
**Component Styles:**
|
||||
- Use `@apply` to abstract common Tailwind patterns into reusable classes
|
||||
- Example: `.btn-action-ghost`, `.status-pill-success`, `.input-search`
|
||||
- Skeleton loading: `.skeleton`, `.skeleton-stat-card`, `.skeleton-chart`
|
||||
|
||||
**When to rebuild:**
|
||||
- After modifying `public/css/src/input.css`
|
||||
- After pulling changes that updated CSS source
|
||||
- Automatically on `npm install` (via `prepare` hook)
|
||||
|
||||
### Error Handling Pattern
|
||||
|
||||
Use `window.ErrorHandler.withLoading()` for async operations:
|
||||
|
||||
```javascript
|
||||
async myOperation() {
|
||||
return await window.ErrorHandler.withLoading(async () => {
|
||||
// Your async code here
|
||||
const result = await someApiCall();
|
||||
if (!result.ok) {
|
||||
throw new Error('Operation failed');
|
||||
}
|
||||
return result;
|
||||
}, this, 'loading', { errorMessage: 'Failed to complete operation' });
|
||||
}
|
||||
```
|
||||
|
||||
- Automatically manages `this.loading` state
|
||||
- Shows error toast on failure
|
||||
- Always resets loading state in `finally` block
|
||||
|
||||
### Account Operations Service Layer
|
||||
|
||||
Use `window.AccountActions` for account operations instead of direct API calls:
|
||||
|
||||
```javascript
|
||||
// ✅ Good: Use service layer
|
||||
const result = await window.AccountActions.refreshAccount(email);
|
||||
if (result.success) {
|
||||
this.$store.global.showToast('Account refreshed', 'success');
|
||||
} else {
|
||||
this.$store.global.showToast(result.error, 'error');
|
||||
}
|
||||
|
||||
// ❌ Bad: Direct API call in component
|
||||
const response = await fetch(`/api/accounts/${email}/refresh`);
|
||||
```
|
||||
|
||||
**Available methods:**
|
||||
- `refreshAccount(email)` - Refresh token and quota
|
||||
- `toggleAccount(email, enabled)` - Enable/disable account (with optimistic update)
|
||||
- `deleteAccount(email)` - Delete account
|
||||
- `getFixAccountUrl(email)` - Get OAuth re-auth URL
|
||||
- `reloadAccounts()` - Reload from disk
|
||||
- `canDelete(account)` - Check if account is deletable
|
||||
|
||||
All methods return `{success: boolean, data?: object, error?: string}`
|
||||
|
||||
### Dashboard Modules
|
||||
|
||||
Dashboard is split into three modules for maintainability:
|
||||
|
||||
1. **stats.js** - Account statistics calculation
|
||||
- `updateStats(component)` - Computes active/limited/total counts
|
||||
- Updates subscription tier distribution
|
||||
|
||||
2. **charts.js** - Chart.js visualizations
|
||||
- `initQuotaChart(component)` - Initialize quota distribution pie chart
|
||||
- `initTrendChart(component)` - Initialize usage trend line chart
|
||||
- `updateQuotaChart(component)` - Update quota chart data
|
||||
- `updateTrendChart(component)` - Update trend chart (with concurrency lock)
|
||||
|
||||
3. **filters.js** - Filter state management
|
||||
- `getInitialState()` - Default filter values
|
||||
- `loadPreferences(component)` - Load from localStorage
|
||||
- `savePreferences(component)` - Save to localStorage
|
||||
- Filter types: time range, display mode, family/model selection
|
||||
|
||||
Each module is well-documented with JSDoc comments.
|
||||
|
||||
## Maintenance
|
||||
|
||||
When making significant changes to the codebase (new modules, refactoring, architectural changes), update this CLAUDE.md and the README.md file to keep documentation in sync.
|
||||
|
||||
57
README.md
57
README.md
@@ -458,6 +458,63 @@ By using this software, you acknowledge and accept the following:
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### For Developers & Contributors
|
||||
|
||||
This project uses a local Tailwind CSS build system. CSS is pre-compiled and included in the repository, so you can run the project immediately after cloning.
|
||||
|
||||
#### Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/badri-s2001/antigravity-claude-proxy.git
|
||||
cd antigravity-claude-proxy
|
||||
npm install # Automatically builds CSS via prepare hook
|
||||
npm start # Start server (no rebuild needed)
|
||||
```
|
||||
|
||||
#### Frontend Development
|
||||
|
||||
If you need to modify styles in `public/css/src/input.css`:
|
||||
|
||||
```bash
|
||||
# Option 1: Build once
|
||||
npm run build:css
|
||||
|
||||
# Option 2: Watch for changes (auto-rebuild)
|
||||
npm run watch:css
|
||||
|
||||
# Option 3: Watch both CSS and server (recommended)
|
||||
npm run dev:full
|
||||
```
|
||||
|
||||
**File Structure:**
|
||||
- `public/css/src/input.css` - Source CSS with Tailwind `@apply` directives (edit this)
|
||||
- `public/css/style.css` - Compiled & minified CSS (auto-generated, don't edit)
|
||||
- `tailwind.config.js` - Tailwind configuration
|
||||
- `postcss.config.js` - PostCSS configuration
|
||||
|
||||
#### Backend-Only Development
|
||||
|
||||
If you're only working on backend code and don't need frontend dev tools:
|
||||
|
||||
```bash
|
||||
npm install --production # Skip devDependencies (saves ~20MB)
|
||||
npm start
|
||||
```
|
||||
|
||||
**Note:** Pre-compiled CSS is committed to the repository, so you don't need to rebuild unless modifying styles.
|
||||
|
||||
#### Project Structure
|
||||
|
||||
See [CLAUDE.md](./CLAUDE.md) for detailed architecture documentation, including:
|
||||
- Request flow and module organization
|
||||
- Frontend architecture (Alpine.js + Tailwind)
|
||||
- Service layer patterns (`ErrorHandler.withLoading`, `AccountActions`)
|
||||
- Dashboard module documentation
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
This project is based on insights and code from:
|
||||
|
||||
1552
package-lock.json
generated
1552
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -13,8 +13,12 @@
|
||||
"public"
|
||||
],
|
||||
"scripts": {
|
||||
"build:css": "tailwindcss -i ./public/css/src/input.css -o ./public/css/style.css --minify",
|
||||
"watch:css": "tailwindcss -i ./public/css/src/input.css -o ./public/css/style.css --watch",
|
||||
"prepare": "npm run build:css",
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"dev:full": "concurrently \"npm run watch:css\" \"npm run dev\"",
|
||||
"accounts": "node src/cli/accounts.js",
|
||||
"accounts:add": "node src/cli/accounts.js add",
|
||||
"accounts:list": "node src/cli/accounts.js list",
|
||||
@@ -57,5 +61,13 @@
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"concurrently": "^8.2.2",
|
||||
"daisyui": "^4.12.14",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
491
public/css/src/input.css
Normal file
491
public/css/src/input.css
Normal file
@@ -0,0 +1,491 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* === Background Layers === */
|
||||
--color-space-950: #09090b;
|
||||
--color-space-900: #0f0f11;
|
||||
--color-space-850: #121214;
|
||||
--color-space-800: #18181b;
|
||||
--color-space-border: #27272a;
|
||||
|
||||
/* === Neon Accents (Full Saturation) === */
|
||||
--color-neon-purple: #a855f7;
|
||||
--color-neon-green: #22c55e;
|
||||
--color-neon-cyan: #06b6d4;
|
||||
--color-neon-yellow: #eab308;
|
||||
--color-neon-red: #ef4444;
|
||||
|
||||
/* === Soft Neon (Reduced Saturation for Fills) === */
|
||||
--color-neon-purple-soft: #9333ea;
|
||||
--color-neon-green-soft: #16a34a;
|
||||
--color-neon-cyan-soft: #0891b2;
|
||||
|
||||
/* === Text Hierarchy (WCAG AA Compliant) === */
|
||||
--color-text-primary: #ffffff; /* Emphasis: Titles, Key Numbers */
|
||||
--color-text-secondary: #d4d4d8; /* Content: Body Text (zinc-300) */
|
||||
--color-text-tertiary: #a1a1aa; /* Metadata: Timestamps, Labels (zinc-400) */
|
||||
--color-text-quaternary: #71717a; /* Subtle: Decorative (zinc-500) */
|
||||
|
||||
/* === Legacy Aliases (Backward Compatibility) === */
|
||||
--color-text-main: var(--color-text-secondary);
|
||||
--color-text-dim: var(--color-text-tertiary);
|
||||
--color-text-muted: var(--color-text-tertiary);
|
||||
--color-text-bright: var(--color-text-primary);
|
||||
|
||||
/* Gradient Accents */
|
||||
--color-green-400: #4ade80;
|
||||
--color-yellow-400: #facc15;
|
||||
--color-red-400: #f87171;
|
||||
|
||||
/* Chart Colors */
|
||||
--color-chart-1: #a855f7;
|
||||
--color-chart-2: #c084fc;
|
||||
--color-chart-3: #e879f9;
|
||||
--color-chart-4: #d946ef;
|
||||
--color-chart-5: #22c55e;
|
||||
--color-chart-6: #4ade80;
|
||||
--color-chart-7: #86efac;
|
||||
--color-chart-8: #10b981;
|
||||
--color-chart-9: #06b6d4;
|
||||
--color-chart-10: #f59e0b;
|
||||
--color-chart-11: #ef4444;
|
||||
--color-chart-12: #ec4899;
|
||||
--color-chart-13: #8b5cf6;
|
||||
--color-chart-14: #14b8a6;
|
||||
--color-chart-15: #f97316;
|
||||
--color-chart-16: #6366f1;
|
||||
}
|
||||
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
.custom-scrollbar {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(9, 9, 11, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #27272a 0%, #18181b 100%);
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, #3f3f46 0%, #27272a 100%);
|
||||
border-color: rgba(168, 85, 247, 0.3);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Note: .glass-panel has been deprecated. Use .view-card instead for consistency. */
|
||||
|
||||
.nav-item.active {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
theme("colors.neon.purple / 15%") 0%,
|
||||
transparent 100%
|
||||
);
|
||||
@apply border-l-4 border-neon-purple text-white;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
@apply border-l-4 border-transparent transition-all duration-200;
|
||||
}
|
||||
|
||||
.progress-gradient-success::-webkit-progress-value {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--color-neon-green),
|
||||
var(--color-green-400)
|
||||
);
|
||||
}
|
||||
|
||||
.progress-gradient-warning::-webkit-progress-value {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--color-neon-yellow),
|
||||
var(--color-yellow-400)
|
||||
);
|
||||
}
|
||||
|
||||
.progress-gradient-error::-webkit-progress-value {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--color-neon-red),
|
||||
var(--color-red-400)
|
||||
);
|
||||
}
|
||||
|
||||
/* Dashboard Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Tooltip Customization */
|
||||
.tooltip:before {
|
||||
@apply bg-space-800 border border-space-border text-gray-200 font-mono text-xs;
|
||||
}
|
||||
|
||||
.tooltip-left:before {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Refactored Global Utilities */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/* Standard Layout Constants */
|
||||
:root {
|
||||
--view-padding: 2rem; /* 32px - Standard Padding */
|
||||
--view-gap: 2rem; /* 32px - Standard component gap */
|
||||
--card-radius: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--view-padding: 1rem;
|
||||
--view-gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Base View Container */
|
||||
.view-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
padding: var(--view-padding);
|
||||
gap: var(--view-gap);
|
||||
min-height: calc(100vh - 56px); /* Align with navbar height */
|
||||
max-width: 1400px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Specialized container for data-heavy pages (Logs) */
|
||||
.view-container-full {
|
||||
@apply w-full animate-fade-in flex flex-col;
|
||||
padding: var(--view-padding);
|
||||
gap: var(--view-gap);
|
||||
min-height: calc(100vh - 56px);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Centered container for form-heavy pages (Settings/Accounts) */
|
||||
.view-container-centered {
|
||||
@apply mx-auto w-full animate-fade-in flex flex-col;
|
||||
padding: var(--view-padding);
|
||||
gap: var(--view-gap);
|
||||
min-height: calc(100vh - 56px);
|
||||
max-width: 900px; /* Comfortable reading width for forms */
|
||||
}
|
||||
|
||||
/* Standard Section Header */
|
||||
.view-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.view-header {
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.view-header-title {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.view-header-title h2 {
|
||||
@apply text-2xl font-bold text-white tracking-tight;
|
||||
}
|
||||
|
||||
.view-header-title p {
|
||||
@apply text-sm text-gray-500 mt-1;
|
||||
}
|
||||
|
||||
.view-header-actions {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
/* Standard Card Panel */
|
||||
.view-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--card-radius);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: linear-gradient(135deg,
|
||||
rgba(15, 15, 17, 0.75) 0%,
|
||||
rgba(18, 18, 20, 0.70) 100%
|
||||
);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02) inset,
|
||||
0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.view-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04) inset,
|
||||
0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.view-card-header {
|
||||
@apply flex items-center justify-between mb-4 pb-4 border-b border-[rgba(39,39,42,0.3)];
|
||||
}
|
||||
|
||||
/* Component Unification */
|
||||
.standard-table {
|
||||
@apply table w-full border-separate border-spacing-0;
|
||||
}
|
||||
.standard-table thead {
|
||||
@apply bg-space-900/50 text-gray-500 font-mono text-xs uppercase border-b border-space-border;
|
||||
}
|
||||
.standard-table tbody tr {
|
||||
@apply transition-all duration-200 border-b border-[rgba(39,39,42,0.3)] last:border-0;
|
||||
}
|
||||
|
||||
.standard-table tbody tr:hover {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.03) 0%,
|
||||
rgba(255, 255, 255, 0.05) 50%,
|
||||
rgba(255, 255, 255, 0.03) 100%
|
||||
);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Custom Range Slider - Simplified */
|
||||
.custom-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--color-space-800);
|
||||
border-radius: 999px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--range-color, var(--color-neon-purple));
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.custom-range::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.custom-range::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--range-color, var(--color-neon-purple));
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.custom-range::-moz-range-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
/* Color Variants */
|
||||
.custom-range-purple {
|
||||
--range-color: var(--color-neon-purple);
|
||||
}
|
||||
.custom-range-green {
|
||||
--range-color: var(--color-neon-green);
|
||||
}
|
||||
.custom-range-cyan {
|
||||
--range-color: var(--color-neon-cyan);
|
||||
}
|
||||
.custom-range-yellow {
|
||||
--range-color: var(--color-neon-yellow);
|
||||
}
|
||||
.custom-range-accent {
|
||||
--range-color: var(--color-neon-cyan);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Refactored UI Components (Phase 1.2) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/* Action Buttons */
|
||||
.btn-action-ghost {
|
||||
@apply btn btn-xs btn-ghost text-gray-400 hover:text-white transition-colors;
|
||||
}
|
||||
|
||||
.btn-action-ghost-square {
|
||||
@apply btn btn-xs btn-ghost btn-square text-gray-400 hover:text-white transition-colors;
|
||||
}
|
||||
|
||||
.btn-action-primary {
|
||||
@apply btn bg-gradient-to-r from-neon-purple to-indigo-600
|
||||
border-none text-white shadow-lg shadow-neon-purple/20
|
||||
hover:shadow-neon-purple/40 transition-all;
|
||||
}
|
||||
|
||||
.btn-action-success {
|
||||
@apply btn btn-xs btn-ghost btn-square text-green-500 hover:bg-green-500/20;
|
||||
}
|
||||
|
||||
.btn-action-danger {
|
||||
@apply btn btn-xs btn-ghost btn-square text-red-400 hover:bg-red-500/20;
|
||||
}
|
||||
|
||||
.btn-action-neutral {
|
||||
@apply btn btn-xs btn-ghost btn-square text-gray-500 hover:bg-gray-500/20;
|
||||
}
|
||||
|
||||
/* Status Pills/Badges */
|
||||
.status-pill {
|
||||
@apply px-2 py-1 text-[10px] font-mono font-bold uppercase rounded border;
|
||||
}
|
||||
|
||||
.status-pill-purple {
|
||||
@apply status-pill bg-neon-purple/10 text-neon-purple border-neon-purple/30;
|
||||
}
|
||||
|
||||
.status-pill-ultra {
|
||||
@apply status-pill bg-yellow-500/10 text-yellow-400 border-yellow-500/30;
|
||||
}
|
||||
|
||||
.status-pill-pro {
|
||||
@apply status-pill bg-blue-500/10 text-blue-400 border-blue-500/30;
|
||||
}
|
||||
|
||||
.status-pill-free {
|
||||
@apply status-pill bg-gray-500/10 text-gray-400 border-gray-500/30;
|
||||
}
|
||||
|
||||
.status-pill-success {
|
||||
@apply status-pill bg-neon-green/10 text-neon-green border-neon-green/30;
|
||||
}
|
||||
|
||||
.status-pill-warning {
|
||||
@apply status-pill bg-yellow-500/10 text-yellow-400 border-yellow-500/30;
|
||||
}
|
||||
|
||||
.status-pill-error {
|
||||
@apply status-pill bg-red-500/10 text-red-400 border-red-500/30;
|
||||
}
|
||||
|
||||
/* Input Components */
|
||||
.input-search {
|
||||
@apply w-full bg-space-900/50 border border-[rgba(39,39,42,0.5)] text-gray-300
|
||||
rounded-lg pl-10 pr-4 py-2
|
||||
focus:outline-none focus:bg-space-800 focus:border-neon-purple/50
|
||||
hover:border-space-border hover:bg-space-800/80
|
||||
transition-all placeholder-gray-600/80;
|
||||
}
|
||||
|
||||
.input-search-sm {
|
||||
@apply input-search h-8 text-xs font-normal;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Skeleton Loading (Phase 4.1) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/* Skeleton animation */
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Base skeleton element */
|
||||
.skeleton {
|
||||
@apply bg-gradient-to-r from-space-900/60 via-space-800/40 to-space-900/60;
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* Skeleton variants */
|
||||
.skeleton-text {
|
||||
@apply skeleton h-4 w-full;
|
||||
}
|
||||
|
||||
.skeleton-text-sm {
|
||||
@apply skeleton h-3 w-3/4;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
@apply skeleton h-6 w-1/2;
|
||||
}
|
||||
|
||||
.skeleton-circle {
|
||||
@apply skeleton rounded-full;
|
||||
}
|
||||
|
||||
.skeleton-stat-card {
|
||||
@apply skeleton h-32 w-full rounded-xl;
|
||||
}
|
||||
|
||||
.skeleton-chart {
|
||||
@apply skeleton h-64 w-full rounded-xl;
|
||||
}
|
||||
|
||||
.skeleton-table-row {
|
||||
@apply skeleton h-12 w-full mb-2;
|
||||
}
|
||||
373
public/css/style.css
generated
373
public/css/style.css
generated
File diff suppressed because one or more lines are too long
@@ -9,57 +9,10 @@
|
||||
|
||||
<!-- Libraries -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.14/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
|
||||
<!-- Alpine.js must be deferred so stores register their listeners first -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Custom Config -->
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
|
||||
sans: ['Inter', 'system-ui', 'sans-serif']
|
||||
},
|
||||
colors: {
|
||||
// Deep Space Palette
|
||||
space: {
|
||||
950: 'var(--color-space-950)', // Deep background
|
||||
900: 'var(--color-space-900)', // Panel background
|
||||
850: 'var(--color-space-850)', // Hover states
|
||||
800: 'var(--color-space-800)', // UI elements
|
||||
border: 'var(--color-space-border)'
|
||||
},
|
||||
neon: {
|
||||
purple: 'var(--color-neon-purple)',
|
||||
cyan: 'var(--color-neon-cyan)',
|
||||
green: 'var(--color-neon-green)',
|
||||
yellow: 'var(--color-neon-yellow)',
|
||||
red: 'var(--color-neon-red)'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
daisyui: {
|
||||
themes: [{
|
||||
antigravity: {
|
||||
"primary": "var(--color-neon-purple)", // Neon Purple
|
||||
"secondary": "var(--color-neon-green)", // Neon Green
|
||||
"accent": "var(--color-neon-cyan)", // Neon Cyan
|
||||
"neutral": "var(--color-space-800)", // space-800
|
||||
"base-100": "var(--color-space-950)", // space-950
|
||||
"info": "var(--color-neon-cyan)",
|
||||
"success": "var(--color-neon-green)",
|
||||
"warning": "var(--color-neon-yellow)",
|
||||
"error": "var(--color-neon-red)",
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<!-- Compiled Tailwind CSS (includes DaisyUI) -->
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
|
||||
@@ -138,8 +91,8 @@
|
||||
<div class="h-4 w-px bg-space-border"></div>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button class="btn btn-ghost btn-xs btn-square text-gray-400 hover:text-white hover:bg-white/5"
|
||||
@click="fetchData" :disabled="loading" :title="$store.global.t('refreshData')">
|
||||
<button type="button" class="btn btn-ghost btn-xs btn-square text-gray-400 hover:text-white hover:bg-white/5"
|
||||
@click="fetchData" :disabled="loading" :title="$store.global.t('refreshData')" aria-label="Refresh data">
|
||||
<svg class="w-4 h-4" :class="{'animate-spin': loading}" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
@@ -218,15 +171,15 @@
|
||||
<!-- Footer Info -->
|
||||
<div class="mt-auto px-6 text-[10px] text-gray-700 font-mono">
|
||||
<div class="flex justify-between">
|
||||
<span>V 1.0.0</span>
|
||||
<a href="https://github.com/badri-s2001/antigravity-claude-proxy" target="_blank"
|
||||
<span x-text="'V ' + $store.global.version">V 1.0.0</span>
|
||||
<a href="https://github.com/badri-s2001/antigravity-claude-proxy" target="_blank" rel="noopener noreferrer"
|
||||
class="hover:text-neon-purple transition-colors">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 overflow-auto bg-space-950 relative custom-scrollbar" style="scrollbar-gutter: stable;">
|
||||
<div class="flex-1 overflow-auto bg-space-950 relative custom-scrollbar">
|
||||
|
||||
<!-- Views Container -->
|
||||
<!-- Dashboard -->
|
||||
@@ -304,12 +257,12 @@
|
||||
|
||||
<div class="modal-action mt-6">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-ghost hover:bg-white/10" x-text="$store.global.t('close')">Close</button>
|
||||
<button type="button" class="btn btn-ghost hover:bg-white/10" x-text="$store.global.t('close')">Close</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button x-text="$store.global.t('close')">close</button>
|
||||
<button type="button" x-text="$store.global.t('close')">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -354,6 +307,7 @@
|
||||
<script src="js/config/constants.js"></script>
|
||||
<script src="js/utils.js"></script>
|
||||
<script src="js/utils/error-handler.js"></script>
|
||||
<script src="js/utils/account-actions.js"></script>
|
||||
<script src="js/utils/validators.js"></script>
|
||||
<script src="js/utils/model-config.js"></script>
|
||||
<!-- 2. Alpine Stores (register alpine:init listeners) -->
|
||||
|
||||
@@ -7,6 +7,10 @@ window.Components = window.Components || {};
|
||||
window.Components.accountManager = () => ({
|
||||
searchQuery: '',
|
||||
deleteTarget: '',
|
||||
refreshing: false,
|
||||
toggling: false,
|
||||
deleting: false,
|
||||
reloading: false,
|
||||
|
||||
get filteredAccounts() {
|
||||
const accounts = Alpine.store('data').accounts || [];
|
||||
@@ -36,11 +40,15 @@ window.Components.accountManager = () => ({
|
||||
},
|
||||
|
||||
async refreshAccount(email) {
|
||||
return await window.ErrorHandler.withLoading(async () => {
|
||||
const store = Alpine.store('global');
|
||||
store.showToast(store.t('refreshingAccount', { email }), 'info');
|
||||
const password = store.webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/refresh`, { method: 'POST' }, password);
|
||||
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/accounts/${encodeURIComponent(email)}/refresh`,
|
||||
{ method: 'POST' },
|
||||
store.webuiPassword
|
||||
);
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
@@ -48,11 +56,9 @@ window.Components.accountManager = () => ({
|
||||
store.showToast(store.t('refreshedAccount', { email }), 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
} else {
|
||||
store.showToast(data.error || store.t('refreshFailed'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
store.showToast(store.t('refreshFailed') + ': ' + e.message, 'error');
|
||||
throw new Error(data.error || store.t('refreshFailed'));
|
||||
}
|
||||
}, this, 'refreshing', { errorMessage: 'Failed to refresh account' });
|
||||
},
|
||||
|
||||
async toggleAccount(email, enabled) {
|
||||
@@ -125,11 +131,14 @@ window.Components.accountManager = () => ({
|
||||
|
||||
async executeDelete() {
|
||||
const email = this.deleteTarget;
|
||||
return await window.ErrorHandler.withLoading(async () => {
|
||||
const store = Alpine.store('global');
|
||||
const password = store.webuiPassword;
|
||||
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}`, { method: 'DELETE' }, password);
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/accounts/${encodeURIComponent(email)}`,
|
||||
{ method: 'DELETE' },
|
||||
store.webuiPassword
|
||||
);
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
@@ -139,18 +148,20 @@ window.Components.accountManager = () => ({
|
||||
document.getElementById('delete_account_modal').close();
|
||||
this.deleteTarget = '';
|
||||
} else {
|
||||
store.showToast(data.error || store.t('deleteFailed'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
store.showToast(store.t('deleteFailed') + ': ' + e.message, 'error');
|
||||
throw new Error(data.error || store.t('deleteFailed'));
|
||||
}
|
||||
}, this, 'deleting', { errorMessage: 'Failed to delete account' });
|
||||
},
|
||||
|
||||
async reloadAccounts() {
|
||||
return await window.ErrorHandler.withLoading(async () => {
|
||||
const store = Alpine.store('global');
|
||||
const password = store.webuiPassword;
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request('/api/accounts/reload', { method: 'POST' }, password);
|
||||
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
'/api/accounts/reload',
|
||||
{ method: 'POST' },
|
||||
store.webuiPassword
|
||||
);
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
@@ -158,11 +169,9 @@ window.Components.accountManager = () => ({
|
||||
store.showToast(store.t('accountsReloaded'), 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
} else {
|
||||
store.showToast(data.error || store.t('reloadFailed'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
store.showToast(store.t('reloadFailed') + ': ' + e.message, 'error');
|
||||
throw new Error(data.error || store.t('reloadFailed'));
|
||||
}
|
||||
}, this, 'reloading', { errorMessage: 'Failed to reload accounts' });
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ window.Components = window.Components || {};
|
||||
|
||||
window.Components.claudeConfig = () => ({
|
||||
config: { env: {} },
|
||||
configPath: '', // Dynamic path from backend
|
||||
models: [],
|
||||
loading: false,
|
||||
gemini1mSuffix: false,
|
||||
@@ -97,6 +98,7 @@ window.Components.claudeConfig = () => ({
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
this.config = data.config || {};
|
||||
this.configPath = data.path || '~/.claude/settings.json'; // Save dynamic path
|
||||
if (!this.config.env) this.config.env = {};
|
||||
|
||||
// Default MCP CLI to true if not set
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
/**
|
||||
* Dashboard Charts Module
|
||||
* Handles Chart.js visualizations (quota distribution & usage trend)
|
||||
* 职责:使用 Chart.js 渲染配额分布图和使用趋势图
|
||||
*
|
||||
* 调用时机:
|
||||
* - dashboard 组件 init() 时初始化图表
|
||||
* - 筛选器变化时更新图表数据
|
||||
* - $store.data 更新时刷新图表
|
||||
*
|
||||
* 图表类型:
|
||||
* 1. Quota Distribution(饼图):按模型家族或具体模型显示配额分布
|
||||
* 2. Usage Trend(折线图):显示历史使用趋势
|
||||
*
|
||||
* 特殊处理:
|
||||
* - 使用 _trendChartUpdateLock 防止并发更新导致的竞争条件
|
||||
* - 通过 debounce 优化频繁更新的性能
|
||||
* - 响应式处理:移动端自动调整图表大小和标签显示
|
||||
*
|
||||
* @module DashboardCharts
|
||||
*/
|
||||
window.DashboardCharts = window.DashboardCharts || {};
|
||||
|
||||
@@ -133,7 +149,7 @@ window.DashboardCharts.updateCharts = function (component) {
|
||||
|
||||
// Safety checks
|
||||
if (!canvas) {
|
||||
console.warn("quotaChart canvas not found");
|
||||
console.debug("quotaChart canvas not found");
|
||||
return;
|
||||
}
|
||||
if (typeof Chart === "undefined") {
|
||||
@@ -141,7 +157,7 @@ window.DashboardCharts.updateCharts = function (component) {
|
||||
return;
|
||||
}
|
||||
if (!isCanvasReady(canvas)) {
|
||||
console.warn("quotaChart canvas not ready, skipping update");
|
||||
console.debug("quotaChart canvas not ready, skipping update");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
/**
|
||||
* Dashboard Filters Module
|
||||
* Handles model/family filter selection and persistence
|
||||
* 职责:管理图表筛选器的状态和持久化
|
||||
*
|
||||
* 功能:
|
||||
* 1. 时间范围筛选(1h/6h/24h/7d/all)
|
||||
* 2. 显示模式切换(按家族/按模型)
|
||||
* 3. 模型/家族多选筛选
|
||||
* 4. 筛选器状态持久化到 localStorage
|
||||
*
|
||||
* 调用时机:
|
||||
* - 组件初始化时加载用户偏好
|
||||
* - 筛选器变化时保存并触发图表更新
|
||||
*
|
||||
* 持久化键:
|
||||
* - localStorage['dashboard_chart_prefs']
|
||||
*
|
||||
* @module DashboardFilters
|
||||
*/
|
||||
window.DashboardFilters = window.DashboardFilters || {};
|
||||
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
/**
|
||||
* Dashboard Stats Module
|
||||
* Handles account statistics calculation
|
||||
* 职责:根据 Alpine.store('data') 计算账号统计数据
|
||||
*
|
||||
* 调用时机:
|
||||
* - dashboard 组件 init() 时
|
||||
* - $store.data 更新时(通过 $watch 监听)
|
||||
*
|
||||
* 统计维度:
|
||||
* - total: 启用账号总数(排除禁用账号)
|
||||
* - active: 有可用配额的账号数
|
||||
* - limited: 配额受限或失效的账号数
|
||||
* - subscription: 按订阅级别分类(ultra/pro/free)
|
||||
*
|
||||
* @module DashboardStats
|
||||
*/
|
||||
window.DashboardStats = window.DashboardStats || {};
|
||||
|
||||
/**
|
||||
* Update account statistics (active, limited, total)
|
||||
* @param {object} component - Dashboard component instance
|
||||
* 更新账号统计数据
|
||||
*
|
||||
* 统计逻辑:
|
||||
* 1. 仅统计启用的账号(enabled !== false)
|
||||
* 2. 优先统计核心模型(Sonnet/Opus/Pro/Flash)的配额
|
||||
* 3. 配额 > 5% 视为 active,否则为 limited
|
||||
* 4. 状态非 'ok' 的账号归为 limited
|
||||
*
|
||||
* @param {object} component - Dashboard 组件实例(Alpine.js 上下文)
|
||||
* @param {object} component.stats - 统计数据对象(会被修改)
|
||||
* @param {number} component.stats.total - 启用账号总数
|
||||
* @param {number} component.stats.active - 活跃账号数
|
||||
* @param {number} component.stats.limited - 受限账号数
|
||||
* @param {object} component.stats.subscription - 订阅级别分布
|
||||
* @returns {void}
|
||||
*/
|
||||
window.DashboardStats.updateStats = function(component) {
|
||||
const accounts = Alpine.store('data').accounts;
|
||||
|
||||
@@ -14,6 +14,7 @@ document.addEventListener('alpine:init', () => {
|
||||
quotaRows: [], // Filtered view
|
||||
usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true)
|
||||
loading: false,
|
||||
initialLoad: true, // Track first load for skeleton screen
|
||||
connectionStatus: 'connecting',
|
||||
lastUpdated: '-',
|
||||
|
||||
@@ -36,7 +37,10 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
async fetchData() {
|
||||
// Only show skeleton on initial load, not on refresh
|
||||
if (this.initialLoad) {
|
||||
this.loading = true;
|
||||
}
|
||||
try {
|
||||
// Get password from global store
|
||||
const password = Alpine.store('global').webuiPassword;
|
||||
@@ -65,6 +69,11 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
this.connectionStatus = 'connected';
|
||||
this.lastUpdated = new Date().toLocaleTimeString();
|
||||
|
||||
// Fetch version from config endpoint if not already loaded
|
||||
if (this.initialLoad) {
|
||||
this.fetchVersion(password);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
this.connectionStatus = 'disconnected';
|
||||
@@ -72,6 +81,21 @@ document.addEventListener('alpine:init', () => {
|
||||
store.showToast(store.t('connectionLost'), 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.initialLoad = false; // Mark initial load as complete
|
||||
}
|
||||
},
|
||||
|
||||
async fetchVersion(password) {
|
||||
try {
|
||||
const { response } = await window.utils.request('/api/config', {}, password);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.version) {
|
||||
Alpine.store('global').version = data.version;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch version:', error);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ document.addEventListener('alpine:init', () => {
|
||||
linkedAccounts: "Linked Accounts",
|
||||
noSignal: "NO SIGNAL DETECTED",
|
||||
establishingUplink: "ESTABLISHING UPLINK...",
|
||||
goToAccounts: "Go to Accounts",
|
||||
// Settings - Models
|
||||
modelsDesc: "Configure model visibility, pinning, and request routing.",
|
||||
modelsPageDesc: "Real-time quota and status for all available models.",
|
||||
@@ -97,7 +98,8 @@ document.addEventListener('alpine:init', () => {
|
||||
opusAlias: "Opus Alias",
|
||||
sonnetAlias: "Sonnet Alias",
|
||||
haikuAlias: "Haiku Alias",
|
||||
claudeSettingsAlert: "Settings below directly modify ~/.claude/settings.json. Restart Claude CLI to apply.",
|
||||
claudeSettingsAlertPrefix: "Settings below directly modify",
|
||||
claudeSettingsAlertSuffix: "Restart Claude CLI to apply.",
|
||||
applyToClaude: "Apply to Claude CLI",
|
||||
// Settings - Server
|
||||
port: "Port",
|
||||
@@ -117,7 +119,7 @@ document.addEventListener('alpine:init', () => {
|
||||
sonnetModel: "Sonnet Model",
|
||||
haikuModel: "Haiku Model",
|
||||
authToken: "Auth Token",
|
||||
saveConfig: "Save to ~/.claude/settings.json",
|
||||
saveConfig: "Save to Claude CLI settings",
|
||||
envVar: "Env",
|
||||
// New Keys
|
||||
systemName: "ANTIGRAVITY",
|
||||
@@ -326,6 +328,7 @@ document.addEventListener('alpine:init', () => {
|
||||
linkedAccounts: "已关联账号",
|
||||
noSignal: "无信号连接",
|
||||
establishingUplink: "正在建立上行链路...",
|
||||
goToAccounts: "前往账号管理",
|
||||
// Settings - Models
|
||||
modelsDesc: "配置模型的可见性、置顶和请求路由。",
|
||||
modelsPageDesc: "所有可用模型的实时配额和状态。",
|
||||
@@ -344,7 +347,8 @@ document.addEventListener('alpine:init', () => {
|
||||
opusAlias: "Opus 别名",
|
||||
sonnetAlias: "Sonnet 别名",
|
||||
haikuAlias: "Haiku 别名",
|
||||
claudeSettingsAlert: "以下设置直接修改 ~/.claude/settings.json。重启 Claude CLI 生效。",
|
||||
claudeSettingsAlertPrefix: "以下设置直接修改",
|
||||
claudeSettingsAlertSuffix: "重启 Claude CLI 生效。",
|
||||
applyToClaude: "应用到 Claude CLI",
|
||||
// Settings - Server
|
||||
port: "端口",
|
||||
@@ -364,7 +368,7 @@ document.addEventListener('alpine:init', () => {
|
||||
sonnetModel: "Sonnet 模型",
|
||||
haikuModel: "Haiku 模型",
|
||||
authToken: "认证令牌",
|
||||
saveConfig: "保存到 ~/.claude/settings.json",
|
||||
saveConfig: "保存到 Claude CLI 设置",
|
||||
envVar: "环境变量",
|
||||
// New Keys
|
||||
systemName: "ANTIGRAVITY",
|
||||
|
||||
199
public/js/utils/account-actions.js
Normal file
199
public/js/utils/account-actions.js
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Account Actions Service
|
||||
* 纯业务逻辑层 - 处理账号操作的 HTTP 请求、乐观更新和数据刷新
|
||||
* 不包含 UI 关注点(Toast、Loading、模态框由组件层处理)
|
||||
*/
|
||||
window.AccountActions = window.AccountActions || {};
|
||||
|
||||
/**
|
||||
* 刷新账号 token 和配额信息
|
||||
* @param {string} email - 账号邮箱
|
||||
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
||||
*/
|
||||
window.AccountActions.refreshAccount = async function(email) {
|
||||
const store = Alpine.store('global');
|
||||
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/accounts/${encodeURIComponent(email)}/refresh`,
|
||||
{ method: 'POST' },
|
||||
store.webuiPassword
|
||||
);
|
||||
|
||||
if (newPassword) {
|
||||
store.webuiPassword = newPassword;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'ok') {
|
||||
return { success: false, error: data.error || 'Refresh failed' };
|
||||
}
|
||||
|
||||
// 触发数据刷新
|
||||
await Alpine.store('data').fetchData();
|
||||
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换账号启用/禁用状态(包含乐观更新和错误回滚)
|
||||
* @param {string} email - 账号邮箱
|
||||
* @param {boolean} enabled - 目标状态(true=启用, false=禁用)
|
||||
* @returns {Promise<{success: boolean, rolledBack?: boolean, data?: object, error?: string}>}
|
||||
*/
|
||||
window.AccountActions.toggleAccount = async function(email, enabled) {
|
||||
const store = Alpine.store('global');
|
||||
const dataStore = Alpine.store('data');
|
||||
|
||||
// 乐观更新:立即修改 UI
|
||||
const account = dataStore.accounts.find(a => a.email === email);
|
||||
const previousState = account ? account.enabled : !enabled;
|
||||
|
||||
if (account) {
|
||||
account.enabled = enabled;
|
||||
}
|
||||
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/accounts/${encodeURIComponent(email)}/toggle`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled })
|
||||
},
|
||||
store.webuiPassword
|
||||
);
|
||||
|
||||
if (newPassword) {
|
||||
store.webuiPassword = newPassword;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'ok') {
|
||||
throw new Error(data.error || 'Toggle failed');
|
||||
}
|
||||
|
||||
// 确认服务器状态
|
||||
await dataStore.fetchData();
|
||||
return { success: true, data };
|
||||
|
||||
} catch (error) {
|
||||
// 错误回滚:恢复原状态
|
||||
if (account) {
|
||||
account.enabled = previousState;
|
||||
}
|
||||
await dataStore.fetchData();
|
||||
return { success: false, error: error.message, rolledBack: true };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除账号
|
||||
* @param {string} email - 账号邮箱
|
||||
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
||||
*/
|
||||
window.AccountActions.deleteAccount = async function(email) {
|
||||
const store = Alpine.store('global');
|
||||
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/accounts/${encodeURIComponent(email)}`,
|
||||
{ method: 'DELETE' },
|
||||
store.webuiPassword
|
||||
);
|
||||
|
||||
if (newPassword) {
|
||||
store.webuiPassword = newPassword;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'ok') {
|
||||
return { success: false, error: data.error || 'Delete failed' };
|
||||
}
|
||||
|
||||
// 触发数据刷新
|
||||
await Alpine.store('data').fetchData();
|
||||
return { success: true, data };
|
||||
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取账号重新认证的 OAuth URL
|
||||
* 注意:此方法仅返回 URL,不打开窗口(由组件层决定如何处理)
|
||||
* @param {string} email - 账号邮箱
|
||||
* @returns {Promise<{success: boolean, url?: string, error?: string}>}
|
||||
*/
|
||||
window.AccountActions.getFixAccountUrl = async function(email) {
|
||||
const store = Alpine.store('global');
|
||||
|
||||
try {
|
||||
const urlPath = `/api/auth/url?email=${encodeURIComponent(email)}`;
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
urlPath,
|
||||
{},
|
||||
store.webuiPassword
|
||||
);
|
||||
|
||||
if (newPassword) {
|
||||
store.webuiPassword = newPassword;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'ok') {
|
||||
return { success: false, error: data.error || 'Failed to get auth URL' };
|
||||
}
|
||||
|
||||
return { success: true, url: data.url };
|
||||
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从磁盘重新加载所有账号配置
|
||||
* @returns {Promise<{success: boolean, data?: object, error?: string}>}
|
||||
*/
|
||||
window.AccountActions.reloadAccounts = async function() {
|
||||
const store = Alpine.store('global');
|
||||
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
'/api/accounts/reload',
|
||||
{ method: 'POST' },
|
||||
store.webuiPassword
|
||||
);
|
||||
|
||||
if (newPassword) {
|
||||
store.webuiPassword = newPassword;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'ok') {
|
||||
return { success: false, error: data.error || 'Reload failed' };
|
||||
}
|
||||
|
||||
// 触发数据刷新
|
||||
await Alpine.store('data').fetchData();
|
||||
return { success: true, data };
|
||||
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查账号是否可以删除
|
||||
* 来自 Antigravity 数据库的账号(source='database')不可删除
|
||||
* @param {object} account - 账号对象
|
||||
* @returns {boolean} true 表示可删除
|
||||
*/
|
||||
window.AccountActions.canDelete = function(account) {
|
||||
return account && account.source !== 'database';
|
||||
};
|
||||
@@ -105,3 +105,41 @@ window.ErrorHandler.apiCall = async function(apiCall, successMessage = null, err
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute an async function with automatic loading state management
|
||||
* @param {Function} asyncFn - Async function to execute
|
||||
* @param {object} context - Component context (this) that contains the loading state
|
||||
* @param {string} loadingKey - Name of the loading state property (default: 'loading')
|
||||
* @param {object} options - Additional options (same as safeAsync)
|
||||
* @returns {Promise<any>} Result of the function or undefined on error
|
||||
*
|
||||
* @example
|
||||
* // In your Alpine component:
|
||||
* async refreshAccount(email) {
|
||||
* return await window.ErrorHandler.withLoading(async () => {
|
||||
* const response = await window.utils.request(`/api/accounts/${email}/refresh`, { method: 'POST' });
|
||||
* this.$store.global.showToast('Account refreshed', 'success');
|
||||
* return response;
|
||||
* }, this, 'refreshing');
|
||||
* }
|
||||
*
|
||||
* // In HTML:
|
||||
* // <button @click="refreshAccount(email)" :disabled="refreshing">
|
||||
* // <i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshing }"></i>
|
||||
* // Refresh
|
||||
* // </button>
|
||||
*/
|
||||
window.ErrorHandler.withLoading = async function(asyncFn, context, loadingKey = 'loading', options = {}) {
|
||||
// Set loading state to true
|
||||
context[loadingKey] = true;
|
||||
|
||||
try {
|
||||
// Execute the async function with error handling
|
||||
const result = await window.ErrorHandler.safeAsync(asyncFn, options.errorMessage, options);
|
||||
return result;
|
||||
} finally {
|
||||
// Always reset loading state, even if there was an error
|
||||
context[loadingKey] = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,15 +19,20 @@
|
||||
<input type="text"
|
||||
x-model="searchQuery"
|
||||
:placeholder="$store.global.t('searchAccounts')"
|
||||
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-48 pl-9 text-xs h-8"
|
||||
:aria-label="$store.global.t('searchAccounts')"
|
||||
class="input-search-sm w-48 pl-9 h-8"
|
||||
@keydown.escape="searchQuery = ''">
|
||||
<svg class="w-4 h-4 absolute left-3 top-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<button class="btn btn-xs btn-outline border-space-border text-gray-400 hover:text-white transition-all gap-2 h-8"
|
||||
@click="reloadAccounts()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@click="reloadAccounts()"
|
||||
:disabled="reloading">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-3.5 h-3.5 transition-transform"
|
||||
:class="{ 'animate-spin': reloading }"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span x-text="$store.global.t('reload')">Reload</span>
|
||||
@@ -109,17 +114,15 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4">
|
||||
<span class="px-2 py-1 text-[10px] font-mono font-bold uppercase rounded"
|
||||
:class="acc.source === 'oauth' ? 'bg-neon-purple/10 text-neon-purple border border-neon-purple/30' : 'bg-gray-500/10 text-gray-400 border border-gray-500/30'"
|
||||
<span :class="acc.source === 'oauth' ? 'status-pill-purple' : 'status-pill-free'"
|
||||
x-text="acc.source || 'oauth'">
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4">
|
||||
<span class="px-2 py-1 text-[10px] font-mono font-bold uppercase rounded"
|
||||
:class="{
|
||||
'bg-yellow-500/10 text-yellow-400 border border-yellow-500/30': acc.subscription?.tier === 'ultra',
|
||||
'bg-blue-500/10 text-blue-400 border border-blue-500/30': acc.subscription?.tier === 'pro',
|
||||
'bg-gray-500/10 text-gray-400 border border-gray-500/30': !acc.subscription || acc.subscription.tier === 'free' || acc.subscription.tier === 'unknown'
|
||||
<span :class="{
|
||||
'status-pill-ultra': acc.subscription?.tier === 'ultra',
|
||||
'status-pill-pro': acc.subscription?.tier === 'pro',
|
||||
'status-pill-free': !acc.subscription || acc.subscription.tier === 'free' || acc.subscription.tier === 'unknown'
|
||||
}"
|
||||
x-text="(acc.subscription?.tier || 'free').toUpperCase()">
|
||||
</span>
|
||||
@@ -169,8 +172,12 @@
|
||||
FIX
|
||||
</button>
|
||||
<button class="p-2 rounded hover:bg-white/10 text-gray-500 hover:text-white transition-colors"
|
||||
@click="refreshAccount(acc.email)" :title="$store.global.t('refreshData')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@click="refreshAccount(acc.email)"
|
||||
:disabled="refreshing"
|
||||
:title="$store.global.t('refreshData')">
|
||||
<svg class="w-4 h-4 transition-transform"
|
||||
:class="{ 'animate-spin': refreshing }"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
@@ -200,7 +207,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<p class="text-sm text-gray-600" x-text="$store.global.t('noSearchResults')">No accounts match your search</p>
|
||||
<button class="btn btn-xs btn-ghost text-gray-500" @click="searchQuery = ''" x-text="$store.global.t('clearSearch')">Clear Search</button>
|
||||
<button class="btn-action-ghost !text-gray-500" @click="searchQuery = ''" x-text="$store.global.t('clearSearch')">Clear Search</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -245,8 +252,10 @@
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn bg-red-500 hover:bg-red-600 border-none text-white"
|
||||
@click="executeDelete()">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@click="executeDelete()"
|
||||
:disabled="deleting"
|
||||
:class="{ 'loading': deleting }">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="!deleting">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<span x-text="$store.global.t('confirmDelete')">Confirm Delete</span>
|
||||
|
||||
@@ -26,6 +26,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton Loading (仅在首次加载时显示) -->
|
||||
<div x-show="$store.data.initialLoad" class="space-y-6">
|
||||
<!-- Skeleton Stats Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div class="skeleton-stat-card"></div>
|
||||
<div class="skeleton-stat-card"></div>
|
||||
<div class="skeleton-stat-card"></div>
|
||||
<div class="skeleton-stat-card"></div>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton Charts -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="skeleton-chart"></div>
|
||||
<div class="skeleton-chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actual Content (首次加载完成后显示) -->
|
||||
<div x-show="!$store.data.initialLoad" class="space-y-6">
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div
|
||||
@@ -434,4 +453,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- End of x-show="!$store.data.loading" -->
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<div x-data="logsViewer" class="view-container h-full flex flex-col">
|
||||
<div class="glass-panel rounded-xl overflow-hidden border-space-border flex flex-col flex-1 min-h-0">
|
||||
<div class="view-card !p-0 flex flex-col flex-1 min-h-0">
|
||||
<!-- Toolbar -->
|
||||
<div class="bg-space-900 flex flex-wrap gap-y-2 justify-between items-center p-2 px-4 border-b border-space-border select-none min-h-[48px] shrink-0">
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
x-text="$store.global.t('autoScroll')">Auto-Scroll</span>
|
||||
<input type="checkbox" class="toggle toggle-xs toggle-success" x-model="isAutoScroll">
|
||||
</label>
|
||||
<button class="btn btn-xs btn-ghost btn-square text-gray-400 hover:text-white" @click="clearLogs" :title="$store.global.t('clearLogs')">
|
||||
<button class="btn-action-ghost-square" @click="clearLogs" :title="$store.global.t('clearLogs')">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
|
||||
@@ -20,9 +20,12 @@
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="text" :placeholder="$store.global.t('searchPlaceholder')"
|
||||
class="w-full h-full bg-space-800 border border-space-border text-gray-300 rounded-lg pl-10 pr-10 focus:outline-none focus:border-neon-purple focus:ring-1 focus:ring-neon-purple transition-all text-xs placeholder-gray-600"
|
||||
x-model.debounce="$store.data.filters.search" @input="$store.data.computeQuotaRows()">
|
||||
<input type="text"
|
||||
:placeholder="$store.global.t('searchPlaceholder')"
|
||||
:aria-label="$store.global.t('searchPlaceholder')"
|
||||
class="input-search-sm pr-10"
|
||||
x-model.debounce="$store.data.filters.search"
|
||||
@input="$store.data.computeQuotaRows()">
|
||||
<button x-show="$store.data.filters.search"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 scale-75"
|
||||
@@ -237,11 +240,28 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Empty -->
|
||||
<tr x-show="!$store.data.loading && $store.data.quotaRows.length === 0">
|
||||
<td colspan="6" class="h-64 text-center text-gray-600 font-mono text-xs"
|
||||
x-text="$store.global.t('noSignal')">
|
||||
<!-- Empty State -->
|
||||
<tr x-show="!$store.data.initialLoad && $store.data.quotaRows.length === 0">
|
||||
<td colspan="6" class="py-16 text-center">
|
||||
<div class="flex flex-col items-center gap-4 max-w-lg mx-auto">
|
||||
<svg class="w-20 h-20 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-gray-400" x-text="$store.global.t('noSignal')">
|
||||
NO SIGNAL DETECTED
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 max-w-md leading-relaxed">
|
||||
No model data available. Add accounts to see available models and quota information.
|
||||
</p>
|
||||
<button class="btn bg-neon-purple hover:bg-purple-600 border-none text-white btn-sm gap-2 shadow-lg shadow-neon-purple/20"
|
||||
@click="$store.global.activeTab = 'accounts'">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span x-text="$store.global.t('goToAccounts')">Go to Accounts</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
activeTab: 'ui'
|
||||
}" class="view-container">
|
||||
<!-- Header & Tabs -->
|
||||
<div class="glass-panel rounded-xl border border-space-border flex flex-col overflow-hidden">
|
||||
<div class="view-card !p-0 flex flex-col overflow-hidden">
|
||||
<div class="bg-space-900/50 border-b border-space-border px-8 pt-8 pb-0 shrink-0">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
@@ -53,7 +53,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
<span x-text="$store.global.t('tabServer')">Server</span>
|
||||
</button>
|
||||
@@ -197,9 +197,11 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span class="text-gray-400" x-text="$store.global.t('claudeSettingsAlert')">Settings below directly
|
||||
modify <code class="text-neon-cyan font-mono">~/.claude/settings.json</code>. Restart Claude CLI
|
||||
to apply.</span>
|
||||
<span class="text-gray-400">
|
||||
<span x-text="$store.global.t('claudeSettingsAlertPrefix')">Settings below directly modify</span>
|
||||
<code class="text-neon-cyan font-mono" x-text="configPath">~/.claude/settings.json</code>.
|
||||
<span x-text="$store.global.t('claudeSettingsAlertSuffix')">Restart Claude CLI to apply.</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Base URL -->
|
||||
@@ -558,7 +560,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Models List -->
|
||||
<div class="glass-panel rounded-lg overflow-hidden">
|
||||
<div class="view-card !p-0">
|
||||
<table class="standard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -609,7 +611,7 @@
|
||||
placeholder="e.g. claude-sonnet-4-5 or gemini-3-flash"
|
||||
@keydown.enter="await updateModelConfig(modelId, { mapping: newMapping }); stopEditing()"
|
||||
@keydown.escape="newMapping = config.mapping || ''; stopEditing()">
|
||||
<button class="btn btn-xs btn-ghost btn-square text-green-500 hover:bg-green-500/20"
|
||||
<button class="btn-action-success"
|
||||
@click="await updateModelConfig(modelId, { mapping: newMapping }); stopEditing()"
|
||||
title="Save">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none"
|
||||
@@ -618,7 +620,7 @@
|
||||
stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-xs btn-ghost btn-square text-gray-500 hover:bg-gray-500/20"
|
||||
<button class="btn-action-neutral"
|
||||
@click="newMapping = config.mapping || ''; stopEditing()"
|
||||
title="Cancel">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none"
|
||||
@@ -628,7 +630,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
<button x-show="config.mapping"
|
||||
class="btn btn-xs btn-ghost btn-square text-red-400 hover:bg-red-500/20"
|
||||
class="btn-action-danger"
|
||||
@click="await updateModelConfig(modelId, { mapping: '' }); stopEditing()"
|
||||
title="Clear mapping">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none"
|
||||
@@ -700,7 +702,7 @@
|
||||
<div x-show="activeTab === 'server'" x-data="window.Components.serverConfig()"
|
||||
class="space-y-6 max-w-2xl animate-fade-in pb-10">
|
||||
<!-- 🔐 Security Section -->
|
||||
<div class="glass-panel p-6 border border-neon-yellow/20 bg-neon-yellow/5">
|
||||
<div class="view-card border-neon-yellow/20 bg-neon-yellow/5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
@@ -741,7 +743,7 @@
|
||||
|
||||
<!-- Debug Mode -->
|
||||
<div
|
||||
class="form-control glass-panel p-4 border border-space-border/50 hover:border-neon-purple/50 transition-all">
|
||||
class="form-control view-card border-space-border/50 hover:border-neon-purple/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-gray-200"
|
||||
@@ -763,7 +765,7 @@
|
||||
|
||||
<!-- Token Cache -->
|
||||
<div
|
||||
class="form-control glass-panel p-4 border border-space-border/50 hover:border-neon-green/50 transition-all">
|
||||
class="form-control view-card border-space-border/50 hover:border-neon-green/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-gray-200"
|
||||
@@ -787,7 +789,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ▼ Advanced Tuning (Fixed Logic) -->
|
||||
<div class="glass-panel border border-space-border/50 overflow-hidden">
|
||||
<div class="view-card !p-0 border-space-border/50">
|
||||
<div class="flex items-center justify-between p-4 cursor-pointer hover:bg-white/5 transition-colors"
|
||||
@click="advancedExpanded = !advancedExpanded">
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import express from 'express';
|
||||
import { getPublicConfig, saveConfig, config } from '../config.js';
|
||||
import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH } from '../constants.js';
|
||||
@@ -21,6 +23,18 @@ import { logger } from '../utils/logger.js';
|
||||
import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js';
|
||||
import { loadAccounts, saveAccounts } from '../account-manager/storage.js';
|
||||
|
||||
// Get package version
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
let packageVersion = '1.0.0';
|
||||
try {
|
||||
const packageJsonPath = path.join(__dirname, '../../package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
||||
packageVersion = packageJson.version;
|
||||
} catch (error) {
|
||||
logger.warn('[WebUI] Could not read package.json version, using default');
|
||||
}
|
||||
|
||||
// OAuth state storage (state -> { server, verifier, state, timestamp })
|
||||
// Maps state ID to active OAuth flow data
|
||||
const pendingOAuthFlows = new Map();
|
||||
@@ -254,6 +268,7 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
config: publicConfig,
|
||||
version: packageVersion,
|
||||
note: 'Edit ~/.config/antigravity-proxy/config.json or use env vars to change these values'
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
51
tailwind.config.js
Normal file
51
tailwind.config.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./public/**/*.{html,js}" // Simplified: already covers all subdirectories
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
|
||||
sans: ['Inter', 'system-ui', 'sans-serif']
|
||||
},
|
||||
colors: {
|
||||
space: {
|
||||
950: '#09090b',
|
||||
900: '#0f0f11',
|
||||
850: '#121214',
|
||||
800: '#18181b',
|
||||
border: '#27272a'
|
||||
},
|
||||
neon: {
|
||||
purple: '#a855f7',
|
||||
cyan: '#06b6d4',
|
||||
green: '#22c55e',
|
||||
yellow: '#eab308',
|
||||
red: '#ef4444'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
require('daisyui')
|
||||
],
|
||||
daisyui: {
|
||||
themes: [{
|
||||
antigravity: {
|
||||
"primary": "#a855f7", // neon-purple
|
||||
"secondary": "#22c55e", // neon-green
|
||||
"accent": "#06b6d4", // neon-cyan
|
||||
"neutral": "#18181b", // space-800
|
||||
"base-100": "#09090b", // space-950
|
||||
"info": "#06b6d4", // neon-cyan
|
||||
"success": "#22c55e", // neon-green
|
||||
"warning": "#eab308", // neon-yellow
|
||||
"error": "#ef4444", // neon-red
|
||||
}
|
||||
}],
|
||||
logs: false // Disable console logs in production
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user