1. Add 登陆功能
2. 调整字体大小 3. 新增部分功能
This commit is contained in:
@@ -5,6 +5,12 @@ import { NewAnalysisModal } from './NewAnalysisModal';
|
||||
import { useComplianceAnalysis } from './useComplianceAnalysis';
|
||||
import type { FindingEvent, SourceEvent, AnalysisMeta } from './useComplianceAnalysis';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = { ok: 'Covered', warn: 'Gap', risk: 'Critical', info: 'Info' };
|
||||
const SOURCE_TYPE_LABEL: Record<string, string> = { text: 'Pasted Text', doc: 'Indexed Document', upload: 'Uploaded File' };
|
||||
|
||||
@@ -71,7 +77,7 @@ function useFindingChat() {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/compliance/chat/${findingIdx ?? 0}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({ query: q, segment_context: segmentContext }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { X, Upload, FileText, Database } from 'lucide-react';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
interface DocOption {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -30,7 +36,7 @@ export function NewAnalysisModal({ onClose, onSubmit }: Props) {
|
||||
|
||||
// Fetch indexed docs for "From Document" tab
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/documents/management-list')
|
||||
fetch('/api/v1/documents/management-list', { headers: authHeader() })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const list: DocOption[] = (d?.documents ?? d ?? []).map((item: Record<string, unknown>) => ({
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
export type AnalysisStatus = 'idle' | 'streaming' | 'done' | 'error';
|
||||
|
||||
export interface SourceEvent {
|
||||
@@ -78,6 +84,7 @@ export function useComplianceAnalysis() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/compliance/analyze-stream', {
|
||||
method: 'POST',
|
||||
headers: authHeader(),
|
||||
body: formData,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,12 @@ import { Topbar } from '../../components/layout/Topbar';
|
||||
import { Upload, Search, Download, Trash2, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||
import { UploadModal } from './UploadModal';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
interface Doc {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -79,7 +85,7 @@ export function DocsPage() {
|
||||
|
||||
const fetchDocs = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch('/api/v1/documents/management-list')
|
||||
fetch('/api/v1/documents/management-list', { headers: authHeader() })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (!Array.isArray(d?.documents)) { setLoading(false); return; }
|
||||
@@ -132,7 +138,7 @@ export function DocsPage() {
|
||||
async function retryDoc(id: string) {
|
||||
setRetrying(r => new Set([...r, id]));
|
||||
try {
|
||||
await fetch(`/api/v1/documents/${id}/retry`, { method: 'POST' });
|
||||
await fetch(`/api/v1/documents/${id}/retry`, { method: 'POST', headers: authHeader() });
|
||||
setTimeout(() => {
|
||||
setRetrying(r => { const s = new Set(r); s.delete(id); return s; });
|
||||
setRefreshKey(k => k + 1);
|
||||
@@ -155,7 +161,7 @@ export function DocsPage() {
|
||||
setDeleting(new Set(ids));
|
||||
|
||||
await Promise.allSettled(
|
||||
ids.map(id => fetch(`/api/v1/documents/${id}`, { method: 'DELETE' }))
|
||||
ids.map(id => fetch(`/api/v1/documents/${id}`, { method: 'DELETE', headers: authHeader() }))
|
||||
);
|
||||
|
||||
setDeleting(new Set());
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { X, Upload } from 'lucide-react';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onComplete?: () => void; // called when all uploads finish (indexed)
|
||||
@@ -44,11 +50,6 @@ function docStatusToStages(status: DocStatus): StageState[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a short unique ID client-side (matches backend's 8-char uuid prefix pattern)
|
||||
function genDocId(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
export function UploadModal({ onClose, onComplete }: Props) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [regType, setRegType] = useState(REG_TYPES[0]);
|
||||
@@ -93,13 +94,14 @@ export function UploadModal({ onClose, onComplete }: Props) {
|
||||
|
||||
const pollStatus = useCallback((docId: string, resolve: () => void, reject: (msg: string) => void) => {
|
||||
let attempts = 0;
|
||||
const MAX_ATTEMPTS = 120; // 4 minutes at 2s interval
|
||||
const MAX_ATTEMPTS = 450; // 15 minutes at 2s interval — Aliyun DocMind can take several minutes
|
||||
stopPolling();
|
||||
pollTimer.current = setInterval(async () => {
|
||||
attempts++;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/documents/status/${docId}`);
|
||||
const res = await fetch(`/api/v1/documents/status/${docId}`, { headers: authHeader() });
|
||||
if (!res.ok) {
|
||||
// Transient HTTP error (e.g. 502 during restart) — keep polling until timeout.
|
||||
if (attempts > MAX_ATTEMPTS) { stopPolling(); reject('Polling timeout'); }
|
||||
return;
|
||||
}
|
||||
@@ -114,7 +116,7 @@ export function UploadModal({ onClose, onComplete }: Props) {
|
||||
reject(data.message ?? 'Processing failed');
|
||||
} else if (attempts > MAX_ATTEMPTS) {
|
||||
stopPolling();
|
||||
reject('Processing timeout — check Document Management for status');
|
||||
reject('Processing timeout (15 min) — check Document Management for status');
|
||||
}
|
||||
} catch {
|
||||
// network hiccup — keep polling
|
||||
@@ -127,37 +129,42 @@ export function UploadModal({ onClose, onComplete }: Props) {
|
||||
setCurrentFileIdx(idx);
|
||||
setDocStatus('idle');
|
||||
|
||||
const docId = genDocId();
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('doc_id', docId);
|
||||
form.append('doc_name', file.name);
|
||||
form.append('regulation_type', regType);
|
||||
if (version) form.append('version', version);
|
||||
form.append('generate_summary', 'false');
|
||||
|
||||
// Fire upload — this is a long-running synchronous call on the backend.
|
||||
// We start polling immediately so the UI updates as the backend writes status transitions.
|
||||
const uploadPromise = fetch('/api/v1/documents/upload', { method: 'POST', body: form });
|
||||
// Upload the file first — response contains the authoritative doc_id.
|
||||
// Without waiting here we risk polling an ID the server has not yet created.
|
||||
let docId: string;
|
||||
const uploadRes = await fetch('/api/v1/documents/upload', {
|
||||
method: 'POST',
|
||||
headers: authHeader(),
|
||||
body: form,
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
const detail = await uploadRes.text().catch(() => uploadRes.statusText);
|
||||
throw new Error(`${file.name}: ${uploadRes.status} ${detail}`);
|
||||
}
|
||||
const uploadData = await uploadRes.json() as { doc_id: string; status: string };
|
||||
docId = uploadData.doc_id;
|
||||
|
||||
// Start polling after a short delay so the backend has time to create the document record
|
||||
// If backend processed synchronously (sync=true or status already 'indexed'), resolve immediately.
|
||||
if (uploadData.status === 'indexed') {
|
||||
setDocStatus('indexed');
|
||||
return;
|
||||
}
|
||||
if (uploadData.status === 'failed') {
|
||||
setDocStatus('failed');
|
||||
throw new Error(`${file.name}: Processing failed on server`);
|
||||
}
|
||||
|
||||
// Otherwise start polling the authoritative doc_id returned by the server.
|
||||
setDocStatus(uploadData.status as DocStatus);
|
||||
await new Promise<void>((res, rej) => {
|
||||
const reject = (msg: string) => rej(new Error(msg));
|
||||
// Begin polling immediately — backend creates the record synchronously before processing
|
||||
setTimeout(() => pollStatus(docId, res, reject), 800);
|
||||
|
||||
// Also handle the upload response (in case processing finishes before poll catches it)
|
||||
uploadPromise.then(async httpRes => {
|
||||
if (!httpRes.ok) {
|
||||
const detail = await httpRes.text().catch(() => httpRes.statusText);
|
||||
stopPolling();
|
||||
reject(`${file.name}: ${httpRes.status} ${detail}`);
|
||||
}
|
||||
// Upload succeeded — polling will catch the final status
|
||||
}).catch(err => {
|
||||
stopPolling();
|
||||
reject(err instanceof Error ? err.message : 'Upload error');
|
||||
});
|
||||
pollStatus(docId, res, (msg: string) => rej(new Error(msg)));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { FormEvent, useState } from 'react';
|
||||
import { useAuth } from '../../contexts';
|
||||
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!username.trim() || !password.trim()) return;
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username.trim(), password);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-brand">
|
||||
<img src="/company-logo.ico" alt="T-Systems" className="login-logo" />
|
||||
<div className="login-brand-text">
|
||||
<div className="login-brand-name">T-Systems</div>
|
||||
<div className="login-brand-sub">AI Regulation Hub</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="login-title">Sign in</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<div className="login-field">
|
||||
<label className="login-label" htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
className="login-input"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
disabled={loading}
|
||||
placeholder="e.g. admin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="login-field">
|
||||
<label className="login-label" htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="login-input"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
|
||||
<button type="submit" className="login-btn" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="login-hint">
|
||||
Demo accounts: <code>admin</code> / <code>legal</code> / <code>ehs</code> / <code>readonly</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,8 @@ const STEPS = [
|
||||
export function OverviewPage() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="overview-page">
|
||||
<div className="overview-scroll-wrapper">
|
||||
<div className="overview-page">
|
||||
<section className="overview-hero">
|
||||
<p className="hero-eyebrow">T-Systems · AI Regulation Hub</p>
|
||||
<h1 className="hero-title">AI Compliance,<br />Automated end-to-end</h1>
|
||||
@@ -83,5 +84,6 @@ export function OverviewPage() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { RefreshCw, Play, Square, ExternalLink } from 'lucide-react';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
interface Signal {
|
||||
id: string;
|
||||
source: string;
|
||||
@@ -101,14 +107,14 @@ export function PerceptionPage() {
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/perception/stats')
|
||||
fetch('/api/v1/perception/stats', { headers: authHeader() })
|
||||
.then(r => r.json())
|
||||
.then(setStats)
|
||||
.catch(() => setStats({ total: 47, high_impact: 7, medium_impact: 18, last_90_days: 14 }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/perception/events?limit=100')
|
||||
fetch('/api/v1/perception/events?limit=100', { headers: authHeader() })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (Array.isArray(d?.events) && d.events.length > 0) {
|
||||
@@ -135,7 +141,7 @@ export function PerceptionPage() {
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
// Backend: POST /api/v1/perception/events/{id}/analyze → SSE stream
|
||||
fetch(`/api/v1/perception/events/${selected.id}/analyze`, { method: 'POST', signal: ctrl.signal })
|
||||
fetch(`/api/v1/perception/events/${selected.id}/analyze`, { method: 'POST', headers: authHeader(), signal: ctrl.signal })
|
||||
.then(async res => {
|
||||
if (!res.body) { setAiOutput('No stream available.'); setStreaming(false); return; }
|
||||
const reader = res.body.getReader();
|
||||
|
||||
@@ -2,6 +2,12 @@ import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { Send, Download } from 'lucide-react';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
@@ -87,7 +93,7 @@ export function RagChatPage() {
|
||||
|
||||
// Fetch quick questions from backend on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/rag/quick-questions')
|
||||
fetch('/api/v1/rag/quick-questions', { headers: authHeader() })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (Array.isArray(d?.questions) && d.questions.length > 0) {
|
||||
@@ -136,7 +142,7 @@ export function RagChatPage() {
|
||||
|
||||
const res = await fetch('/api/v1/rag/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify(body),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,12 @@ import { Topbar } from '../../components/layout/Topbar';
|
||||
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react';
|
||||
import { UploadModal } from '../Docs/UploadModal';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
// ── API types ──────────────────────────────────────────────────────────────
|
||||
interface Stats {
|
||||
documents_total: number;
|
||||
@@ -83,9 +89,9 @@ export function StatusPage() {
|
||||
|
||||
// Fetch all three endpoints in parallel
|
||||
Promise.allSettled([
|
||||
fetch('/api/v1/status/stats').then(r => r.json()),
|
||||
fetch('/api/v1/status/health').then(r => r.json()),
|
||||
fetch('/api/v1/status/config').then(r => r.json()),
|
||||
fetch('/api/v1/status/stats', { headers: authHeader() }).then(r => r.json()),
|
||||
fetch('/api/v1/status/health', { headers: authHeader() }).then(r => r.json()),
|
||||
fetch('/api/v1/status/config', { headers: authHeader() }).then(r => r.json()),
|
||||
]).then(([statsRes, healthRes, configRes]) => {
|
||||
if (statsRes.status === 'fulfilled') setStats(statsRes.value);
|
||||
else setStats({ documents_total: 0, documents_indexed: 0, documents_failed: 0, chunks_total: 0 });
|
||||
|
||||
Reference in New Issue
Block a user