1928 lines
64 KiB
Markdown
1928 lines
64 KiB
Markdown
# Frontend Internationalisation (i18n) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add a zero-dependency EN ↔ 中 language toggle to the Sidebar (left of the existing theme button) that switches all UI framework strings across every page at runtime, defaulting to English on every load.
|
||
|
||
**Architecture:** Custom `LanguageContext` (mirrors `ThemeContext` pattern) exposes `{ lang, t, toggleLang }`. Two TypeScript locale files (`locales/en.ts`, `locales/zh.ts`) export the same `Translations` shape. Pages call `useLanguage()` and replace hardcoded strings with `t.section.key`. No external libraries added.
|
||
|
||
**Tech Stack:** React 19, TypeScript, Vite — all already in the project.
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
| File | Action |
|
||
|------|--------|
|
||
| `frontend/src/locales/en.ts` | **Create** — full English `Translations` object |
|
||
| `frontend/src/locales/zh.ts` | **Create** — full Chinese `Translations` object |
|
||
| `frontend/src/contexts/LanguageContext.tsx` | **Create** — `LanguageProvider`, `useLanguage()` |
|
||
| `frontend/src/contexts/index.ts` | **Modify** — export `LanguageProvider`, `useLanguage`, `Translations` |
|
||
| `frontend/src/App.tsx` | **Modify** — wrap with `<LanguageProvider>` |
|
||
| `frontend/src/components/layout/Sidebar.tsx` | **Modify** — lang toggle button + translated nav labels |
|
||
| `frontend/src/pages/Overview/OverviewPage.tsx` | **Modify** — `t.overview.*` |
|
||
| `frontend/src/pages/Perception/PerceptionPage.tsx` | **Modify** — `t.signals.*` |
|
||
| `frontend/src/pages/Status/StatusPage.tsx` | **Modify** — `t.status.*` |
|
||
| `frontend/src/pages/Docs/DocsPage.tsx` | **Modify** — `t.docs.*` |
|
||
| `frontend/src/pages/Compliance/CompliancePage.tsx` | **Modify** — `t.compliance.*` |
|
||
| `frontend/src/pages/Compliance/HistoryRail.tsx` | **Modify** — `t.compliance.*` |
|
||
| `frontend/src/pages/Compliance/FindingChatDrawer.tsx` | **Modify** — `t.compliance.*` |
|
||
| `frontend/src/pages/RagChat/RagChatPage.tsx` | **Modify** — `t.ragchat.*` |
|
||
|
||
---
|
||
|
||
## Task 1: Create locale files (`en.ts` and `zh.ts`)
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/locales/en.ts`
|
||
- Create: `frontend/src/locales/zh.ts`
|
||
|
||
- [ ] **Step 1: Create `frontend/src/locales/en.ts`**
|
||
|
||
```ts
|
||
// English translations — default language
|
||
export interface Translations {
|
||
nav: {
|
||
groupMain: string;
|
||
groupWorkbench: string;
|
||
groupChat: string;
|
||
overview: string;
|
||
signals: string;
|
||
status: string;
|
||
documents: string;
|
||
compliance: string;
|
||
chat: string;
|
||
};
|
||
sidebar: {
|
||
toggleTheme: string;
|
||
toggleLang: string;
|
||
signOut: string;
|
||
};
|
||
overview: {
|
||
eyebrow: string;
|
||
heroTitle: string;
|
||
heroDesc: string;
|
||
openDashboard: string;
|
||
jumpToChat: string;
|
||
sectionHowItWorks: string;
|
||
sectionScreens: string;
|
||
statScreens: string;
|
||
statFlows: string;
|
||
statReviewPosture: string;
|
||
stepUpload: string; stepUploadDesc: string;
|
||
stepProcess: string; stepProcessDesc: string;
|
||
stepMonitor: string; stepMonitorDesc: string;
|
||
stepAnalyze: string; stepAnalyzeDesc: string;
|
||
stepReview: string; stepReviewDesc: string;
|
||
stepChat: string; stepChatDesc: string;
|
||
screenStatus: string; screenStatusDesc: string;
|
||
screenSignals: string; screenSignalsDesc: string;
|
||
screenDocuments: string; screenDocumentsDesc: string;
|
||
screenCompliance: string; screenComplianceDesc: string;
|
||
screenChat: string; screenChatDesc: string;
|
||
screenAnalytics: string; screenAnalyticsDesc: string;
|
||
};
|
||
signals: {
|
||
topbarTitle: string;
|
||
topbarSub: string;
|
||
searchPlaceholder: string;
|
||
refreshBtn: string;
|
||
crawlingBtn: string;
|
||
statTotal: string;
|
||
statHigh: string;
|
||
statMedium: string;
|
||
statLast90: string;
|
||
badgeFinal: string;
|
||
badgeDraft: string;
|
||
badgeUrgent: string;
|
||
badgePublished: string;
|
||
emptySelectSignal: string;
|
||
runAnalysis: string;
|
||
stopBtn: string;
|
||
sourceLink: string;
|
||
tabOverview: string;
|
||
tabObligations: string;
|
||
tabImpact: string;
|
||
tabChanges: string;
|
||
cardScopeHeader: string;
|
||
cardObligationsHeader: string;
|
||
obligationsEmpty: string;
|
||
colObligationDesc: string;
|
||
colSubject: string;
|
||
colType: string;
|
||
colDeadline: string;
|
||
deadlinePending: string;
|
||
cardAffectedDocs: string;
|
||
noAffectedDocs: string;
|
||
cardAIImpact: string;
|
||
footerText: string;
|
||
statusConnecting: string;
|
||
statusNoStream: string;
|
||
statusCrawling: string;
|
||
statusProcessing: string;
|
||
statusComplete: string;
|
||
statusUpdateComplete: string;
|
||
statusError: string;
|
||
statusConnFailed: string;
|
||
diffOld: string;
|
||
diffNew: string;
|
||
diffCardHeader: string;
|
||
};
|
||
status: {
|
||
topbarTitle: string;
|
||
searchPlaceholder: string;
|
||
exportBtn: string;
|
||
refreshBtn: string;
|
||
newUploadBtn: string;
|
||
statTotal: string;
|
||
statIndexed: string;
|
||
statFailed: string;
|
||
statChunks: string;
|
||
statCoverage: string;
|
||
cardHealth: string;
|
||
badgeOnline: string;
|
||
badgeError: string;
|
||
badgeDegraded: string;
|
||
badgeUnknown: string;
|
||
healthEndpointError: string;
|
||
serviceEnabled: string;
|
||
serviceDisabled: string;
|
||
serviceNotLoaded: string;
|
||
cardConfig: string;
|
||
labelLLMProvider: string;
|
||
labelLLMModel: string;
|
||
labelEmbeddingModel: string;
|
||
labelEmbeddingDim: string;
|
||
labelMilvusCollection: string;
|
||
labelParserBackend: string;
|
||
labelChunkBackend: string;
|
||
labelParserFailureMode: string;
|
||
configLoadError: string;
|
||
cardBreakdown: string;
|
||
breakdownIndexed: string;
|
||
breakdownProcessing: string;
|
||
breakdownFailed: string;
|
||
cardRuntime: string;
|
||
labelActiveSessions: string;
|
||
labelSessionCapacity: string;
|
||
labelReranker: string;
|
||
labelBM25: string;
|
||
statusActive: string;
|
||
statusUnavailable: string;
|
||
footerAllOk: string;
|
||
footerDegraded: string;
|
||
footerChecking: string;
|
||
totalChunks: string;
|
||
};
|
||
docs: {
|
||
topbarTitle: string;
|
||
searchPlaceholder: string;
|
||
refreshBtn: string;
|
||
uploadBtn: string;
|
||
confirmDeleteTitle: string;
|
||
cancelBtn: string;
|
||
deleteBtn: string;
|
||
filterAll: string;
|
||
filterReady: string;
|
||
filterProcessing: string;
|
||
filterFailed: string;
|
||
filterPending: string;
|
||
filterAllTypes: string;
|
||
deleteSelected: string;
|
||
colName: string;
|
||
colStatus: string;
|
||
colUploaded: string;
|
||
colChunks: string;
|
||
colSize: string;
|
||
colType: string;
|
||
colActions: string;
|
||
loading: string;
|
||
emptyNoDocuments: string;
|
||
emptyNoMatch: string;
|
||
titleDownload: string;
|
||
titleRetry: string;
|
||
titleDelete: string;
|
||
};
|
||
compliance: {
|
||
topbarTitle: string;
|
||
searchPlaceholder: string;
|
||
clearBtn: string;
|
||
exportBtn: string;
|
||
exportJSON: string;
|
||
exportText: string;
|
||
newAnalysisBtn: string;
|
||
statusAnalyzing: string;
|
||
statusComplete: string;
|
||
statusError: string;
|
||
emptyTitle: string;
|
||
emptyDesc: string;
|
||
retrievingMsg: string;
|
||
defaultRegulation: string;
|
||
matchSuffix: string;
|
||
colParagraph: string;
|
||
extractingMsg: string;
|
||
noTextExtracted: string;
|
||
stagesHeader: string;
|
||
stageExtraction: string;
|
||
stageClauseSplit: string;
|
||
stageRetrieval: string;
|
||
stageSynthesis: string;
|
||
gapInProgress: string;
|
||
askAIBtn: string;
|
||
chatBtn: string;
|
||
conclusionHeader: string;
|
||
riskScoreTooltip: string;
|
||
statusCovered: string;
|
||
statusGap: string;
|
||
statusCritical: string;
|
||
statusInfo: string;
|
||
sourceTypePasted: string;
|
||
sourceTypeIndexed: string;
|
||
sourceTypeUploaded: string;
|
||
chatSidebarHeader: string;
|
||
chatThinking: string;
|
||
quickQ1: string;
|
||
quickQ2: string;
|
||
quickQ3: string;
|
||
chatPlaceholder: string;
|
||
sendBtn: string;
|
||
analysisFailed: string;
|
||
exportReportHeader: string;
|
||
exportSectionParagraph: string;
|
||
exportSectionFindings: string;
|
||
exportSectionConclusion: string;
|
||
exportSectionActions: string;
|
||
historyHeader: string;
|
||
downloadReport: string;
|
||
historyEmpty: string;
|
||
historyDeleteConfirm: string;
|
||
drawerClose: string;
|
||
drawerChatEmpty: string;
|
||
drawerSuggestionsHeader: string;
|
||
};
|
||
ragchat: {
|
||
topbarTitle: string;
|
||
exportBtn: string;
|
||
quickPromptsHeader: string;
|
||
inputPlaceholder: string;
|
||
citationsHeader: string;
|
||
citationsEmpty: string;
|
||
apiError: string;
|
||
};
|
||
}
|
||
|
||
export const en: Translations = {
|
||
nav: {
|
||
groupMain: 'Main',
|
||
groupWorkbench: 'Workbench',
|
||
groupChat: 'Chat',
|
||
overview: 'Overview',
|
||
signals: 'Regulatory Signals',
|
||
status: 'System Status',
|
||
documents: 'Documents',
|
||
compliance: 'Compliance Analysis',
|
||
chat: 'Regulation Q&A',
|
||
},
|
||
sidebar: {
|
||
toggleTheme: 'Toggle theme',
|
||
toggleLang: 'Switch language',
|
||
signOut: 'Sign out',
|
||
},
|
||
overview: {
|
||
eyebrow: 'T-Systems · AI Regulation Hub',
|
||
heroTitle: 'AI Compliance,\nAutomated end-to-end',
|
||
heroDesc: 'Monitor global AI regulations, analyze document compliance gaps, and get cited answers — all in one platform.',
|
||
openDashboard: 'Open dashboard',
|
||
jumpToChat: 'Jump to regulation chat',
|
||
sectionHowItWorks: 'How it works',
|
||
sectionScreens: 'Screens',
|
||
statScreens: 'Screens',
|
||
statFlows: 'Backend-aware flows',
|
||
statReviewPosture: 'Review posture',
|
||
stepUpload: 'Upload', stepUploadDesc: 'Ingest regulation documents',
|
||
stepProcess: 'Process', stepProcessDesc: 'Embed and chunk via vector DB',
|
||
stepMonitor: 'Monitor', stepMonitorDesc: 'Watch regulatory signal feed',
|
||
stepAnalyze: 'Analyze', stepAnalyzeDesc: 'Run compliance gap analysis',
|
||
stepReview: 'Review', stepReviewDesc: 'Inspect findings with AI assist',
|
||
stepChat: 'Chat', stepChatDesc: 'Ask questions with cited answers',
|
||
screenStatus: 'System Status', screenStatusDesc: 'Live health and workflow queue',
|
||
screenSignals: 'Regulatory Signals', screenSignalsDesc: 'AI-detected regulatory changes',
|
||
screenDocuments: 'Document Management', screenDocumentsDesc: 'Upload and inspect documents',
|
||
screenCompliance: 'Compliance Analysis', screenComplianceDesc: 'Three-column compliance workspace',
|
||
screenChat: 'Regulation Q&A', screenChatDesc: 'Chat with cited regulation sources',
|
||
screenAnalytics: 'Analytics', screenAnalyticsDesc: 'KPIs and coverage metrics',
|
||
},
|
||
signals: {
|
||
topbarTitle: 'Regulatory Signals',
|
||
topbarSub: 'ai-powered · live feed',
|
||
searchPlaceholder: 'Search signals...',
|
||
refreshBtn: 'Refresh Sources',
|
||
crawlingBtn: 'Crawling...',
|
||
statTotal: 'Total signals',
|
||
statHigh: 'High impact',
|
||
statMedium: 'Medium impact',
|
||
statLast90: 'Last 90 days',
|
||
badgeFinal: 'Final',
|
||
badgeDraft: 'Draft',
|
||
badgeUrgent: 'Urgent',
|
||
badgePublished: 'Published',
|
||
emptySelectSignal: 'Select a signal to run impact analysis',
|
||
runAnalysis: 'Run impact analysis',
|
||
stopBtn: 'Stop',
|
||
sourceLink: 'Source',
|
||
tabOverview: 'Overview',
|
||
tabObligations: 'Obligations',
|
||
tabImpact: 'Impact Assessment',
|
||
tabChanges: 'Change Comparison',
|
||
cardScopeHeader: 'Scope & Summary',
|
||
cardObligationsHeader: 'Obligations',
|
||
obligationsEmpty: 'No structured data yet. Click "Run impact analysis" to extract.',
|
||
colObligationDesc: 'Obligation',
|
||
colSubject: 'Subject',
|
||
colType: 'Type',
|
||
colDeadline: 'Deadlines',
|
||
deadlinePending: 'Pending',
|
||
cardAffectedDocs: 'Affected documents',
|
||
noAffectedDocs: 'No affected documents found.',
|
||
cardAIImpact: 'AI Impact Analysis',
|
||
footerText: 'Live feed · Regulation Hub',
|
||
statusConnecting: 'Connecting to data sources...',
|
||
statusNoStream: 'No stream',
|
||
statusCrawling: 'Crawling...',
|
||
statusProcessing: 'Processing {count} items...',
|
||
statusComplete: 'Done +{count} items',
|
||
statusUpdateComplete: 'Update complete — {new} added, {updated} updated',
|
||
statusError: 'Error: {message}',
|
||
statusConnFailed: 'Connection failed: {message}',
|
||
diffOld: 'Previous',
|
||
diffNew: 'Current',
|
||
diffCardHeader: 'Change Comparison',
|
||
},
|
||
status: {
|
||
topbarTitle: 'System Status',
|
||
searchPlaceholder: 'Search...',
|
||
exportBtn: 'Export',
|
||
refreshBtn: 'Refresh',
|
||
newUploadBtn: 'New upload',
|
||
statTotal: 'Documents total',
|
||
statIndexed: 'Indexed',
|
||
statFailed: 'Failed',
|
||
statChunks: 'Vector chunks',
|
||
statCoverage: 'Index coverage',
|
||
cardHealth: 'System health',
|
||
badgeOnline: 'Online',
|
||
badgeError: 'Error',
|
||
badgeDegraded: 'Degraded',
|
||
badgeUnknown: 'Unknown',
|
||
healthEndpointError: 'Could not reach health endpoint',
|
||
serviceEnabled: 'Enabled',
|
||
serviceDisabled: 'Disabled',
|
||
serviceNotLoaded: 'Not loaded',
|
||
cardConfig: 'System configuration',
|
||
labelLLMProvider: 'LLM provider',
|
||
labelLLMModel: 'LLM model',
|
||
labelEmbeddingModel: 'Embedding model',
|
||
labelEmbeddingDim: 'Embedding dim',
|
||
labelMilvusCollection: 'Milvus collection',
|
||
labelParserBackend: 'Parser backend',
|
||
labelChunkBackend: 'Chunk backend',
|
||
labelParserFailureMode: 'Parser failure mode',
|
||
configLoadError: 'Could not load config',
|
||
cardBreakdown: 'Document breakdown',
|
||
breakdownIndexed: 'Indexed',
|
||
breakdownProcessing: 'Processing / Parsed',
|
||
breakdownFailed: 'Failed',
|
||
cardRuntime: 'Runtime info',
|
||
labelActiveSessions: 'Active chat sessions',
|
||
labelSessionCapacity: 'Session capacity',
|
||
labelReranker: 'Cross-encoder reranker',
|
||
labelBM25: 'BM25 hybrid retrieval',
|
||
statusActive: 'Active',
|
||
statusUnavailable: 'Unavailable',
|
||
footerAllOk: 'All systems operational',
|
||
footerDegraded: 'Degraded',
|
||
footerChecking: 'Checking…',
|
||
totalChunks: 'Total vector chunks',
|
||
},
|
||
docs: {
|
||
topbarTitle: 'Document Management',
|
||
searchPlaceholder: 'Search documents...',
|
||
refreshBtn: 'Refresh',
|
||
uploadBtn: 'Upload document',
|
||
confirmDeleteTitle: 'Confirm deletion',
|
||
cancelBtn: 'Cancel',
|
||
deleteBtn: 'Delete',
|
||
filterAll: 'All',
|
||
filterReady: 'Ready',
|
||
filterProcessing: 'Processing',
|
||
filterFailed: 'Failed',
|
||
filterPending: 'Pending',
|
||
filterAllTypes: 'All types',
|
||
deleteSelected: 'Delete selected',
|
||
colName: 'Document name',
|
||
colStatus: 'Status',
|
||
colUploaded: 'Uploaded',
|
||
colChunks: 'Chunks',
|
||
colSize: 'Size',
|
||
colType: 'Type',
|
||
colActions: 'Actions',
|
||
loading: 'Loading documents…',
|
||
emptyNoDocuments: 'No documents yet. Upload a document to get started.',
|
||
emptyNoMatch: 'No documents match the current filters.',
|
||
titleDownload: 'Download original file',
|
||
titleRetry: 'Retry processing',
|
||
titleDelete: 'Delete document',
|
||
},
|
||
compliance: {
|
||
topbarTitle: 'Compliance Analysis',
|
||
searchPlaceholder: 'Search analyses...',
|
||
clearBtn: 'Clear',
|
||
exportBtn: 'Export',
|
||
exportJSON: 'Export JSON',
|
||
exportText: 'Export Text',
|
||
newAnalysisBtn: 'New analysis',
|
||
statusAnalyzing: 'Analyzing…',
|
||
statusComplete: 'Analysis complete',
|
||
statusError: 'Error',
|
||
emptyTitle: 'No analysis running',
|
||
emptyDesc: 'Click New analysis to start a compliance gap review against your indexed regulations.',
|
||
retrievingMsg: 'Retrieving relevant regulations…',
|
||
defaultRegulation: 'Regulation',
|
||
matchSuffix: '% match',
|
||
colParagraph: 'Paragraph Under Review',
|
||
extractingMsg: 'Extracting and analyzing text…',
|
||
noTextExtracted: 'No text extracted',
|
||
stagesHeader: 'Analysis stages',
|
||
stageExtraction: 'Text extraction',
|
||
stageClauseSplit: 'Clause splitting',
|
||
stageRetrieval: 'Regulation retrieval',
|
||
stageSynthesis: 'Conclusion synthesis',
|
||
gapInProgress: 'Gap analysis in progress…',
|
||
askAIBtn: 'Ask AI',
|
||
chatBtn: 'Chat',
|
||
conclusionHeader: 'Conclusion',
|
||
riskScoreTooltip: 'Risk score (0=safe, 100=critical)',
|
||
statusCovered: 'Covered',
|
||
statusGap: 'Gap',
|
||
statusCritical: 'Critical',
|
||
statusInfo: 'Info',
|
||
sourceTypePasted: 'Pasted Text',
|
||
sourceTypeIndexed: 'Indexed Document',
|
||
sourceTypeUploaded: 'Uploaded File',
|
||
chatSidebarHeader: 'AI Compliance Q&A',
|
||
chatThinking: 'Thinking▋',
|
||
quickQ1: 'What regulation applies?',
|
||
quickQ2: 'How to remediate?',
|
||
quickQ3: 'What is the risk?',
|
||
chatPlaceholder: 'Ask about this finding…',
|
||
sendBtn: 'Send',
|
||
analysisFailed: 'Analysis failed',
|
||
exportReportHeader: 'COMPLIANCE ANALYSIS REPORT',
|
||
exportSectionParagraph: '── PARAGRAPH UNDER REVIEW ──',
|
||
exportSectionFindings: '── FINDINGS ──',
|
||
exportSectionConclusion: '── CONCLUSION ──',
|
||
exportSectionActions: '── RECOMMENDED ACTIONS ──',
|
||
historyHeader: 'History',
|
||
downloadReport: 'Download report',
|
||
historyEmpty: 'No analyses yet.',
|
||
historyDeleteConfirm: 'Delete this analysis record? This cannot be undone.',
|
||
drawerClose: 'Close',
|
||
drawerChatEmpty: 'No messages yet. Ask a question below.',
|
||
drawerSuggestionsHeader: 'Suggested questions',
|
||
},
|
||
ragchat: {
|
||
topbarTitle: 'Regulation Q&A',
|
||
exportBtn: 'Export chat',
|
||
quickPromptsHeader: 'Quick prompts',
|
||
inputPlaceholder: 'Ask about your regulations…',
|
||
citationsHeader: 'Sources',
|
||
citationsEmpty: 'Citations will appear here after a response is generated.',
|
||
apiError: 'Could not reach the RAG API. Please check the backend.',
|
||
},
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Create `frontend/src/locales/zh.ts`**
|
||
|
||
```ts
|
||
import type { Translations } from './en';
|
||
|
||
export const zh: Translations = {
|
||
nav: {
|
||
groupMain: '主菜单',
|
||
groupWorkbench: '工作台',
|
||
groupChat: '对话',
|
||
overview: '概览',
|
||
signals: '法规信号',
|
||
status: '系统状态',
|
||
documents: '文档管理',
|
||
compliance: '合规分析',
|
||
chat: '法规问答',
|
||
},
|
||
sidebar: {
|
||
toggleTheme: '切换主题',
|
||
toggleLang: '切换语言',
|
||
signOut: '退出',
|
||
},
|
||
overview: {
|
||
eyebrow: 'T-Systems · AI 法规中心',
|
||
heroTitle: 'AI 合规,\n端到端自动化',
|
||
heroDesc: '监控全球 AI 法规,分析文档合规差距,获取有引用来源的回答——一站式平台。',
|
||
openDashboard: '打开仪表盘',
|
||
jumpToChat: '跳转到法规对话',
|
||
sectionHowItWorks: '工作流程',
|
||
sectionScreens: '功能页面',
|
||
statScreens: '功能页面',
|
||
statFlows: '后端感知流程',
|
||
statReviewPosture: '审查状态',
|
||
stepUpload: '上传', stepUploadDesc: '导入法规文档',
|
||
stepProcess: '处理', stepProcessDesc: '向量化与分块',
|
||
stepMonitor: '监控', stepMonitorDesc: '监控法规信号流',
|
||
stepAnalyze: '分析', stepAnalyzeDesc: '运行合规差距分析',
|
||
stepReview: '审查', stepReviewDesc: 'AI 辅助审查发现',
|
||
stepChat: '对话', stepChatDesc: '带引用来源的问答',
|
||
screenStatus: '系统状态', screenStatusDesc: '实时健康与任务队列',
|
||
screenSignals: '法规信号', screenSignalsDesc: 'AI 检测法规变更',
|
||
screenDocuments: '文档管理', screenDocumentsDesc: '上传与查阅文档',
|
||
screenCompliance: '合规分析', screenComplianceDesc: '三栏合规工作台',
|
||
screenChat: '法规问答', screenChatDesc: '带引用来源的法规对话',
|
||
screenAnalytics: '数据分析', screenAnalyticsDesc: 'KPI 与覆盖指标',
|
||
},
|
||
signals: {
|
||
topbarTitle: '法规信号',
|
||
topbarSub: 'AI 驱动 · 实时订阅',
|
||
searchPlaceholder: '搜索信号...',
|
||
refreshBtn: '刷新数据源',
|
||
crawlingBtn: '抓取中...',
|
||
statTotal: '信号总数',
|
||
statHigh: '高影响',
|
||
statMedium: '中影响',
|
||
statLast90: '近 90 天',
|
||
badgeFinal: '已发布',
|
||
badgeDraft: '草案',
|
||
badgeUrgent: '紧急',
|
||
badgePublished: '已发布',
|
||
emptySelectSignal: '选择信号以运行影响分析',
|
||
runAnalysis: '运行影响分析',
|
||
stopBtn: '停止',
|
||
sourceLink: '来源',
|
||
tabOverview: '概览',
|
||
tabObligations: '义务条款',
|
||
tabImpact: '影响评估',
|
||
tabChanges: '变更对比',
|
||
cardScopeHeader: '范围与摘要',
|
||
cardObligationsHeader: '义务条款',
|
||
obligationsEmpty: '暂无结构化数据。点击"运行影响分析"触发提取。',
|
||
colObligationDesc: '义务描述',
|
||
colSubject: '主体',
|
||
colType: '类型',
|
||
colDeadline: '截止日期',
|
||
deadlinePending: '待定',
|
||
cardAffectedDocs: '受影响文档',
|
||
noAffectedDocs: '未找到受影响文档。',
|
||
cardAIImpact: 'AI 影响分析',
|
||
footerText: '实时订阅 · 法规中心',
|
||
statusConnecting: '正在连接数据源...',
|
||
statusNoStream: '无数据流',
|
||
statusCrawling: '抓取中...',
|
||
statusProcessing: '处理 {count} 条...',
|
||
statusComplete: '完成 +{count} 条',
|
||
statusUpdateComplete: '更新完成 — 新增 {new} 条,更新 {updated} 条',
|
||
statusError: '错误: {message}',
|
||
statusConnFailed: '连接失败: {message}',
|
||
diffOld: '旧版',
|
||
diffNew: '新版',
|
||
diffCardHeader: '变更对比',
|
||
},
|
||
status: {
|
||
topbarTitle: '系统状态',
|
||
searchPlaceholder: '搜索...',
|
||
exportBtn: '导出',
|
||
refreshBtn: '刷新',
|
||
newUploadBtn: '上传文档',
|
||
statTotal: '文档总数',
|
||
statIndexed: '已索引',
|
||
statFailed: '失败',
|
||
statChunks: '向量分块数',
|
||
statCoverage: '索引覆盖率',
|
||
cardHealth: '系统健康',
|
||
badgeOnline: '在线',
|
||
badgeError: '错误',
|
||
badgeDegraded: '降级',
|
||
badgeUnknown: '未知',
|
||
healthEndpointError: '无法访问健康检查端点',
|
||
serviceEnabled: '已启用',
|
||
serviceDisabled: '已禁用',
|
||
serviceNotLoaded: '未加载',
|
||
cardConfig: '系统配置',
|
||
labelLLMProvider: 'LLM 提供商',
|
||
labelLLMModel: 'LLM 模型',
|
||
labelEmbeddingModel: '向量模型',
|
||
labelEmbeddingDim: '向量维度',
|
||
labelMilvusCollection: 'Milvus 集合',
|
||
labelParserBackend: '解析后端',
|
||
labelChunkBackend: '分块后端',
|
||
labelParserFailureMode: '解析失败模式',
|
||
configLoadError: '无法加载配置',
|
||
cardBreakdown: '文档分布',
|
||
breakdownIndexed: '已索引',
|
||
breakdownProcessing: '处理中 / 已解析',
|
||
breakdownFailed: '失败',
|
||
cardRuntime: '运行时信息',
|
||
labelActiveSessions: '活跃对话会话',
|
||
labelSessionCapacity: '会话容量',
|
||
labelReranker: '交叉编码器重排序',
|
||
labelBM25: 'BM25 混合检索',
|
||
statusActive: '活跃',
|
||
statusUnavailable: '不可用',
|
||
footerAllOk: '所有系统正常',
|
||
footerDegraded: '降级运行',
|
||
footerChecking: '检查中…',
|
||
totalChunks: '向量分块总数',
|
||
},
|
||
docs: {
|
||
topbarTitle: '文档管理',
|
||
searchPlaceholder: '搜索文档...',
|
||
refreshBtn: '刷新',
|
||
uploadBtn: '上传文档',
|
||
confirmDeleteTitle: '确认删除',
|
||
cancelBtn: '取消',
|
||
deleteBtn: '删除',
|
||
filterAll: '全部',
|
||
filterReady: '就绪',
|
||
filterProcessing: '处理中',
|
||
filterFailed: '失败',
|
||
filterPending: '待处理',
|
||
filterAllTypes: '所有类型',
|
||
deleteSelected: '删除所选',
|
||
colName: '文档名称',
|
||
colStatus: '状态',
|
||
colUploaded: '上传时间',
|
||
colChunks: '分块数',
|
||
colSize: '大小',
|
||
colType: '类型',
|
||
colActions: '操作',
|
||
loading: '加载文档中…',
|
||
emptyNoDocuments: '暂无文档。请上传文档以开始使用。',
|
||
emptyNoMatch: '没有文档符合当前筛选条件。',
|
||
titleDownload: '下载原始文件',
|
||
titleRetry: '重试处理',
|
||
titleDelete: '删除文档',
|
||
},
|
||
compliance: {
|
||
topbarTitle: '合规分析',
|
||
searchPlaceholder: '搜索分析记录...',
|
||
clearBtn: '清除',
|
||
exportBtn: '导出',
|
||
exportJSON: '导出 JSON',
|
||
exportText: '导出文本',
|
||
newAnalysisBtn: '新建分析',
|
||
statusAnalyzing: '分析中…',
|
||
statusComplete: '分析完成',
|
||
statusError: '错误',
|
||
emptyTitle: '暂无分析任务',
|
||
emptyDesc: '点击"新建分析"对已索引法规进行合规差距审查。',
|
||
retrievingMsg: '正在检索相关法规…',
|
||
defaultRegulation: '法规',
|
||
matchSuffix: '% 匹配',
|
||
colParagraph: '待审查段落',
|
||
extractingMsg: '正在提取并分析文本…',
|
||
noTextExtracted: '未提取到文本',
|
||
stagesHeader: '分析阶段',
|
||
stageExtraction: '文本提取',
|
||
stageClauseSplit: '条款分割',
|
||
stageRetrieval: '法规检索',
|
||
stageSynthesis: '结论综合',
|
||
gapInProgress: '差距分析进行中…',
|
||
askAIBtn: '问 AI',
|
||
chatBtn: '对话',
|
||
conclusionHeader: '结论',
|
||
riskScoreTooltip: '风险评分(0=安全,100=严重)',
|
||
statusCovered: '已覆盖',
|
||
statusGap: '存在差距',
|
||
statusCritical: '严重',
|
||
statusInfo: '信息',
|
||
sourceTypePasted: '粘贴文本',
|
||
sourceTypeIndexed: '已索引文档',
|
||
sourceTypeUploaded: '上传文件',
|
||
chatSidebarHeader: 'AI 合规问答',
|
||
chatThinking: '思考中▋',
|
||
quickQ1: '适用哪条法规?',
|
||
quickQ2: '如何整改?',
|
||
quickQ3: '风险等级如何?',
|
||
chatPlaceholder: '针对此发现提问…',
|
||
sendBtn: '发送',
|
||
analysisFailed: '分析失败',
|
||
exportReportHeader: '合规分析报告',
|
||
exportSectionParagraph: '── 待审查段落 ──',
|
||
exportSectionFindings: '── 发现 ──',
|
||
exportSectionConclusion: '── 结论 ──',
|
||
exportSectionActions: '── 建议行动 ──',
|
||
historyHeader: '历史记录',
|
||
downloadReport: '下载报告',
|
||
historyEmpty: '暂无分析记录。',
|
||
historyDeleteConfirm: '删除此分析记录?此操作不可撤销。',
|
||
drawerClose: '关闭',
|
||
drawerChatEmpty: '暂无消息。请在下方提问。',
|
||
drawerSuggestionsHeader: '建议问题',
|
||
},
|
||
ragchat: {
|
||
topbarTitle: '法规问答',
|
||
exportBtn: '导出对话',
|
||
quickPromptsHeader: '快捷问题',
|
||
inputPlaceholder: '请输入关于法规的问题…',
|
||
citationsHeader: '引用来源',
|
||
citationsEmpty: '生成回答后,引用来源将显示在此处。',
|
||
apiError: '无法连接到 RAG API,请检查后端服务。',
|
||
},
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 3: Verify TypeScript sees both files**
|
||
|
||
```bash
|
||
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
|
||
```
|
||
|
||
Expected: 0 errors (or only pre-existing errors, none from the new locale files).
|
||
|
||
---
|
||
|
||
## Task 2: Create `LanguageContext.tsx`
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/contexts/LanguageContext.tsx`
|
||
|
||
- [ ] **Step 1: Write `LanguageContext.tsx`**
|
||
|
||
```tsx
|
||
import React, { createContext, useContext, useState } from 'react';
|
||
import { en } from '../locales/en';
|
||
import type { Translations } from '../locales/en';
|
||
import { zh } from '../locales/zh';
|
||
|
||
export type Lang = 'en' | 'zh';
|
||
|
||
interface LanguageContextValue {
|
||
lang: Lang;
|
||
t: Translations;
|
||
toggleLang: () => void;
|
||
}
|
||
|
||
const LanguageContext = createContext<LanguageContextValue>({
|
||
lang: 'en',
|
||
t: en,
|
||
toggleLang: () => {},
|
||
});
|
||
|
||
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
||
const [lang, setLang] = useState<Lang>('en');
|
||
|
||
const toggleLang = () => setLang(l => (l === 'en' ? 'zh' : 'en'));
|
||
|
||
const t = lang === 'en' ? en : zh;
|
||
|
||
return (
|
||
<LanguageContext.Provider value={{ lang, t, toggleLang }}>
|
||
{children}
|
||
</LanguageContext.Provider>
|
||
);
|
||
}
|
||
|
||
export function useLanguage() {
|
||
return useContext(LanguageContext);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Export from `contexts/index.ts`**
|
||
|
||
Add these lines to `frontend/src/contexts/index.ts`:
|
||
|
||
```ts
|
||
export { LanguageProvider, useLanguage } from './LanguageContext';
|
||
export type { Lang } from './LanguageContext';
|
||
```
|
||
|
||
Full updated file:
|
||
|
||
```ts
|
||
export { ThemeProvider, useTheme } from './ThemeContext';
|
||
export { AuthProvider, useAuth } from './AuthContext';
|
||
export type { AuthUser } from './AuthContext';
|
||
export { PageStateProvider, usePageState } from './PageStateContext';
|
||
export { LanguageProvider, useLanguage } from './LanguageContext';
|
||
export type { Lang } from './LanguageContext';
|
||
export type {
|
||
RagChatState,
|
||
RagMessage,
|
||
RagCitation,
|
||
ComplianceState,
|
||
ComplianceStatus,
|
||
ComplianceSourceEvent,
|
||
ComplianceFindingEvent,
|
||
ComplianceDonePayload,
|
||
ComplianceMeta,
|
||
ComplianceActionItem,
|
||
PerceptionPageState,
|
||
PerceptionSignal,
|
||
} from './PageStateContext';
|
||
```
|
||
|
||
- [ ] **Step 3: Verify TypeScript**
|
||
|
||
```bash
|
||
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
---
|
||
|
||
## Task 3: Wire `LanguageProvider` in `App.tsx` and add toggle button to Sidebar
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/App.tsx`
|
||
- Modify: `frontend/src/components/layout/Sidebar.tsx`
|
||
|
||
- [ ] **Step 1: Update `App.tsx`**
|
||
|
||
Replace the full file content:
|
||
|
||
```tsx
|
||
import './styles/globals.css';
|
||
import { ThemeProvider, AuthProvider, PageStateProvider, LanguageProvider } from './contexts';
|
||
import { AppRouter } from './router/AppRouter';
|
||
|
||
function App() {
|
||
return (
|
||
<LanguageProvider>
|
||
<ThemeProvider>
|
||
<AuthProvider>
|
||
<PageStateProvider>
|
||
<AppRouter />
|
||
</PageStateProvider>
|
||
</AuthProvider>
|
||
</ThemeProvider>
|
||
</LanguageProvider>
|
||
);
|
||
}
|
||
|
||
export default App;
|
||
```
|
||
|
||
- [ ] **Step 2: Update `Sidebar.tsx`**
|
||
|
||
Replace the full file content:
|
||
|
||
```tsx
|
||
import { NavLink } from 'react-router-dom';
|
||
import {
|
||
LayoutDashboard, Radio, Monitor, FileText,
|
||
Shield, MessageSquare, Sun, Moon, LogOut
|
||
} from 'lucide-react';
|
||
import { useTheme } from '../../contexts/ThemeContext';
|
||
import { useAuth } from '../../contexts/AuthContext';
|
||
import { useLanguage } from '../../contexts/LanguageContext';
|
||
|
||
interface NavItem {
|
||
to: string;
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
badge?: number;
|
||
}
|
||
|
||
function NavGroup({ title, items }: { title: string; items: NavItem[] }) {
|
||
return (
|
||
<div className="nav-group">
|
||
<div className="nav-group-label">{title}</div>
|
||
{items.map(item => (
|
||
<NavLink
|
||
key={item.to}
|
||
to={item.to}
|
||
end={item.to === '/'}
|
||
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
||
>
|
||
<span className="nav-icon">{item.icon}</span>
|
||
<span className="nav-label">{item.label}</span>
|
||
{item.badge !== undefined && item.badge > 0 && (
|
||
<span className="nav-badge">{item.badge}</span>
|
||
)}
|
||
</NavLink>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Avatar initials from username (up to 2 chars). */
|
||
function initials(name: string): string {
|
||
const parts = name.trim().split(/[\s_-]+/);
|
||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
||
return name.slice(0, 2).toUpperCase();
|
||
}
|
||
|
||
export function Sidebar() {
|
||
const { theme, toggleTheme } = useTheme();
|
||
const { user, logout } = useAuth();
|
||
const { lang, t, toggleLang } = useLanguage();
|
||
|
||
const mainNav: NavItem[] = [
|
||
{ to: '/', icon: <LayoutDashboard size={16} />, label: t.nav.overview },
|
||
{ to: '/signals', icon: <Radio size={16} />, label: t.nav.signals },
|
||
{ to: '/status', icon: <Monitor size={16} />, label: t.nav.status },
|
||
];
|
||
|
||
const workbenchNav: NavItem[] = [
|
||
{ to: '/documents', icon: <FileText size={16} />, label: t.nav.documents },
|
||
{ to: '/compliance', icon: <Shield size={16} />, label: t.nav.compliance },
|
||
];
|
||
|
||
const chatNav: NavItem[] = [
|
||
{ to: '/chat', icon: <MessageSquare size={16} />, label: t.nav.chat },
|
||
];
|
||
|
||
return (
|
||
<aside className="sidebar">
|
||
<div className="sidebar-brand">
|
||
<img src="/company-logo.ico" alt="T-Systems" className="brand-logo" />
|
||
<div className="brand-text">
|
||
<div className="brand-name">T-Systems</div>
|
||
<div className="brand-sub">Regulation Hub</div>
|
||
</div>
|
||
</div>
|
||
|
||
<nav className="sidebar-nav">
|
||
<NavGroup title={t.nav.groupMain} items={mainNav} />
|
||
<NavGroup title={t.nav.groupWorkbench} items={workbenchNav} />
|
||
<NavGroup title={t.nav.groupChat} items={chatNav} />
|
||
</nav>
|
||
|
||
<div className="sidebar-footer">
|
||
<div className="sidebar-user">
|
||
<div className="user-avatar">{user ? initials(user.username) : 'TS'}</div>
|
||
<div className="user-info">
|
||
<div className="user-name">{user?.username ?? 'Analyst'}</div>
|
||
<div className="user-role">
|
||
{user ? (
|
||
<span className="user-badge">{user.role}</span>
|
||
) : (
|
||
'T-Systems'
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
<button
|
||
className="theme-btn"
|
||
onClick={toggleLang}
|
||
title={t.sidebar.toggleLang}
|
||
style={{ fontSize: 12, fontWeight: 600 }}
|
||
>
|
||
{lang === 'en' ? 'EN' : '中'}
|
||
</button>
|
||
<button className="theme-btn" onClick={toggleTheme} title={t.sidebar.toggleTheme}>
|
||
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
|
||
</button>
|
||
{user && (
|
||
<button className="logout-btn" onClick={logout} title={t.sidebar.signOut}>
|
||
<LogOut size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify TypeScript**
|
||
|
||
```bash
|
||
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 4: Manual check — open the app**
|
||
|
||
Start the dev server (if not running): `cd frontend && npm run dev`
|
||
|
||
Open http://localhost:5173 and verify:
|
||
1. Sidebar shows `EN` button left of the sun/moon button
|
||
2. Clicking `EN` toggles to `中` and nav labels switch to Chinese
|
||
3. Clicking again returns to English
|
||
|
||
---
|
||
|
||
## Task 4: Translate `OverviewPage.tsx`
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/Overview/OverviewPage.tsx`
|
||
|
||
- [ ] **Step 1: Replace `OverviewPage.tsx`**
|
||
|
||
```tsx
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { ArrowRight, BarChart2, Eye, FileText, Shield, MessageSquare, Monitor } from 'lucide-react';
|
||
import { useLanguage } from '../../contexts/LanguageContext';
|
||
|
||
export function OverviewPage() {
|
||
const navigate = useNavigate();
|
||
const { t } = useLanguage();
|
||
|
||
const SCREENS = [
|
||
{ id: 'status', label: t.overview.screenStatus, icon: <Monitor size={20} />, to: '/status', desc: t.overview.screenStatusDesc },
|
||
{ id: 'signals', label: t.overview.screenSignals, icon: <Eye size={20} />, to: '/signals', desc: t.overview.screenSignalsDesc },
|
||
{ id: 'documents', label: t.overview.screenDocuments, icon: <FileText size={20} />, to: '/documents', desc: t.overview.screenDocumentsDesc },
|
||
{ id: 'compliance', label: t.overview.screenCompliance, icon: <Shield size={20} />, to: '/compliance', desc: t.overview.screenComplianceDesc },
|
||
{ id: 'chat', label: t.overview.screenChat, icon: <MessageSquare size={20} />, to: '/chat', desc: t.overview.screenChatDesc },
|
||
{ id: 'analytics', label: t.overview.screenAnalytics, icon: <BarChart2 size={20} />, to: '/status', desc: t.overview.screenAnalyticsDesc },
|
||
];
|
||
|
||
const STEPS = [
|
||
{ num: '01', label: t.overview.stepUpload, desc: t.overview.stepUploadDesc },
|
||
{ num: '02', label: t.overview.stepProcess, desc: t.overview.stepProcessDesc },
|
||
{ num: '03', label: t.overview.stepMonitor, desc: t.overview.stepMonitorDesc },
|
||
{ num: '04', label: t.overview.stepAnalyze, desc: t.overview.stepAnalyzeDesc },
|
||
{ num: '05', label: t.overview.stepReview, desc: t.overview.stepReviewDesc },
|
||
{ num: '06', label: t.overview.stepChat, desc: t.overview.stepChatDesc },
|
||
];
|
||
|
||
return (
|
||
<div className="overview-scroll-wrapper">
|
||
<div className="overview-page">
|
||
<section className="overview-hero">
|
||
<p className="hero-eyebrow">{t.overview.eyebrow}</p>
|
||
<h1 className="hero-title">{t.overview.heroTitle.split('\n').map((line, i) => (
|
||
<span key={i}>{line}{i === 0 && <br />}</span>
|
||
))}</h1>
|
||
<p className="hero-desc">{t.overview.heroDesc}</p>
|
||
<div className="hero-actions">
|
||
<button className="btn primary" onClick={() => navigate('/status')}>
|
||
{t.overview.openDashboard} <ArrowRight size={14} />
|
||
</button>
|
||
<button className="btn" onClick={() => navigate('/chat')}>
|
||
{t.overview.jumpToChat}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="overview-summary card">
|
||
<div className="summary-item">
|
||
<span className="summary-num">6</span>
|
||
<span className="summary-label">{t.overview.statScreens}</span>
|
||
</div>
|
||
<div className="summary-divider" />
|
||
<div className="summary-item">
|
||
<span className="summary-num">5</span>
|
||
<span className="summary-label">{t.overview.statFlows}</span>
|
||
</div>
|
||
<div className="summary-divider" />
|
||
<div className="summary-item">
|
||
<span className="summary-num">AI</span>
|
||
<span className="summary-label">{t.overview.statReviewPosture}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<section className="overview-workflow">
|
||
<h2 className="section-title">{t.overview.sectionHowItWorks}</h2>
|
||
<div className="workflow-steps">
|
||
{STEPS.map(s => (
|
||
<div key={s.num} className="workflow-step">
|
||
<div className="step-num">{s.num}</div>
|
||
<div className="step-label">{s.label}</div>
|
||
<div className="step-desc">{s.desc}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="overview-screens">
|
||
<h2 className="section-title">{t.overview.sectionScreens}</h2>
|
||
<div className="screen-grid">
|
||
{SCREENS.map(s => (
|
||
<button key={s.id} className="screen-card card" onClick={() => navigate(s.to)}>
|
||
<div className="screen-icon">{s.icon}</div>
|
||
<div className="screen-label">{s.label}</div>
|
||
<div className="screen-desc">{s.desc}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify TypeScript**
|
||
|
||
```bash
|
||
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
---
|
||
|
||
## Task 5: Translate `StatusPage.tsx`
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/Status/StatusPage.tsx`
|
||
|
||
- [ ] **Step 1: Add `useLanguage` import and hook call**
|
||
|
||
At the top of the file, add the import:
|
||
|
||
```tsx
|
||
import { useLanguage } from '../../contexts/LanguageContext';
|
||
```
|
||
|
||
Inside `StatusPage()` function, add at the top:
|
||
|
||
```tsx
|
||
const { t } = useLanguage();
|
||
```
|
||
|
||
- [ ] **Step 2: Replace hardcoded strings — `Topbar` actions**
|
||
|
||
Find:
|
||
```tsx
|
||
<Topbar
|
||
title="System Status"
|
||
actions={
|
||
<>
|
||
<div className="search-box">
|
||
<Search size={13} />
|
||
<input placeholder="Search..." />
|
||
</div>
|
||
<button className="btn sm" onClick={handleExport}>
|
||
<Download size={13} />Export
|
||
</button>
|
||
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
||
<RefreshCw size={13} />Refresh
|
||
</button>
|
||
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
|
||
<Upload size={13} />New upload
|
||
</button>
|
||
</>
|
||
}
|
||
/>
|
||
```
|
||
|
||
Replace with:
|
||
```tsx
|
||
<Topbar
|
||
title={t.status.topbarTitle}
|
||
actions={
|
||
<>
|
||
<div className="search-box">
|
||
<Search size={13} />
|
||
<input placeholder={t.status.searchPlaceholder} />
|
||
</div>
|
||
<button className="btn sm" onClick={handleExport}>
|
||
<Download size={13} />{t.status.exportBtn}
|
||
</button>
|
||
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
||
<RefreshCw size={13} />{t.status.refreshBtn}
|
||
</button>
|
||
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
|
||
<Upload size={13} />{t.status.newUploadBtn}
|
||
</button>
|
||
</>
|
||
}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 3: Replace stat labels**
|
||
|
||
Find:
|
||
```tsx
|
||
<div className="stat-label">Documents total</div>
|
||
```
|
||
Replace: `<div className="stat-label">{t.status.statTotal}</div>`
|
||
|
||
Find: `<div className="stat-label">Indexed</div>` (first occurrence in stats grid)
|
||
Replace: `<div className="stat-label">{t.status.statIndexed}</div>`
|
||
|
||
Find: `<div className="stat-label">Failed</div>` (in stats grid)
|
||
Replace: `<div className="stat-label">{t.status.statFailed}</div>`
|
||
|
||
Find: `<div className="stat-label">Vector chunks</div>`
|
||
Replace: `<div className="stat-label">{t.status.statChunks}</div>`
|
||
|
||
Find: `<span style={{ fontSize: 12, color: 'var(--muted)', whiteSpace: 'nowrap' }}>Index coverage</span>`
|
||
Replace: `<span style={{ fontSize: 12, color: 'var(--muted)', whiteSpace: 'nowrap' }}>{t.status.statCoverage}</span>`
|
||
|
||
- [ ] **Step 4: Replace `ServiceRow` status strings**
|
||
|
||
The `ServiceRow` component renders `'Online'`, `'Error'`, `'Degraded'`, `'Unknown'` from a ternary. Replace the inline string map in the `ServiceRow` `<span>`:
|
||
|
||
Find inside `ServiceRow`:
|
||
```tsx
|
||
{status === 'ok' ? 'Online' : status === 'error' ? 'Error' : status === 'warn' ? 'Degraded' : 'Unknown'}
|
||
```
|
||
|
||
This requires `t` inside `ServiceRow`. Convert `ServiceRow` to accept a `t` prop, **or** (simpler) pass the label string externally. The simpler approach: add a `label` prop with a default:
|
||
|
||
Replace `ServiceRow` component definition:
|
||
|
||
```tsx
|
||
function ServiceRow({ name, status, detail }: { name: string; status: 'ok' | 'error' | 'warn' | 'info'; detail?: string }) {
|
||
const { t } = useLanguage();
|
||
return (
|
||
<div className="service-row">
|
||
<StatusIcon status={status} />
|
||
<span className="service-name" style={{ marginLeft: 8 }}>{name}</span>
|
||
{detail && <span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6 }}>{detail}</span>}
|
||
<span className={`status ${status}`} style={{ marginLeft: 'auto' }}>
|
||
{status === 'ok' ? t.status.badgeOnline : status === 'error' ? t.status.badgeError : status === 'warn' ? t.status.badgeDegraded : t.status.badgeUnknown}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Replace card headers, service names, and config labels**
|
||
|
||
Find: `<span>System health</span>`
|
||
Replace: `<span>{t.status.cardHealth}</span>`
|
||
|
||
Find: `Could not reach health endpoint`
|
||
Replace: `{t.status.healthEndpointError}`
|
||
|
||
Find (in `ServiceRow` calls):
|
||
```tsx
|
||
detail={health.bm25.available ? undefined : 'Not loaded'}
|
||
```
|
||
Replace:
|
||
```tsx
|
||
detail={health.bm25.available ? undefined : t.status.serviceNotLoaded}
|
||
```
|
||
|
||
Find:
|
||
```tsx
|
||
detail={health.reranker.enabled ? 'Enabled' : 'Disabled'}
|
||
```
|
||
Replace:
|
||
```tsx
|
||
detail={health.reranker.enabled ? t.status.serviceEnabled : t.status.serviceDisabled}
|
||
```
|
||
|
||
Find: `<div className="card-header" style={{ margin: 0, padding: 0, flex: 1, textAlign: 'left' }}>System configuration</div>`
|
||
Replace: `<div className="card-header" style={{ margin: 0, padding: 0, flex: 1, textAlign: 'left' }}>{t.status.cardConfig}</div>`
|
||
|
||
Replace each `ConfigRow label=` string:
|
||
- `"LLM provider"` → `{t.status.labelLLMProvider}`
|
||
- `"LLM model"` → `{t.status.labelLLMModel}`
|
||
- `"Embedding model"` → `{t.status.labelEmbeddingModel}`
|
||
- `"Embedding dim"` → `{t.status.labelEmbeddingDim}`
|
||
- `"Milvus collection"` → `{t.status.labelMilvusCollection}`
|
||
- `"Parser backend"` → `{t.status.labelParserBackend}`
|
||
- `"Chunk backend"` → `{t.status.labelChunkBackend}`
|
||
- `"Parser failure mode"` → `{t.status.labelParserFailureMode}`
|
||
|
||
Find: `<div style={{ color: 'var(--muted)', fontSize: 13 }}>Could not load config</div>`
|
||
Replace: `<div style={{ color: 'var(--muted)', fontSize: 13 }}>{t.status.configLoadError}</div>`
|
||
|
||
Find: `<div className="card-header">Document breakdown</div>`
|
||
Replace: `<div className="card-header">{t.status.cardBreakdown}</div>`
|
||
|
||
Find breakdown row labels array `'Indexed'`, `'Processing / Parsed'`, `'Failed'`:
|
||
```tsx
|
||
{ label: 'Indexed', ...},
|
||
{ label: 'Processing / Parsed', ...},
|
||
{ label: 'Failed', ...},
|
||
```
|
||
Replace:
|
||
```tsx
|
||
{ label: t.status.breakdownIndexed, ...},
|
||
{ label: t.status.breakdownProcessing, ...},
|
||
{ label: t.status.breakdownFailed, ...},
|
||
```
|
||
|
||
Find: `<span style={{ color: 'var(--muted)' }}>Total vector chunks</span>`
|
||
Replace: `<span style={{ color: 'var(--muted)' }}>{t.status.totalChunks}</span>`
|
||
|
||
Find: `<div className="card-header">Runtime info</div>`
|
||
Replace: `<div className="card-header">{t.status.cardRuntime}</div>`
|
||
|
||
Replace the runtime info row labels:
|
||
- `'Active chat sessions'` → `{t.status.labelActiveSessions}`
|
||
- `'Session capacity'` → `{t.status.labelSessionCapacity}`
|
||
- `'Cross-encoder reranker'` → `{t.status.labelReranker}`
|
||
- `'BM25 hybrid retrieval'` → `{t.status.labelBM25}`
|
||
|
||
Replace runtime status values:
|
||
- `'Active'` (BM25 true) → `{t.status.statusActive}`
|
||
- `'Unavailable'` → `{t.status.statusUnavailable}`
|
||
|
||
Find footer:
|
||
```tsx
|
||
<span>Regulation Hub · T-Systems AI · {health ? (health.milvus.status === 'ok' && health.minio.connected ? 'All systems operational' : 'Degraded') : 'Checking…'}</span>
|
||
```
|
||
Replace:
|
||
```tsx
|
||
<span>Regulation Hub · T-Systems AI · {health ? (health.milvus.status === 'ok' && health.minio.connected ? t.status.footerAllOk : t.status.footerDegraded) : t.status.footerChecking}</span>
|
||
```
|
||
|
||
- [ ] **Step 6: Verify TypeScript**
|
||
|
||
```bash
|
||
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
---
|
||
|
||
## Task 6: Translate `PerceptionPage.tsx`
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/Perception/PerceptionPage.tsx`
|
||
|
||
- [ ] **Step 1: Add `useLanguage` import and hook**
|
||
|
||
Add import:
|
||
```tsx
|
||
import { useLanguage } from '../../contexts/LanguageContext';
|
||
```
|
||
|
||
Add at the top of `PerceptionPage()`:
|
||
```tsx
|
||
const { t } = useLanguage();
|
||
```
|
||
|
||
- [ ] **Step 2: Replace `Topbar` props**
|
||
|
||
Find:
|
||
```tsx
|
||
<Topbar
|
||
title="Regulatory Signals"
|
||
subtitle="ai-powered · live feed"
|
||
actions={
|
||
<>
|
||
<div className="search-box">
|
||
<input
|
||
placeholder="Search signals..."
|
||
```
|
||
Replace `title`, `subtitle`, `placeholder`:
|
||
```tsx
|
||
<Topbar
|
||
title={t.signals.topbarTitle}
|
||
subtitle={t.signals.topbarSub}
|
||
actions={
|
||
<>
|
||
<div className="search-box">
|
||
<input
|
||
placeholder={t.signals.searchPlaceholder}
|
||
```
|
||
|
||
- [ ] **Step 3: Replace button labels**
|
||
|
||
Find:
|
||
```tsx
|
||
{crawling ? 'Crawling...' : 'Refresh Sources'}
|
||
```
|
||
Replace:
|
||
```tsx
|
||
{crawling ? t.signals.crawlingBtn : t.signals.refreshBtn}
|
||
```
|
||
|
||
- [ ] **Step 4: Replace stat labels**
|
||
|
||
Find `'Total signals'` → `{t.signals.statTotal}`
|
||
Find `'High impact'` → `{t.signals.statHigh}`
|
||
Find `'Medium impact'` → `{t.signals.statMedium}`
|
||
Find `'Last 90 days'` → `{t.signals.statLast90}`
|
||
|
||
- [ ] **Step 5: Replace signal status badges**
|
||
|
||
Find:
|
||
```tsx
|
||
{sig.status === 'ok' ? 'Final' : sig.status === 'warn' ? 'Draft' : sig.status === 'risk' ? 'Urgent' : 'Published'}
|
||
```
|
||
Replace:
|
||
```tsx
|
||
{sig.status === 'ok' ? t.signals.badgeFinal : sig.status === 'warn' ? t.signals.badgeDraft : sig.status === 'risk' ? t.signals.badgeUrgent : t.signals.badgePublished}
|
||
```
|
||
|
||
Do the same for the detail card status badge (second occurrence with `selected.status`).
|
||
|
||
- [ ] **Step 6: Replace empty state, buttons, and tab labels**
|
||
|
||
Find: `<p>Select a signal to run impact analysis</p>`
|
||
Replace: `<p>{t.signals.emptySelectSignal}</p>`
|
||
|
||
Find: `<button className="btn sm primary" onClick={runAnalysis}><Play size={12} />Run impact analysis</button>`
|
||
Replace: `<button className="btn sm primary" onClick={runAnalysis}><Play size={12} />{t.signals.runAnalysis}</button>`
|
||
|
||
Find: `<button className="btn sm" onClick={stopAnalysis}><Square size={12} />Stop</button>`
|
||
Replace: `<button className="btn sm" onClick={stopAnalysis}><Square size={12} />{t.signals.stopBtn}</button>`
|
||
|
||
Find: `<ExternalLink size={12} />Source`
|
||
Replace: `<ExternalLink size={12} />{t.signals.sourceLink}`
|
||
|
||
- [ ] **Step 7: Replace tab labels**
|
||
|
||
Find:
|
||
```tsx
|
||
{tab === 'overview' ? '概览' : tab === 'obligations' ? '义务条款' : tab === 'assessment' ? '影响评估' : '变更对比'}
|
||
```
|
||
Replace:
|
||
```tsx
|
||
{tab === 'overview' ? t.signals.tabOverview : tab === 'obligations' ? t.signals.tabObligations : tab === 'assessment' ? t.signals.tabImpact : t.signals.tabChanges}
|
||
```
|
||
|
||
- [ ] **Step 8: Replace card headers and table headers**
|
||
|
||
Find: `<div className="card-header">Scope & Summary</div>`
|
||
Replace: `<div className="card-header">{t.signals.cardScopeHeader}</div>`
|
||
|
||
Find: `<div className="card-header">义务条款</div>`
|
||
Replace: `<div className="card-header">{t.signals.cardObligationsHeader}</div>`
|
||
|
||
Find: `<p className="detail-summary" style={{ marginTop: 8 }}>暂无结构化数据。点击右上角"Run impact analysis"触发提取。</p>`
|
||
Replace: `<p className="detail-summary" style={{ marginTop: 8 }}>{t.signals.obligationsEmpty}</p>`
|
||
|
||
Find: `<th style={{ textAlign: 'left', padding: '4px 8px' }}>义务描述</th>`
|
||
Replace: `<th style={{ textAlign: 'left', padding: '4px 8px' }}>{t.signals.colObligationDesc}</th>`
|
||
|
||
Find: `<th style={{ textAlign: 'left', padding: '4px 8px', width: 80 }}>主体</th>`
|
||
Replace: `<th style={{ textAlign: 'left', padding: '4px 8px', width: 80 }}>{t.signals.colSubject}</th>`
|
||
|
||
Find: `<th style={{ textAlign: 'left', padding: '4px 8px', width: 60 }}>类型</th>`
|
||
Replace: `<th style={{ textAlign: 'left', padding: '4px 8px', width: 60 }}>{t.signals.colType}</th>`
|
||
|
||
Find: `<div className="card-header">截止日期</div>`
|
||
Replace: `<div className="card-header">{t.signals.colDeadline}</div>`
|
||
|
||
Find: `{d.date || '待定'}`
|
||
Replace: `{d.date || t.signals.deadlinePending}`
|
||
|
||
Find: `<div className="card-header">Affected documents</div>`
|
||
Replace: `<div className="card-header">{t.signals.cardAffectedDocs}</div>`
|
||
|
||
Find: `<p className="detail-summary" style={{ marginTop: 8 }}>No affected documents found.</p>`
|
||
Replace: `<p className="detail-summary" style={{ marginTop: 8 }}>{t.signals.noAffectedDocs}</p>`
|
||
|
||
Find: `<div className="card-header">AI Impact Analysis</div>`
|
||
Replace: `<div className="card-header">{t.signals.cardAIImpact}</div>`
|
||
|
||
- [ ] **Step 9: Replace diff tab headers**
|
||
|
||
Find: `<div className="card-header">变更对比</div>`
|
||
Replace: `<div className="card-header">{t.signals.diffCardHeader}</div>`
|
||
|
||
Find: `<div style={{ fontWeight: 600, marginBottom: 4 }}>旧版</div>`
|
||
Replace: `<div style={{ fontWeight: 600, marginBottom: 4 }}>{t.signals.diffOld}</div>`
|
||
|
||
Find: `<div style={{ fontWeight: 600, marginBottom: 4 }}>新版</div>`
|
||
Replace: `<div style={{ fontWeight: 600, marginBottom: 4 }}>{t.signals.diffNew}</div>`
|
||
|
||
- [ ] **Step 10: Replace crawl status strings in `runCrawl()`**
|
||
|
||
Find: `crawlStatus: '正在连接数据源...'`
|
||
Replace: `crawlStatus: t.signals.statusConnecting`
|
||
|
||
Find:
|
||
```tsx
|
||
crawlStatus: `${d.source}: ${d.stage === 'fetching' ? '抓取中...' : d.stage === 'processing' ? `处理 ${d.fetched} 条...` : `完成 +${d.new} 条`}`,
|
||
```
|
||
Replace:
|
||
```tsx
|
||
crawlStatus: `${d.source}: ${d.stage === 'fetching' ? t.signals.statusCrawling : d.stage === 'processing' ? t.signals.statusProcessing.replace('{count}', String(d.fetched)) : t.signals.statusComplete.replace('{count}', String(d.new))}`,
|
||
```
|
||
|
||
Find: `` crawlStatus: `更新完成 — 新增 ${d.total_new} 条,更新 ${d.total_updated} 条` ``
|
||
Replace:
|
||
```tsx
|
||
crawlStatus: t.signals.statusUpdateComplete.replace('{new}', String(d.total_new)).replace('{updated}', String(d.total_updated))
|
||
```
|
||
|
||
Find: `` crawlStatus: `错误: ${typeof d === 'string' ? d : d.message}` ``
|
||
Replace:
|
||
```tsx
|
||
crawlStatus: t.signals.statusError.replace('{message}', typeof d === 'string' ? d : String(d.message))
|
||
```
|
||
|
||
Find: `` crawlStatus: `连接失败: ${e instanceof Error ? e.message : String(e)}` ``
|
||
Replace:
|
||
```tsx
|
||
crawlStatus: t.signals.statusConnFailed.replace('{message}', e instanceof Error ? e.message : String(e))
|
||
```
|
||
|
||
- [ ] **Step 11: Replace footer**
|
||
|
||
Find: `<span>Live feed · Regulation Hub</span>`
|
||
Replace: `<span>{t.signals.footerText}</span>`
|
||
|
||
- [ ] **Step 12: Verify TypeScript**
|
||
|
||
```bash
|
||
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
---
|
||
|
||
## Task 7: Translate `DocsPage.tsx`
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/Docs/DocsPage.tsx`
|
||
|
||
- [ ] **Step 1: Add `useLanguage` import**
|
||
|
||
```tsx
|
||
import { useLanguage } from '../../contexts/LanguageContext';
|
||
```
|
||
|
||
- [ ] **Step 2: Add hook in `ConfirmDialog` and `DocsPage`**
|
||
|
||
`ConfirmDialog` uses `'Confirm deletion'`, `'Cancel'`, `'Delete'`. Convert it to accept a `t` prop **or** call `useLanguage()` inside it. Use `useLanguage()` directly inside (same pattern as `ServiceRow`):
|
||
|
||
```tsx
|
||
function ConfirmDialog({ message, onConfirm, onCancel }: {
|
||
message: string;
|
||
onConfirm: () => void;
|
||
onCancel: () => void;
|
||
}) {
|
||
const { t } = useLanguage();
|
||
return (
|
||
<div className="modal-overlay" onClick={onCancel}>
|
||
<div
|
||
style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', maxWidth: 400, width: '100%', boxShadow: '0 12px 40px rgba(0,0,0,.2)' }}
|
||
onClick={e => e.stopPropagation()}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||
<AlertTriangle size={18} color="var(--danger)" />
|
||
<span style={{ fontWeight: 600, fontSize: 15 }}>{t.docs.confirmDeleteTitle}</span>
|
||
</div>
|
||
<p style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 20 }}>{message}</p>
|
||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||
<button className="btn sm" onClick={onCancel}>{t.docs.cancelBtn}</button>
|
||
<button className="btn sm" style={{ background: 'var(--danger)', color: '#fff', borderColor: 'var(--danger)' }} onClick={onConfirm}>
|
||
{t.docs.deleteBtn}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Add `const { t } = useLanguage();` at top of `DocsPage()`.
|
||
|
||
- [ ] **Step 3: Replace `STATUS_FILTERS` array and topbar**
|
||
|
||
The `STATUS_FILTERS` array is used in chip rendering and filter logic. The filter logic uses the English strings as keys (through `STATUS_MAP`). Keep the filter keys as English internally, only translate the displayed chip label.
|
||
|
||
Update the chip rendering:
|
||
```tsx
|
||
{STATUS_FILTERS.map(f => (
|
||
<button
|
||
key={f}
|
||
className={`chip${statusF === f ? ' active' : ''}`}
|
||
onClick={() => setStatusF(f)}
|
||
>
|
||
{f === 'All' ? t.docs.filterAll
|
||
: f === 'Ready' ? t.docs.filterReady
|
||
: f === 'Processing' ? t.docs.filterProcessing
|
||
: f === 'Failed' ? t.docs.filterFailed
|
||
: t.docs.filterPending}
|
||
</button>
|
||
))}
|
||
```
|
||
|
||
Replace the `'All types'` dropdown option display (keep value as English for filter logic):
|
||
```tsx
|
||
<select className="select-input" value={typeF} onChange={e => setTypeF(e.target.value)}>
|
||
{typeOpts.map(o => (
|
||
<option key={o} value={o}>{o === 'All types' ? t.docs.filterAllTypes : o}</option>
|
||
))}
|
||
</select>
|
||
```
|
||
|
||
Replace `Topbar`:
|
||
```tsx
|
||
<Topbar
|
||
title={t.docs.topbarTitle}
|
||
actions={
|
||
<>
|
||
<div className="search-box">
|
||
<Search size={13} />
|
||
<input
|
||
placeholder={t.docs.searchPlaceholder}
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
||
<RefreshCw size={13} />{t.docs.refreshBtn}
|
||
</button>
|
||
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
|
||
<Upload size={13} />{t.docs.uploadBtn}
|
||
</button>
|
||
</>
|
||
}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 4: Replace batch bar and table headers**
|
||
|
||
Find: `<span>{selected.size} document{selected.size > 1 ? 's' : ''} selected</span>`
|
||
|
||
This string has pluralisation logic. Keep it simple — just use the count:
|
||
```tsx
|
||
<span>{selected.size} {t.docs.filterAll === 'All' ? 'document(s)' : '份文档'} selected</span>
|
||
```
|
||
|
||
Actually, simpler — since the text is "X documents selected", we can just do:
|
||
```tsx
|
||
<span>{selected.size} {selected.size > 1 ? (t.docs.colName === 'Document name' ? 'documents' : '份文档') : (t.docs.colName === 'Document name' ? 'document' : '份文档')} selected</span>
|
||
```
|
||
|
||
Cleanest approach — add a helper:
|
||
|
||
Find the batch bar span and replace with:
|
||
```tsx
|
||
<span>
|
||
{selected.size}{' '}
|
||
{t.docs.colName === 'Document name'
|
||
? `document${selected.size > 1 ? 's' : ''} selected`
|
||
: `份文档已选择`}
|
||
</span>
|
||
```
|
||
|
||
Find: `<Trash2 size={12} />Delete selected`
|
||
Replace: `<Trash2 size={12} />{t.docs.deleteSelected}`
|
||
|
||
Replace table header spans:
|
||
```tsx
|
||
<span>{t.docs.colName}</span>
|
||
<span>{t.docs.colStatus}</span>
|
||
<span>{t.docs.colUploaded}</span>
|
||
<span>{t.docs.colChunks}</span>
|
||
<span>{t.docs.colSize}</span>
|
||
<span>{t.docs.colType}</span>
|
||
<span>{t.docs.colActions}</span>
|
||
```
|
||
|
||
Find: `Loading documents…`
|
||
Replace: `{t.docs.loading}`
|
||
|
||
Find: `docs.length === 0 ? 'No documents yet. Upload a document to get started.' : 'No documents match the current filters.'`
|
||
Replace: `docs.length === 0 ? t.docs.emptyNoDocuments : t.docs.emptyNoMatch`
|
||
|
||
Replace `title` attributes on action buttons:
|
||
- `"Download original file"` → `{t.docs.titleDownload}`
|
||
- `"Retry processing"` → `{t.docs.titleRetry}`
|
||
- `"Delete document"` → `{t.docs.titleDelete}`
|
||
|
||
Replace confirm dialog messages:
|
||
```tsx
|
||
message={
|
||
confirmDelete.ids.length === 1
|
||
? `Delete "${confirmDelete.names[0]}"? This will remove the document, all its chunks, and embeddings from the vector store. This action cannot be undone.`
|
||
: `Delete ${confirmDelete.ids.length} documents? This will remove them and all their chunks from the vector store. This action cannot be undone.`
|
||
}
|
||
```
|
||
Replace with (keep the English detail text as it is system info):
|
||
```tsx
|
||
message={
|
||
confirmDelete.ids.length === 1
|
||
? `${t.docs.deleteBtn} "${confirmDelete.names[0]}"? This will remove the document, all its chunks, and embeddings from the vector store. This action cannot be undone.`
|
||
: `${t.docs.deleteBtn} ${confirmDelete.ids.length} ${t.docs.colName === 'Document name' ? 'documents' : '份文档'}? This will remove them and all their chunks from the vector store. This action cannot be undone.`
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Verify TypeScript**
|
||
|
||
```bash
|
||
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
---
|
||
|
||
## Task 8: Translate `CompliancePage.tsx`, `HistoryRail.tsx`, `FindingChatDrawer.tsx`
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/Compliance/CompliancePage.tsx`
|
||
- Modify: `frontend/src/pages/Compliance/HistoryRail.tsx`
|
||
- Modify: `frontend/src/pages/Compliance/FindingChatDrawer.tsx`
|
||
|
||
- [ ] **Step 1: Add `useLanguage` to `CompliancePage.tsx`**
|
||
|
||
Add import:
|
||
```tsx
|
||
import { useLanguage } from '../../contexts/LanguageContext';
|
||
```
|
||
|
||
Add at top of `CompliancePage()`:
|
||
```tsx
|
||
const { t } = useLanguage();
|
||
```
|
||
|
||
Replace all hardcoded UI strings using `t.compliance.*`. Key replacements (search and replace each):
|
||
|
||
| Find | Replace |
|
||
|------|---------|
|
||
| `title="Compliance Analysis"` (Topbar) | `title={t.compliance.topbarTitle}` |
|
||
| `placeholder="Search analyses..."` | `placeholder={t.compliance.searchPlaceholder}` |
|
||
| `'Clear'` (button) | `{t.compliance.clearBtn}` |
|
||
| `'Export'` (button label) | `{t.compliance.exportBtn}` |
|
||
| `'Export JSON'` | `{t.compliance.exportJSON}` |
|
||
| `'Export Text'` | `{t.compliance.exportText}` |
|
||
| `'New analysis'` | `{t.compliance.newAnalysisBtn}` |
|
||
| `'Analyzing…'` | `{t.compliance.statusAnalyzing}` |
|
||
| `'Analysis complete'` | `{t.compliance.statusComplete}` |
|
||
| `'No analysis running'` | `{t.compliance.emptyTitle}` |
|
||
| `'Click New analysis to start...'` | `{t.compliance.emptyDesc}` |
|
||
| `'Retrieving relevant regulations…'` | `{t.compliance.retrievingMsg}` |
|
||
| `'Regulation'` (default name) | `{t.compliance.defaultRegulation}` |
|
||
| `'% match'` | `{t.compliance.matchSuffix}` |
|
||
| `'Paragraph Under Review'` | `{t.compliance.colParagraph}` |
|
||
| `'Extracting and analyzing text…'` | `{t.compliance.extractingMsg}` |
|
||
| `'No text extracted'` | `{t.compliance.noTextExtracted}` |
|
||
| `'Analysis stages'` | `{t.compliance.stagesHeader}` |
|
||
| `'Text extraction'` | `{t.compliance.stageExtraction}` |
|
||
| `'Clause splitting'` | `{t.compliance.stageClauseSplit}` |
|
||
| `'Regulation retrieval'` | `{t.compliance.stageRetrieval}` |
|
||
| `'Conclusion synthesis'` | `{t.compliance.stageSynthesis}` |
|
||
| `'Gap analysis in progress…'` | `{t.compliance.gapInProgress}` |
|
||
| `'Ask AI'` | `{t.compliance.askAIBtn}` |
|
||
| `'Chat'` (button) | `{t.compliance.chatBtn}` |
|
||
| `'Conclusion'` (header) | `{t.compliance.conclusionHeader}` |
|
||
| `title="Risk score (0=safe, 100=critical)"` | `title={t.compliance.riskScoreTooltip}` |
|
||
| `'Covered'` | `{t.compliance.statusCovered}` |
|
||
| `'Gap'` | `{t.compliance.statusGap}` |
|
||
| `'Critical'` | `{t.compliance.statusCritical}` |
|
||
| `'Info'` (status) | `{t.compliance.statusInfo}` |
|
||
| `'Pasted Text'` | `{t.compliance.sourceTypePasted}` |
|
||
| `'Indexed Document'` | `{t.compliance.sourceTypeIndexed}` |
|
||
| `'Uploaded File'` | `{t.compliance.sourceTypeUploaded}` |
|
||
| `'AI Compliance Q&A'` | `{t.compliance.chatSidebarHeader}` |
|
||
| `'Thinking▋'` | `{t.compliance.chatThinking}` |
|
||
| `'What regulation applies?'` | `{t.compliance.quickQ1}` |
|
||
| `'How to remediate?'` | `{t.compliance.quickQ2}` |
|
||
| `'What is the risk?'` | `{t.compliance.quickQ3}` |
|
||
| `placeholder="Ask about this finding…"` | `placeholder={t.compliance.chatPlaceholder}` |
|
||
| `'Send'` (button) | `{t.compliance.sendBtn}` |
|
||
| `'Analysis failed'` | `{t.compliance.analysisFailed}` |
|
||
| `'COMPLIANCE ANALYSIS REPORT'` (export) | `t.compliance.exportReportHeader` |
|
||
| `'── PARAGRAPH UNDER REVIEW ──'` | `t.compliance.exportSectionParagraph` |
|
||
| `'── FINDINGS ──'` | `t.compliance.exportSectionFindings` |
|
||
| `'── CONCLUSION ──'` | `t.compliance.exportSectionConclusion` |
|
||
| `'── RECOMMENDED ACTIONS ──'` | `t.compliance.exportSectionActions` |
|
||
|
||
Column headers with counts use string interpolation — use template literals:
|
||
- `'Retrieved Regulations {count}'` rendered as: `` `${t.compliance.stageRetrieval} (${count})` `` or `` `Retrieved Regulations (${count})` `` — keep the count logic, wrap the label.
|
||
- `'Findings {count}'` → same pattern.
|
||
|
||
- [ ] **Step 2: Translate `HistoryRail.tsx`**
|
||
|
||
Add import + hook:
|
||
```tsx
|
||
import { useLanguage } from '../../contexts/LanguageContext';
|
||
// Inside component:
|
||
const { t } = useLanguage();
|
||
```
|
||
|
||
Replace:
|
||
- `'History'` (header) → `{t.compliance.historyHeader}`
|
||
- `'Download report'` / `'Download'` → `{t.compliance.downloadReport}`
|
||
- `'No analyses yet.'` → `{t.compliance.historyEmpty}`
|
||
- `window.confirm(...)` message → `t.compliance.historyDeleteConfirm`
|
||
|
||
- [ ] **Step 3: Translate `FindingChatDrawer.tsx`**
|
||
|
||
Add import + hook:
|
||
```tsx
|
||
import { useLanguage } from '../../contexts/LanguageContext';
|
||
// Inside component:
|
||
const { t } = useLanguage();
|
||
```
|
||
|
||
Replace:
|
||
- `'Close'` button → `{t.compliance.drawerClose}`
|
||
- Empty chat state message → `{t.compliance.drawerChatEmpty}`
|
||
- `'Suggested questions'` header (if present) → `{t.compliance.drawerSuggestionsHeader}`
|
||
- `'Send'` button → `{t.compliance.sendBtn}`
|
||
|
||
- [ ] **Step 4: Verify TypeScript**
|
||
|
||
```bash
|
||
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
---
|
||
|
||
## Task 9: Translate `RagChatPage.tsx`
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/RagChat/RagChatPage.tsx`
|
||
|
||
- [ ] **Step 1: Add `useLanguage` import and hook**
|
||
|
||
```tsx
|
||
import { useLanguage } from '../../contexts/LanguageContext';
|
||
// Inside RagChatPage():
|
||
const { t } = useLanguage();
|
||
```
|
||
|
||
- [ ] **Step 2: Replace `Topbar` and export button**
|
||
|
||
Find:
|
||
```tsx
|
||
<Topbar
|
||
title="Regulation Q&A"
|
||
actions={
|
||
<button ...>
|
||
<Download size={13} />Export chat
|
||
</button>
|
||
}
|
||
/>
|
||
```
|
||
Replace:
|
||
```tsx
|
||
<Topbar
|
||
title={t.ragchat.topbarTitle}
|
||
actions={
|
||
<button ...>
|
||
<Download size={13} />{t.ragchat.exportBtn}
|
||
</button>
|
||
}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 3: Replace history pane header**
|
||
|
||
Find: `<div className="history-header">Quick prompts</div>`
|
||
Replace: `<div className="history-header">{t.ragchat.quickPromptsHeader}</div>`
|
||
|
||
- [ ] **Step 4: Replace textarea placeholder**
|
||
|
||
Find: `placeholder="Ask about your regulations…"`
|
||
Replace: `placeholder={t.ragchat.inputPlaceholder}`
|
||
|
||
- [ ] **Step 5: Replace citations header and empty state**
|
||
|
||
Find:
|
||
```tsx
|
||
<div className="citation-header">
|
||
Sources {citations.length > 0 && `(${citations.length})`}
|
||
</div>
|
||
```
|
||
Replace:
|
||
```tsx
|
||
<div className="citation-header">
|
||
{t.ragchat.citationsHeader}{citations.length > 0 && ` (${citations.length})`}
|
||
</div>
|
||
```
|
||
|
||
Find: `Citations will appear here after a response is generated.`
|
||
Replace: `{t.ragchat.citationsEmpty}`
|
||
|
||
- [ ] **Step 6: Replace error messages in `send()`**
|
||
|
||
Find: `'Could not reach the RAG API. Please check the backend.'`
|
||
Replace: `t.ragchat.apiError`
|
||
|
||
- [ ] **Step 7: Verify TypeScript**
|
||
|
||
```bash
|
||
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
---
|
||
|
||
## Task 10: Final verification
|
||
|
||
- [ ] **Step 1: Run TypeScript check on entire frontend**
|
||
|
||
```bash
|
||
cd frontend && npx tsc --noEmit --skipLibCheck
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 2: Manual browser verification**
|
||
|
||
Open http://localhost:5173 and verify all pages:
|
||
|
||
1. **Sidebar** — `EN` button left of theme toggle; clicking cycles EN ↔ 中 instantly for nav labels and group headers.
|
||
2. **Overview** (`/`) — hero text, step labels, screen cards, stat bar all switch language.
|
||
3. **Regulatory Signals** (`/signals`) — topbar title, refresh button, stat bar, tab labels, card headers switch language. Crawl status messages use correct language.
|
||
4. **System Status** (`/status`) — topbar, stat labels, service rows ("Online"/"在线"), config labels, runtime info, footer message switch language.
|
||
5. **Documents** (`/documents`) — topbar, filter chips, table headers, action button tooltips, confirm dialog switch language.
|
||
6. **Compliance** (`/compliance`) — topbar, new analysis button, stage labels, finding status badges, export menu, chat sidebar, history rail switch language.
|
||
7. **Regulation Q&A** (`/chat`) — topbar, "Quick prompts" header, input placeholder, citations header, empty citations message switch language.
|
||
8. **Language persists within a session** — navigate between pages, language stays consistent.
|
||
9. **Language resets on reload** — refresh the browser, language returns to `EN`.
|