feat: scaffold ai gateway identity and design

This commit is contained in:
2026-05-09 16:01:32 +08:00
parent 6323e70e49
commit 5b20f017eb
18 changed files with 3043 additions and 372 deletions
+496 -120
View File
@@ -1,11 +1,14 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import type {
BaseModelCatalogItem,
CatalogProvider,
GatewayTenant,
GatewayUser,
IntegrationPlatform,
PlatformModel,
PricingRule,
RateLimitWindow,
UserGroup,
} from '@easyai-ai-gateway/contracts';
import {
getHealth,
@@ -15,13 +18,88 @@ import {
listPlatforms,
listPricingRules,
listRateLimitWindows,
listTenants,
listUserGroups,
listUsers,
loginLocalAccount,
registerLocalAccount,
type HealthResponse,
} from './api';
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
type AuthMode = 'login' | 'register' | 'external';
const primaryModules = [
{
title: '首页',
path: '/',
description: '服务状态、推荐模型、最近任务、用量摘要和快捷入口。',
items: ['能力概览', '最近任务', '用量摘要'],
},
{
title: '模型',
path: '/models',
description: '按能力、价格、限流和 provider 浏览模型,并进入在线试用。',
items: ['模型广场', '模型详情', '调用测试'],
},
{
title: '用户工作台',
path: '/workspace',
description: '个人中心、身份来源、余额充值、API Key 管理和任务记录。',
items: ['个人总览', '身份来源', '余额充值', 'API Key', '任务记录'],
},
{
title: '管理工作台',
path: '/admin',
description: '租户、用户、用户组、全局模型、平台、限流、重试、队列和回调 outbox。',
items: ['租户管理', '用户管理', '用户组策略', '全局模型', '队列限流'],
},
{
title: 'API 文档',
path: '/docs',
description: '开放接口、鉴权、错误码、示例代码和在线调用测试。',
items: ['快速开始', '接口文档', '在线调试'],
},
];
const workspacePages = [
{ title: '个人中心总览', path: '/workspace/overview', description: '账号、身份来源、租户、角色、用户组、余额、API Key 数、最近任务和用量摘要。' },
{ title: '余额与充值', path: '/workspace/billing', description: '余额、资源包、充值入口、用户组折扣、消费记录和订单状态。' },
{ title: 'API Key 管理', path: '/workspace/api-keys', description: '创建、禁用、重置、权限范围和最近调用记录。' },
{ title: '任务记录', path: '/workspace/tasks', description: 'Chat、生图、生视频任务列表、进度、结果和计费明细。' },
];
const adminPages = [
{ title: '租户管理', path: '/admin/tenants', description: '本地租户、同步租户、租户策略、状态和用量隔离。' },
{ title: '用户管理', path: '/admin/users', description: '本地用户、同步用户、角色、状态、同步差异和用户组命中。' },
{ title: '用户组策略', path: '/admin/user-groups', description: '用户组成员、充值折扣、调用折扣、TPM/RPM/并发和队列优先级。' },
{ title: '全局模型配置', path: '/admin/models/global', description: '基准模型库、能力 schema、基准定价和默认限流模板。' },
{ title: '平台管理', path: '/admin/platforms', description: '平台 CRUD、凭证、默认折扣、平台模型、限流和重试策略。' },
{ title: '运行与队列', path: '/admin/runtime/queues', description: 'TPM/RPM 窗口、并发 lease、cooldown、任务恢复和队列积压。' },
{ title: '回调与结算', path: '/admin/callbacks', description: '任务进度 callback outbox、结算 outbox、失败重试和手动 replay。' },
];
const apiDocPages = [
{ title: '鉴权与限流', path: '/docs/auth', description: '本地账号、JWT、OpenAPI Key、TPM/RPM/并发限制和错误码。' },
{ title: 'Chat / Responses', path: '/docs/api/chat', description: '对话、stream、结构化输出、取消请求和示例代码。' },
{ title: '图片 / 视频', path: '/docs/api/media', description: '生图、图像编辑、生视频、任务进度和结果取回。' },
{ title: '在线调用测试', path: '/docs/playground', description: '选择模型和 API Key,编辑参数,查看实时响应和 billings。' },
];
export function App() {
const [token, setToken] = useState('');
const [externalToken, setExternalToken] = useState('');
const [authMode, setAuthMode] = useState<AuthMode>('login');
const [loginForm, setLoginForm] = useState({ account: '', password: '' });
const [registerForm, setRegisterForm] = useState({
username: '',
email: '',
password: '',
displayName: '',
tenantKey: '',
tenantName: '',
invitationCode: '',
});
const [health, setHealth] = useState<HealthResponse | null>(null);
const [platforms, setPlatforms] = useState<IntegrationPlatform[]>([]);
const [models, setModels] = useState<PlatformModel[]>([]);
@@ -29,6 +107,9 @@ export function App() {
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
const [rateLimitWindows, setRateLimitWindows] = useState<RateLimitWindow[]>([]);
const [tenants, setTenants] = useState<GatewayTenant[]>([]);
const [users, setUsers] = useState<GatewayUser[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
const [state, setState] = useState<LoadState>('idle');
const [error, setError] = useState('');
@@ -44,6 +125,9 @@ export function App() {
const activeProviders = providers.filter((item) => item.status === 'active').length;
const activeRateWindows = rateLimitWindows.filter((item) => item.resetAt >= new Date().toISOString()).length;
return [
{ label: '租户', value: tenants.length, tone: 'cyan' },
{ label: '用户', value: users.length, tone: 'green' },
{ label: '用户组', value: userGroups.length, tone: 'blue' },
{ label: '平台', value: platforms.length, tone: 'blue' },
{ label: '启用平台', value: enabledPlatforms, tone: 'green' },
{ label: '基准模型', value: baseModels.length, tone: 'violet' },
@@ -51,9 +135,9 @@ export function App() {
{ label: '定价规则', value: pricingRules.length, tone: 'cyan' },
{ label: '限流窗口', value: activeRateWindows, tone: 'rose' },
];
}, [baseModels.length, models, platforms, pricingRules.length, providers, rateLimitWindows]);
}, [baseModels.length, models, platforms, pricingRules.length, providers, rateLimitWindows, tenants.length, userGroups.length, users.length]);
async function refresh() {
async function refresh(nextToken = token) {
setState('loading');
setError('');
try {
@@ -64,13 +148,19 @@ export function App() {
baseModelResponse,
pricingRuleResponse,
rateLimitWindowResponse,
tenantResponse,
userResponse,
userGroupResponse,
] = await Promise.all([
listPlatforms(token),
listModels(token),
listCatalogProviders(token),
listBaseModels(token),
listPricingRules(token),
listRateLimitWindows(token),
listPlatforms(nextToken),
listModels(nextToken),
listCatalogProviders(nextToken),
listBaseModels(nextToken),
listPricingRules(nextToken),
listRateLimitWindows(nextToken),
listTenants(nextToken),
listUsers(nextToken),
listUserGroups(nextToken),
]);
setPlatforms(platformResponse.items);
setModels(modelResponse.items);
@@ -78,6 +168,9 @@ export function App() {
setBaseModels(baseModelResponse.items);
setPricingRules(pricingRuleResponse.items);
setRateLimitWindows(rateLimitWindowResponse.items);
setTenants(tenantResponse.items);
setUsers(userResponse.items);
setUserGroups(userGroupResponse.items);
setState('ready');
} catch (err) {
setState('error');
@@ -85,6 +178,59 @@ export function App() {
}
}
async function submitLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setState('loading');
setError('');
try {
const response = await loginLocalAccount(loginForm);
setToken(response.accessToken);
await refresh(response.accessToken);
} catch (err) {
setState('error');
setError(err instanceof Error ? err.message : '登录失败');
}
}
async function submitRegister(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setState('loading');
setError('');
try {
const response = await registerLocalAccount(registerForm);
setToken(response.accessToken);
await refresh(response.accessToken);
} catch (err) {
setState('error');
setError(err instanceof Error ? err.message : '注册失败');
}
}
async function submitExternalToken(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextToken = externalToken.trim();
if (!nextToken) {
setError('请填写 access token');
return;
}
setToken(nextToken);
await refresh(nextToken);
}
function signOut() {
setToken('');
setState('idle');
setPlatforms([]);
setModels([]);
setProviders([]);
setBaseModels([]);
setPricingRules([]);
setRateLimitWindows([]);
setTenants([]);
setUsers([]);
setUserGroups([]);
}
return (
<main className="page">
<header className="topbar">
@@ -92,30 +238,284 @@ export function App() {
<p className="eyebrow">EasyAI</p>
<h1>AI Gateway Console</h1>
</div>
<div className="health" data-ok={health?.ok === true}>
<span />
{health?.service ?? 'API 未连接'}
<div className="topbarActions">
<div className="health" data-ok={health?.ok === true}>
<span />
{health?.identityMode ? `${health.service} · ${health.identityMode}` : health?.service ?? 'API 未连接'}
</div>
{token && (
<button type="button" className="ghostButton" onClick={signOut}>
退
</button>
)}
</div>
</header>
<section className="toolbar" aria-label="授权与刷新">
<label className="tokenField">
<span>Server Main JWT</span>
<input
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder="粘贴 server-main access_token"
{!token ? (
<AuthPanel
authMode={authMode}
externalToken={externalToken}
loginForm={loginForm}
registerForm={registerForm}
state={state}
onAuthModeChange={setAuthMode}
onExternalTokenChange={setExternalToken}
onLoginChange={setLoginForm}
onRegisterChange={setRegisterForm}
onSubmitExternalToken={submitExternalToken}
onSubmitLogin={submitLogin}
onSubmitRegister={submitRegister}
/>
) : (
<>
<section className="toolbar" aria-label="授权与刷新">
<label className="tokenField">
<span>Access Token</span>
<input value={token} onChange={(event) => setToken(event.target.value)} />
</label>
<button type="button" onClick={() => refresh()} disabled={!token || state === 'loading'}>
{state === 'loading' ? '加载中' : '刷新'}
</button>
</section>
<Dashboard
baseModels={baseModels}
models={models}
platforms={platforms}
rateLimitWindows={rateLimitWindows}
stats={stats}
/>
</label>
<button type="button" onClick={refresh} disabled={!token || state === 'loading'}>
{state === 'loading' ? '加载中' : '刷新'}
</button>
</section>
</>
)}
{error && <div className="notice">{error}</div>}
</main>
);
}
function AuthPanel(props: {
authMode: AuthMode;
externalToken: string;
loginForm: { account: string; password: string };
registerForm: {
username: string;
email: string;
password: string;
displayName: string;
tenantKey: string;
tenantName: string;
invitationCode: string;
};
state: LoadState;
onAuthModeChange: (value: AuthMode) => void;
onExternalTokenChange: (value: string) => void;
onLoginChange: (value: { account: string; password: string }) => void;
onRegisterChange: (value: {
username: string;
email: string;
password: string;
displayName: string;
tenantKey: string;
tenantName: string;
invitationCode: string;
}) => void;
onSubmitExternalToken: (event: FormEvent<HTMLFormElement>) => void;
onSubmitLogin: (event: FormEvent<HTMLFormElement>) => void;
onSubmitRegister: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<section className="authShell" aria-label="登录">
<div className="authPanel">
<div className="authHeader">
<p className="eyebrow">Gateway Identity</p>
<h2> AI Gateway</h2>
</div>
<div className="segmented" role="tablist">
{[
['login', '账号登录'],
['register', '注册账号'],
['external', '外部 Token'],
].map(([value, label]) => (
<button
type="button"
className="segmentButton"
data-active={props.authMode === value}
key={value}
onClick={() => props.onAuthModeChange(value as AuthMode)}
>
{label}
</button>
))}
</div>
{props.authMode === 'login' && (
<form className="authForm" onSubmit={props.onSubmitLogin}>
<label>
<span></span>
<input
autoComplete="username"
value={props.loginForm.account}
onChange={(event) => props.onLoginChange({ ...props.loginForm, account: event.target.value })}
placeholder="用户名或邮箱"
/>
</label>
<label>
<span></span>
<input
autoComplete="current-password"
type="password"
value={props.loginForm.password}
onChange={(event) => props.onLoginChange({ ...props.loginForm, password: event.target.value })}
placeholder="至少 8 位"
/>
</label>
<button type="submit" disabled={props.state === 'loading'}>
{props.state === 'loading' ? '登录中' : '登录'}
</button>
</form>
)}
{props.authMode === 'register' && (
<form className="authForm twoColumn" onSubmit={props.onSubmitRegister}>
<label>
<span></span>
<input
autoComplete="username"
value={props.registerForm.username}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, username: event.target.value })}
placeholder="demo"
/>
</label>
<label>
<span></span>
<input
autoComplete="email"
type="email"
value={props.registerForm.email}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, email: event.target.value })}
placeholder="demo@example.com"
/>
</label>
<label>
<span></span>
<input
value={props.registerForm.displayName}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, displayName: event.target.value })}
placeholder="Demo User"
/>
</label>
<label>
<span></span>
<input
autoComplete="new-password"
type="password"
value={props.registerForm.password}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, password: event.target.value })}
placeholder="至少 8 位"
/>
</label>
<label>
<span> Key</span>
<input
value={props.registerForm.tenantKey}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, tenantKey: event.target.value })}
placeholder="team-a"
/>
</label>
<label>
<span></span>
<input
value={props.registerForm.tenantName}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, tenantName: event.target.value })}
placeholder="Team A"
/>
</label>
<label>
<span></span>
<input
value={props.registerForm.invitationCode}
onChange={(event) => props.onRegisterChange({ ...props.registerForm, invitationCode: event.target.value })}
placeholder="可选"
/>
</label>
<button type="submit" disabled={props.state === 'loading'}>
{props.state === 'loading' ? '注册中' : '注册并登录'}
</button>
</form>
)}
{props.authMode === 'external' && (
<form className="authForm" onSubmit={props.onSubmitExternalToken}>
<label>
<span>Access Token</span>
<input
value={props.externalToken}
onChange={(event) => props.onExternalTokenChange(event.target.value)}
placeholder="粘贴 server-main access_token"
/>
</label>
<button type="submit" disabled={props.state === 'loading'}>
{props.state === 'loading' ? '验证中' : '进入控制台'}
</button>
</form>
)}
</div>
</section>
);
}
function Dashboard(props: {
baseModels: BaseModelCatalogItem[];
models: PlatformModel[];
platforms: IntegrationPlatform[];
rateLimitWindows: RateLimitWindow[];
stats: Array<{ label: string; value: number; tone: string }>;
}) {
return (
<>
<section className="moduleBand" aria-label="一级页面">
<div className="sectionHeader">
<div>
<p className="eyebrow">Navigation</p>
<h2></h2>
</div>
<span>5 </span>
</div>
<div className="moduleGrid">
{primaryModules.map((item) => (
<article className="moduleCard" key={item.path}>
<div className="moduleCardTop">
<h3>{item.title}</h3>
<span>{item.path}</span>
</div>
<p>{item.description}</p>
<div className="moduleTags">
{item.items.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
</article>
))}
</div>
</section>
<section className="moduleBand" aria-label="工作台与文档">
<div className="sectionHeader">
<div>
<p className="eyebrow">Workspace</p>
<h2> API </h2>
</div>
<span></span>
</div>
<div className="detailGrid">
<ModuleList title="用户工作台" items={workspacePages} />
<ModuleList title="管理工作台" items={adminPages} />
<ModuleList title="API 文档" items={apiDocPages} />
</div>
</section>
<section className="metrics" aria-label="概览">
{stats.map((item) => (
{props.stats.map((item) => (
<div className="metric" data-tone={item.tone} key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
@@ -124,104 +524,80 @@ export function App() {
</section>
<section className="split">
<div className="panel">
<div className="panelHeader">
<h2></h2>
<span>{platforms.length}</span>
</div>
<div className="table" role="table">
<div className="row head" role="row">
<span>Provider</span>
<span></span>
<span></span>
<span></span>
</div>
{platforms.map((item) => (
<div className="row" role="row" key={item.id}>
<span>{item.provider}</span>
<span>{item.name}</span>
<span>{item.status}</span>
<span>{item.priority}</span>
</div>
))}
{!platforms.length && <p className="empty"></p>}
</div>
</div>
<div className="panel">
<div className="panelHeader">
<h2></h2>
<span>{models.length}</span>
</div>
<div className="table" role="table">
<div className="row head" role="row">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{models.map((item) => (
<div className="row" role="row" key={item.id}>
<span>{item.modelName}</span>
<span>{item.modelType}</span>
<span>{item.provider ?? item.platformName}</span>
<span>{item.enabled ? '是' : '否'}</span>
</div>
))}
{!models.length && <p className="empty"></p>}
</div>
</div>
<DataPanel
columns={['Provider', '名称', '状态', '优先级']}
empty="暂无平台数据"
rows={props.platforms.map((item) => [item.provider, item.name, item.status, String(item.priority)])}
title="平台"
/>
<DataPanel
columns={['模型', '类型', '平台', '启用']}
empty="暂无模型数据"
rows={props.models.map((item) => [item.modelName, item.modelType, item.provider ?? item.platformName ?? '-', item.enabled ? '是' : '否'])}
title="模型"
/>
</section>
<section className="split secondary">
<div className="panel">
<div className="panelHeader">
<h2></h2>
<span>{baseModels.length}</span>
</div>
<div className="table catalogTable" role="table">
<div className="row head" role="row">
<span>Provider</span>
<span></span>
<span></span>
<span></span>
</div>
{baseModels.map((item) => (
<div className="row" role="row" key={item.id}>
<span>{item.providerKey}</span>
<span>{item.canonicalModelKey}</span>
<span>{item.modelType}</span>
<span>{item.pricingVersion}</span>
</div>
))}
{!baseModels.length && <p className="empty"></p>}
</div>
</div>
<div className="panel">
<div className="panelHeader">
<h2>TPM/RPM </h2>
<span>{rateLimitWindows.length}</span>
</div>
<div className="table rateTable" role="table">
<div className="row head" role="row">
<span>Scope</span>
<span></span>
<span>使</span>
<span></span>
</div>
{rateLimitWindows.map((item) => (
<div className="row" role="row" key={`${item.scopeType}:${item.scopeKey}:${item.metric}:${item.windowStart}`}>
<span>{item.scopeKey}</span>
<span>{item.metric}</span>
<span>{item.usedValue}/{item.limitValue}</span>
<span>{item.reservedValue}</span>
</div>
))}
{!rateLimitWindows.length && <p className="empty"></p>}
</div>
</div>
<DataPanel
columns={['Provider', '模型', '类型', '版本']}
empty="暂无基准模型"
rows={props.baseModels.map((item) => [item.providerKey, item.canonicalModelKey, item.modelType, String(item.pricingVersion)])}
title="基准模型库"
/>
<DataPanel
columns={['Scope', '指标', '使用', '预占']}
empty="暂无限流窗口"
rows={props.rateLimitWindows.map((item) => [item.scopeKey, item.metric, `${item.usedValue}/${item.limitValue}`, String(item.reservedValue)])}
title="TPM/RPM 窗口"
/>
</section>
</main>
</>
);
}
function DataPanel(props: { columns: string[]; empty: string; rows: string[][]; title: string }) {
return (
<div className="panel">
<div className="panelHeader">
<h2>{props.title}</h2>
<span>{props.rows.length}</span>
</div>
<div className="table" role="table">
<div className="row head" role="row">
{props.columns.map((column) => (
<span key={column}>{column}</span>
))}
</div>
{props.rows.map((row, index) => (
<div className="row" role="row" key={`${props.title}-${index}`}>
{row.map((cell, cellIndex) => (
<span key={`${props.title}-${index}-${cellIndex}`}>{cell}</span>
))}
</div>
))}
{!props.rows.length && <p className="empty">{props.empty}</p>}
</div>
</div>
);
}
function ModuleList(props: {
title: string;
items: Array<{ title: string; path: string; description: string }>;
}) {
return (
<div className="moduleList">
<h3>{props.title}</h3>
{props.items.map((item) => (
<div className="moduleRow" key={item.path}>
<div>
<strong>{item.title}</strong>
<p>{item.description}</p>
</div>
<span>{item.path}</span>
</div>
))}
</div>
);
}
+50 -1
View File
@@ -1,11 +1,15 @@
import type {
AuthResponse,
BaseModelCatalogItem,
CatalogProvider,
GatewayTenant,
GatewayUser,
IntegrationPlatform,
ListResponse,
PlatformModel,
PricingRule,
RateLimitWindow,
UserGroup,
} from '@easyai-ai-gateway/contracts';
const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088';
@@ -14,12 +18,37 @@ export interface HealthResponse {
ok: boolean;
service: string;
env: string;
identityMode?: string;
}
export async function getHealth(): Promise<HealthResponse> {
return request<HealthResponse>('/healthz', { auth: false });
}
export async function registerLocalAccount(input: {
username: string;
email?: string;
password: string;
displayName?: string;
tenantKey?: string;
tenantName?: string;
invitationCode?: string;
}): Promise<AuthResponse> {
return request<AuthResponse>('/api/v1/auth/register', {
auth: false,
body: input,
method: 'POST',
});
}
export async function loginLocalAccount(input: { account: string; password: string }): Promise<AuthResponse> {
return request<AuthResponse>('/api/v1/auth/login', {
auth: false,
body: input,
method: 'POST',
});
}
export async function listPlatforms(token: string): Promise<ListResponse<IntegrationPlatform>> {
return request<ListResponse<IntegrationPlatform>>('/api/v1/platforms', { token });
}
@@ -40,17 +69,37 @@ export async function listPricingRules(token: string): Promise<ListResponse<Pric
return request<ListResponse<PricingRule>>('/api/v1/pricing/rules', { token });
}
export async function listTenants(token: string): Promise<ListResponse<GatewayTenant>> {
return request<ListResponse<GatewayTenant>>('/api/v1/tenants', { token });
}
export async function listUsers(token: string): Promise<ListResponse<GatewayUser>> {
return request<ListResponse<GatewayUser>>('/api/v1/users', { token });
}
export async function listUserGroups(token: string): Promise<ListResponse<UserGroup>> {
return request<ListResponse<UserGroup>>('/api/v1/user-groups', { token });
}
export async function listRateLimitWindows(token: string): Promise<ListResponse<RateLimitWindow>> {
return request<ListResponse<RateLimitWindow>>('/api/v1/runtime/rate-limit-windows', { token });
}
async function request<T>(path: string, options: { token?: string; auth?: boolean } = {}): Promise<T> {
async function request<T>(
path: string,
options: { token?: string; auth?: boolean; method?: string; body?: unknown } = {},
): Promise<T> {
const headers: Record<string, string> = {};
if (options.auth !== false && options.token) {
headers.Authorization = `Bearer ${options.token}`;
}
if (options.body !== undefined) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(`${API_BASE}${path}`, {
method: options.method ?? 'GET',
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
if (!response.ok) {
const body = await response.text();
+219 -4
View File
@@ -35,6 +35,12 @@ input {
margin-bottom: 24px;
}
.topbarActions {
display: flex;
align-items: center;
gap: 12px;
}
.eyebrow {
margin: 0 0 4px;
color: #667085;
@@ -58,6 +64,11 @@ h2 {
font-size: 18px;
}
h3 {
margin: 0;
font-size: 15px;
}
.health {
display: inline-flex;
align-items: center;
@@ -123,11 +134,98 @@ button {
cursor: pointer;
}
.ghostButton {
min-height: 36px;
padding: 0 14px;
border: 1px solid #cbd5e1;
background: #ffffff;
color: #2d3748;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.authShell {
display: grid;
min-height: 620px;
align-items: center;
}
.authPanel {
width: min(720px, 100%);
margin: 0 auto;
padding: 20px;
border: 1px solid #dde3ee;
border-radius: 8px;
background: #ffffff;
}
.authHeader {
margin-bottom: 16px;
}
.segmented {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 4px;
margin-bottom: 16px;
border: 1px solid #d8e0ec;
border-radius: 8px;
background: #f8fafc;
}
.segmentButton {
min-height: 36px;
padding: 0 10px;
border-radius: 6px;
background: transparent;
color: #4a5568;
}
.segmentButton[data-active="true"] {
background: #214e8a;
color: #ffffff;
}
.authForm {
display: grid;
gap: 12px;
}
.authForm.twoColumn {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.authForm label {
display: grid;
gap: 8px;
color: #4a5568;
font-size: 13px;
font-weight: 700;
}
.authForm input {
width: 100%;
min-height: 42px;
padding: 0 12px;
border: 1px solid #cbd5e1;
border-radius: 6px;
color: #172033;
outline: none;
}
.authForm input:focus {
border-color: #2b6cb0;
box-shadow: 0 0 0 3px rgba(43, 108, 176, 0.14);
}
.authForm button[type="submit"] {
grid-column: 1 / -1;
}
.notice {
padding: 12px 14px;
margin-bottom: 18px;
@@ -138,9 +236,112 @@ button:disabled {
font-size: 14px;
}
.moduleBand {
padding: 18px;
margin-bottom: 18px;
border: 1px solid #dde3ee;
border-radius: 8px;
background: #ffffff;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.sectionHeader span {
color: #667085;
font-size: 13px;
font-weight: 700;
}
.moduleGrid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.moduleCard {
min-height: 174px;
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border: 1px solid #e4eaf3;
border-radius: 8px;
background: #fbfcff;
}
.moduleCardTop {
display: grid;
gap: 6px;
}
.moduleCardTop span,
.moduleRow span {
color: #667085;
font-size: 12px;
font-weight: 700;
}
.moduleCard p,
.moduleRow p {
color: #4a5568;
font-size: 13px;
line-height: 1.5;
}
.moduleTags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: auto;
}
.moduleTags span {
padding: 4px 7px;
border: 1px solid #d8e0ec;
border-radius: 999px;
background: #ffffff;
color: #3f4f67;
font-size: 12px;
font-weight: 700;
}
.detailGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.moduleList {
display: grid;
gap: 10px;
}
.moduleRow {
display: grid;
gap: 8px;
min-height: 112px;
padding: 12px;
border: 1px solid #e4eaf3;
border-radius: 8px;
background: #fbfcff;
}
.moduleRow strong {
display: block;
margin-bottom: 4px;
color: #172033;
font-size: 14px;
}
.metrics {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(126px, 1fr));
gap: 12px;
margin-bottom: 18px;
}
@@ -259,7 +460,9 @@ button:disabled {
@media (max-width: 860px) {
.topbar,
.toolbar {
.toolbar,
.authForm.twoColumn,
.segmented {
grid-template-columns: 1fr;
}
@@ -268,8 +471,15 @@ button:disabled {
flex-direction: column;
}
.topbarActions {
align-items: flex-start;
flex-direction: column;
}
.metrics,
.split {
.split,
.moduleGrid,
.detailGrid {
grid-template-columns: 1fr;
}
@@ -280,7 +490,12 @@ button:disabled {
}
@media (min-width: 861px) and (max-width: 1180px) {
.metrics {
.metrics,
.moduleGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.detailGrid {
grid-template-columns: 1fr;
}
}