mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
Feature/vscode i18n (#219)
* feat(i18n): add localization support for Chinese and update description in package.json * feat: add internationalization support to various components and views - Integrated translation functionality using `useT` from `I18nProvider` across multiple components including CustomProviderManager, EnvSetupGuide, FileList, LogViewer, PasswordInput, Select, and various views (CancelledView, ConfigView, DoneView, EmptyView, FailedView, IdleView, RunningView). - Replaced hardcoded strings with localized strings to enhance user experience for different languages. - Updated button labels, titles, and hints to reflect the new translation implementation. * fix: correct regex for validating command names in resolveBin function(预存代码,非本次 PR 引入,阻塞了 lint,这里进行修复) * chore: update open-code-review-vscode-0.1.0.vsix binary file * fix: address OpenCodeReview bot findings for i18n PR - Fix singular '1 hour ago' / '1 小时前' in GitService.formatRelative - Replace hardcoded 'en' locale with dynamic resolveLocale in CliService.install - Move hardcoded full-width colon into i18n translation strings - Narrow locale type from string to SupportedLocale in messages, stores - Extract toHtmlLang() helper to deduplicate locale→HTML lang mapping - Replace nested ternary with mapping object in ConfigView - Add missing trailing newlines to 7 files - Add jest __mocks__/vscode.js for CliService test
This commit is contained in:
parent
d939b327cf
commit
b1db14f4ea
32 changed files with 640 additions and 175 deletions
19
extensions/vscode/__mocks__/vscode.js
Normal file
19
extensions/vscode/__mocks__/vscode.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
module.exports = {
|
||||
env: { language: 'en' },
|
||||
Uri: { file: (p) => ({ fsPath: p, scheme: 'file' }), joinPath: (...args) => args.join('/') },
|
||||
workspace: { workspaceFolders: [{ uri: { fsPath: '/mock' } }], openTextDocument: () => Promise.resolve({ lineCount: 10, lineAt: () => ({ text: '' }) }) },
|
||||
window: { showErrorMessage: () => {}, showWarningMessage: () => Promise.resolve(undefined), createOutputChannel: () => ({ appendLine: () => {}, dispose: () => {} }), createWebviewPanel: () => ({ webview: { html: '', onDidReceiveMessage: () => ({ dispose: () => {} }), asWebviewUri: (u) => u }, onDidDispose: () => ({ dispose: () => {} }), reveal: () => {}, dispose: () => {} }) },
|
||||
commands: { registerCommand: () => ({ dispose: () => {} }), executeCommand: () => Promise.resolve() },
|
||||
extensions: { getExtension: () => null },
|
||||
Disposable: { from: (...ds) => ({ dispose: () => ds.forEach((d) => d.dispose()) }) },
|
||||
CommentMode: { Preview: 1 },
|
||||
CommentThreadCollapsibleState: { Expanded: 0 },
|
||||
MarkdownString: function (v) { this.value = v; this.isTrusted = false; },
|
||||
Range: function (a, b, c, d) { this.start = { line: a, character: b }; this.end = { line: c, character: d }; },
|
||||
WorkspaceEdit: function () { this.replace = () => {}; this.delete = () => {}; },
|
||||
ThemeIcon: function () {},
|
||||
ViewColumn: { One: 1 },
|
||||
EventEmitter: function () { this.event = () => {}; this.fire = () => {}; },
|
||||
CommentController: function () { this.createCommentThread = () => ({ canReply: false, label: '', collapsibleState: 0, contextValue: '', comments: [], dispose: () => {} }); },
|
||||
WebviewViewProvider: function () {},
|
||||
};
|
||||
Binary file not shown.
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "open-code-review-vscode",
|
||||
"displayName": "Open Code Review",
|
||||
"description": "AI 代码审查 —— 基于 open-code-review CLI",
|
||||
"description": "%ocr.description%",
|
||||
"version": "0.1.0",
|
||||
"publisher": "open-code-review",
|
||||
"license": "Apache-2.0",
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"activitybar": [
|
||||
{
|
||||
"id": "ocr-container",
|
||||
"title": "Open Code Review",
|
||||
"title": "%ocr.activitybar.title%",
|
||||
"icon": "resources/icon.svg"
|
||||
}
|
||||
]
|
||||
|
|
@ -35,34 +35,34 @@
|
|||
{
|
||||
"id": "ocr.sidebar",
|
||||
"type": "webview",
|
||||
"name": "Code Review"
|
||||
"name": "%ocr.sidebar.name%"
|
||||
}
|
||||
]
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"command": "ocr.review.start",
|
||||
"title": "OCR: 开始代码审查"
|
||||
"title": "%ocr.review.start%"
|
||||
},
|
||||
{
|
||||
"command": "ocr.review.cancel",
|
||||
"title": "OCR: 取消审查"
|
||||
"title": "%ocr.review.cancel%"
|
||||
},
|
||||
{
|
||||
"command": "ocr.config.open",
|
||||
"title": "OCR: 打开配置"
|
||||
"title": "%ocr.config.open%"
|
||||
},
|
||||
{
|
||||
"command": "ocr.comment.apply",
|
||||
"title": "应用"
|
||||
"title": "%ocr.comment.apply%"
|
||||
},
|
||||
{
|
||||
"command": "ocr.comment.discard",
|
||||
"title": "忽略"
|
||||
"title": "%ocr.comment.discard%"
|
||||
},
|
||||
{
|
||||
"command": "ocr.comment.falsePositive",
|
||||
"title": "误报"
|
||||
"title": "%ocr.comment.falsePositive%"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
|
|
|
|||
11
extensions/vscode/package.nls.json
Normal file
11
extensions/vscode/package.nls.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"ocr.activitybar.title": "Open Code Review",
|
||||
"ocr.sidebar.name": "Code Review",
|
||||
"ocr.review.start": "OCR: Start Code Review",
|
||||
"ocr.review.cancel": "OCR: Cancel Review",
|
||||
"ocr.config.open": "OCR: Open Configuration",
|
||||
"ocr.comment.apply": "Apply",
|
||||
"ocr.comment.discard": "Discard",
|
||||
"ocr.comment.falsePositive": "False Positive",
|
||||
"ocr.description": "AI Code Review — powered by open-code-review CLI"
|
||||
}
|
||||
11
extensions/vscode/package.nls.zh-cn.json
Normal file
11
extensions/vscode/package.nls.zh-cn.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"ocr.activitybar.title": "Open Code Review",
|
||||
"ocr.sidebar.name": "Code Review",
|
||||
"ocr.review.start": "OCR: 开始代码审查",
|
||||
"ocr.review.cancel": "OCR: 取消审查",
|
||||
"ocr.config.open": "OCR: 打开配置",
|
||||
"ocr.comment.apply": "应用",
|
||||
"ocr.comment.discard": "忽略",
|
||||
"ocr.comment.falsePositive": "误报",
|
||||
"ocr.description": "AI 代码审查 —— 基于 open-code-review CLI"
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { t, resolveLocale, SupportedLocale } from '../../shared/i18n';
|
||||
import * as vscode from 'vscode';
|
||||
import { ReviewComment, CommentStatus, CommentSyncState } from '../../shared/types';
|
||||
import { COMMENT_CONTROLLER_ID } from '../../shared/constants';
|
||||
|
|
@ -13,8 +14,11 @@ export class CommentProvider {
|
|||
private offsets = new LineOffsetTracker();
|
||||
private syncListeners: Array<(s: CommentSyncState[]) => void> = [];
|
||||
|
||||
private locale: SupportedLocale;
|
||||
|
||||
constructor(private extensionUri: vscode.Uri) {
|
||||
this.controller = vscode.comments.createCommentController(COMMENT_CONTROLLER_ID, 'Open Code Review');
|
||||
this.locale = resolveLocale(vscode.env.language);
|
||||
this.controller = vscode.comments.createCommentController(COMMENT_CONTROLLER_ID, t(this.locale, 'ext.commentController'));
|
||||
}
|
||||
|
||||
onSync(fn: (s: CommentSyncState[]) => void): void {
|
||||
|
|
@ -58,10 +62,10 @@ export class CommentProvider {
|
|||
const body = this.renderBody(c, i, 'pending');
|
||||
const thread = this.controller.createCommentThread(doc.uri, range, [{
|
||||
body, mode: vscode.CommentMode.Preview,
|
||||
author: { name: '⏳ [未处理]' },
|
||||
author: { name: t(this.locale, 'ext.comment.pending') },
|
||||
}]);
|
||||
thread.canReply = false;
|
||||
thread.label = `Code Review (${i + 1} / ${this.comments.length})`;
|
||||
thread.label = `${t(this.locale, 'ext.comment.threadLabel')} (${i + 1} / ${this.comments.length})`;
|
||||
// 有代码建议 → 'pending'(显示应用+忽略);无建议 → 'pendingNoSuggestion'(仅忽略)
|
||||
thread.contextValue = this.hasSuggestion(c) ? 'pending' : 'pendingNoSuggestion';
|
||||
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded;
|
||||
|
|
@ -82,7 +86,7 @@ export class CommentProvider {
|
|||
if (this.hasSuggestion(c)) {
|
||||
md += `\n***\n\`\`\`diff\n${c.suggestionCode}\n\`\`\``;
|
||||
} else {
|
||||
md += `\n***\n_💡 无代码建议,请手动处理_`;
|
||||
md += `\n***\n${t(this.locale, 'ext.comment.noSuggestion')}`;
|
||||
}
|
||||
const s = new vscode.MarkdownString(md);
|
||||
s.isTrusted = true;
|
||||
|
|
@ -100,7 +104,7 @@ export class CommentProvider {
|
|||
const start = Math.max(0, this.offsets.adjusted(c.path, c.startLine) - 1);
|
||||
const end = Math.min(doc.lineCount - 1, this.offsets.adjusted(c.path, c.endLine) - 1);
|
||||
if (end < start) {
|
||||
vscode.window.showErrorMessage('应用失败:代码位置已失效,请刷新后重试。');
|
||||
vscode.window.showErrorMessage(t(this.locale, 'ext.comment.applyFailedStale'));
|
||||
return;
|
||||
}
|
||||
const range = new vscode.Range(start, 0, end, doc.lineAt(end).text.length);
|
||||
|
|
@ -113,7 +117,7 @@ export class CommentProvider {
|
|||
else edit.delete(uri, range);
|
||||
const ok = await vscode.workspace.applyEdit(edit);
|
||||
if (!ok) {
|
||||
vscode.window.showErrorMessage('应用失败:无法修改文件,请检查文件是否被占用或处于只读状态。');
|
||||
vscode.window.showErrorMessage(t(this.locale, 'ext.comment.applyFailedLocked'));
|
||||
return;
|
||||
}
|
||||
await doc.save();
|
||||
|
|
@ -129,7 +133,12 @@ export class CommentProvider {
|
|||
this.status.set(index, status);
|
||||
const thread = this.threads.get(index);
|
||||
if (thread) {
|
||||
const label = { applied: '✅ [已应用]', discarded: '✅ [已忽略]', falsePositive: '✅ [已误报]', pending: '⏳ [未处理]' }[status];
|
||||
const label = {
|
||||
applied: t(this.locale, 'ext.comment.statusApplied'),
|
||||
discarded: t(this.locale, 'ext.comment.statusDiscarded'),
|
||||
falsePositive: t(this.locale, 'ext.comment.statusFalsePositive'),
|
||||
pending: t(this.locale, 'ext.comment.pending'),
|
||||
}[status];
|
||||
thread.comments = [{ ...thread.comments[0], author: { name: label }, body: this.renderBody(this.comments[index], index, status) }] as any;
|
||||
thread.contextValue = status;
|
||||
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed;
|
||||
|
|
@ -141,7 +150,7 @@ export class CommentProvider {
|
|||
const thread = this.threads.get(index);
|
||||
if (!thread) {
|
||||
const c = this.comments[index];
|
||||
if (c) vscode.window.showWarningMessage(`无法定位到 ${c.path}:该路径不是可打开的文件。`);
|
||||
if (c) vscode.window.showWarningMessage(`${t(this.locale, 'ext.comment.jumpFailed')}${c.path}${t(this.locale, 'ext.comment.jumpNotAFile')}`);
|
||||
return;
|
||||
}
|
||||
await vscode.window.showTextDocument(thread.uri, { selection: thread.range, preview: false });
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { resolveLocale, t, toHtmlLang } from '../../shared/i18n';
|
||||
import * as vscode from 'vscode';
|
||||
import { ConfigPanelFocus, isConfigReady } from '../../shared/configUtils';
|
||||
import { ConfigPanelHostToWebview, WebviewToHost } from '../../shared/messages';
|
||||
|
|
@ -30,7 +31,7 @@ export class ConfigPanelProvider implements vscode.Disposable {
|
|||
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
PANEL_VIEW_TYPE,
|
||||
'模型配置',
|
||||
t(resolveLocale(vscode.env.language), 'ext.configPanelTitle'),
|
||||
vscode.ViewColumn.One,
|
||||
{ enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [this.extensionUri] },
|
||||
);
|
||||
|
|
@ -80,12 +81,14 @@ export class ConfigPanelProvider implements vscode.Disposable {
|
|||
const config = this.config.read();
|
||||
const cached = this.cli.getCachedEnvironment();
|
||||
const skipEnvCheck = focus?.step === 2 || isConfigReady(config);
|
||||
const locale = resolveLocale(vscode.env.language);
|
||||
this.post({
|
||||
type: 'configPanelInit',
|
||||
config,
|
||||
focus: focus ?? null,
|
||||
env: cached,
|
||||
skipEnvCheck,
|
||||
locale,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
@ -106,12 +109,13 @@ export class ConfigPanelProvider implements vscode.Disposable {
|
|||
break;
|
||||
}
|
||||
case 'deleteCustomProvider': {
|
||||
const locale = resolveLocale(vscode.env.language);
|
||||
const confirmed = await vscode.window.showWarningMessage(
|
||||
`确定删除自定义 Provider「${msg.name}」?`,
|
||||
t(locale, 'ext.deleteProviderConfirm').replace('{name}', msg.name),
|
||||
{ modal: true },
|
||||
'删除',
|
||||
t(locale, 'ext.deleteProviderConfirmBtn'),
|
||||
);
|
||||
if (confirmed !== '删除') break;
|
||||
if (confirmed !== t(locale, 'ext.deleteProviderConfirmBtn')) break;
|
||||
this.notifyConfig(this.config.deleteCustomProvider(msg.name));
|
||||
break;
|
||||
}
|
||||
|
|
@ -144,8 +148,10 @@ export class ConfigPanelProvider implements vscode.Disposable {
|
|||
private html(webview: vscode.Webview): string {
|
||||
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'out', 'configPanel.js'));
|
||||
const nonce = String(Date.now());
|
||||
const resolved = resolveLocale(vscode.env.language);
|
||||
const lang = toHtmlLang(resolved);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head>
|
||||
<html lang="${lang}"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';">
|
||||
</head><body><div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { resolveLocale, toHtmlLang } from '../../shared/i18n';
|
||||
import * as vscode from 'vscode';
|
||||
import { ConfigPanelFocus } from '../../shared/configUtils';
|
||||
import { HostToWebview, WebviewToHost } from '../../shared/messages';
|
||||
|
|
@ -48,7 +49,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||
case 'ready': {
|
||||
const config = this.config.read();
|
||||
const gitState = await this.git.getState('workspace');
|
||||
this.post({ type: 'init', config, gitState });
|
||||
const locale = resolveLocale(vscode.env.language);
|
||||
this.post({ type: 'init', config, gitState, locale });
|
||||
break;
|
||||
}
|
||||
case 'getGitState': {
|
||||
|
|
@ -108,8 +110,10 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||
private html(webview: vscode.Webview): string {
|
||||
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'out', 'webview.js'));
|
||||
const nonce = String(Date.now());
|
||||
const resolved = resolveLocale(vscode.env.language);
|
||||
const lang = toHtmlLang(resolved);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head>
|
||||
<html lang="${lang}"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';">
|
||||
</head><body><div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { t, resolveLocale } from '../../shared/i18n';
|
||||
import * as vscode from 'vscode';
|
||||
import { spawn } from 'child_process';
|
||||
import { CliResult, CliRunOptions, EnvCheckResult, LogLine } from '../../shared/types';
|
||||
import { buildReviewArgs, extractCliError, parseCliResult, parseLogLine } from './cliParse';
|
||||
|
|
@ -90,7 +92,8 @@ export class CliService {
|
|||
proc.on('error', (err) => { onLog({ text: String(err), level: 'error' }); resolve(false); });
|
||||
proc.on('close', (code) => {
|
||||
emitLines('', 'info', true);
|
||||
onLog({ text: code === 0 ? '✓ 安装完成' : `✗ 安装失败 (exit ${code})`, level: code === 0 ? 'info' : 'error' });
|
||||
const locale = resolveLocale(vscode.env.language);
|
||||
onLog({ text: code === 0 ? t(locale, 'ext.cli.installOk') : `${t(locale, 'ext.cli.installFail')}${code})`, level: code === 0 ? 'info' : 'error' });
|
||||
if (code === 0) this.invalidateEnvironmentCache();
|
||||
resolve(code === 0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { t, resolveLocale } from '../../shared/i18n';
|
||||
import * as vscode from 'vscode';
|
||||
import { execFile } from 'child_process';
|
||||
import { GitState, CommitInfo, FileChange, ReviewMode } from '../../shared/types';
|
||||
|
|
@ -167,7 +168,7 @@ export class GitService {
|
|||
if (opts.mode === 'workspace') {
|
||||
left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : 'HEAD');
|
||||
right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : fileUri;
|
||||
label = '工作区 ↔ HEAD';
|
||||
label = t(resolveLocale(vscode.env.language), 'ext.git.workspaceVsHead');
|
||||
} else if (opts.mode === 'commit' && opts.commit) {
|
||||
left = api.toGitUri(fileUri, opts.status === 'added' ? emptyRef : `${opts.commit}^`);
|
||||
right = opts.status === 'deleted' ? api.toGitUri(fileUri, emptyRef) : api.toGitUri(fileUri, opts.commit);
|
||||
|
|
@ -211,11 +212,13 @@ function runGit(cwd: string, args: string[]): Promise<string> {
|
|||
|
||||
function formatRelative(date?: Date): string {
|
||||
if (!date) return '';
|
||||
const locale = resolveLocale(vscode.env.language);
|
||||
const diff = Date.now() - date.getTime();
|
||||
const h = Math.floor(diff / 3.6e6);
|
||||
if (h < 1) return '刚刚';
|
||||
if (h < 24) return `${h} 小时前`;
|
||||
if (h < 1) return t(locale, 'ext.git.justNow');
|
||||
if (h === 1) return t(locale, 'ext.git.hourAgo');
|
||||
if (h < 24) return t(locale, 'ext.git.hoursAgo').replace('{h}', String(h));
|
||||
const d = Math.floor(h / 24);
|
||||
if (d === 1) return '昨天';
|
||||
return `${d} 天前`;
|
||||
if (d === 1) return t(locale, 'ext.git.yesterday');
|
||||
return t(locale, 'ext.git.daysAgo').replace('{d}', String(d));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export function resolveBin(name: string): string {
|
|||
let resolved = name;
|
||||
try {
|
||||
const shell = process.env.SHELL || '/bin/zsh';
|
||||
if (!/^[a-zA-Z0-9._\/-]+$/.test(name)) return name;
|
||||
if (!/^[a-zA-Z0-9._/-]+$/.test(name)) return name;
|
||||
const res = spawnSync(shell, ['-ilc', `command -v '${name.replace(/'/g, "'\\''")}'`], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
|
|
|
|||
332
extensions/vscode/src/shared/i18n.ts
Normal file
332
extensions/vscode/src/shared/i18n.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
/**
|
||||
* Supported display locales for the extension UI.
|
||||
* Add new entries here and in the `messages` dictionary below to extend.
|
||||
*
|
||||
* - `en` — English (default, fallback for all unrecognized locales)
|
||||
* - `zh-cn` — Simplified Chinese (matches VS Code `zh-cn` / `zh-CN`)
|
||||
*/
|
||||
export type SupportedLocale = 'en' | 'zh-cn';
|
||||
|
||||
const messages: Record<SupportedLocale, Record<string, string>> = {
|
||||
en: {
|
||||
// ── IdleView ──
|
||||
'view.idle.configFirst': 'Configure model first',
|
||||
'view.idle.reviewing': 'Reviewing…',
|
||||
'view.idle.selectBranch': 'Select comparison branch',
|
||||
'view.idle.selectCommit': 'Select a commit',
|
||||
'view.idle.noFiles': 'No files to review',
|
||||
'view.idle.reviewAll': 'Review all changes',
|
||||
'view.idle.workspace': 'Workspace',
|
||||
'view.idle.branch': 'Branch Compare',
|
||||
'view.idle.commit': 'Single Commit',
|
||||
'view.idle.baseRef': 'Base ref',
|
||||
'view.idle.targetRef': 'Target ref',
|
||||
'view.idle.chooseBranch': 'Choose branch',
|
||||
'view.idle.commitHistory': 'Commit history',
|
||||
'view.idle.customPrompt': 'Custom review prompt (optional)',
|
||||
'view.idle.manageCustom': 'Manage custom providers',
|
||||
'view.idle.modelConfig': 'Model config',
|
||||
|
||||
// ── RunningView ──
|
||||
'view.running.reviewLog': 'Review log',
|
||||
'view.running.cancel': 'Cancel',
|
||||
|
||||
// ── DoneView ──
|
||||
'view.done.comments': 'comments',
|
||||
'view.done.files': 'files',
|
||||
'view.done.processLog': 'Process log',
|
||||
|
||||
// ── EmptyView ──
|
||||
'view.empty.noIssues': 'No issues found · Passed',
|
||||
'view.empty.processLog': 'Process log',
|
||||
|
||||
// ── CancelledView ──
|
||||
'view.cancelled.title': 'Review cancelled',
|
||||
|
||||
// ── FailedView ──
|
||||
'view.failed.title': 'Review failed.',
|
||||
'view.failed.checkConfig': 'Please check model configuration and retry.',
|
||||
'view.failed.checkApiKey': 'Please check API key and network connection.',
|
||||
'view.failed.retry': 'Retry',
|
||||
|
||||
// ── ConfigView ──
|
||||
'view.config.title': 'Model Configuration',
|
||||
'view.config.desc': 'Connect an LLM provider to start code review',
|
||||
'view.config.close': 'Close',
|
||||
'view.config.step1': 'Environment Setup',
|
||||
'view.config.step2': 'Provider Config',
|
||||
'view.config.checking': 'ocr checking…',
|
||||
'view.config.notInstalled': 'ocr not installed',
|
||||
'view.config.official': 'Official Provider',
|
||||
'view.config.custom': 'Custom Provider',
|
||||
'view.config.currentUse': 'Currently using',
|
||||
'view.config.notConfigured': 'No provider configured',
|
||||
'view.config.officialLabel': 'Official',
|
||||
'view.config.customLabel': 'Custom',
|
||||
'view.config.legacyLabel': 'Legacy',
|
||||
'view.config.model': 'Model',
|
||||
'view.config.customModel': 'Enter custom model…',
|
||||
'view.config.apiKey': 'API Key',
|
||||
'view.config.apiKeyEnvHint': 'Also available via env var',
|
||||
'view.config.apiKeySaved': 'Saved (leave blank to keep)',
|
||||
'view.config.testing': 'Testing connection…',
|
||||
'view.config.testOk': '✓ Connected',
|
||||
'view.config.testFail': '✗ Connection failed',
|
||||
'view.config.previous': 'Previous',
|
||||
'view.config.testFailDetail': '✗ Connection failed: {message}',
|
||||
'view.config.test': 'Test Connection',
|
||||
'view.config.save': 'Save',
|
||||
'view.config.continueProvider': 'Continue to Provider Config',
|
||||
'view.config.providerName': 'Provider Name',
|
||||
'view.config.protocol': 'Protocol',
|
||||
'view.config.baseUrl': 'Base URL',
|
||||
'view.config.modelList': 'Model list',
|
||||
'view.config.modelListPlaceholder': 'Comma-separated, e.g. model-a, model-b',
|
||||
'view.config.authHeader': 'Auth Header',
|
||||
'view.config.authHeaderHint': 'Optional x-api-key or authorization for Anthropic protocol',
|
||||
'view.config.authHeaderDefault': 'Default (Authorization)',
|
||||
'view.config.backToList': '← Back to list',
|
||||
'view.config.optional': '(optional)',
|
||||
'view.config.ocrVersionTooltip': 'Open Code Review CLI Version',
|
||||
|
||||
// ── EnvSetupGuide ──
|
||||
'view.env.installing': 'Installing ocr CLI…',
|
||||
'view.env.checking': 'Checking, please wait…',
|
||||
'view.env.ready': 'Environment is ready. Continue to Provider Config.',
|
||||
'view.env.stepLead': 'Complete each step in order. Move to the next after each passes.',
|
||||
'view.env.nodeHint': 'Node.js not detected. Visit nodejs.org to install the LTS version, then restart VS Code.',
|
||||
'view.env.npmHint': 'npm not detected. npm is usually bundled with Node.js — verify your Node installation.',
|
||||
'view.env.ocrHint': 'Install open-code-review globally in your terminal, or click "One-Click Install" below.',
|
||||
'view.env.oneClickInstall': 'One-Click Install',
|
||||
'view.env.redetect': 'Re-detect',
|
||||
'view.env.checkingStatus': 'Checking',
|
||||
'view.env.readyStatus': 'Ready',
|
||||
'view.env.notReady': 'Not ready',
|
||||
'view.env.pass': 'Pass',
|
||||
'view.env.fail': 'Fail',
|
||||
'view.env.waitPrev': 'Waiting for previous',
|
||||
'view.env.copy': 'Copy',
|
||||
'view.env.copiedToast': 'Copied ✓',
|
||||
|
||||
// ── CustomProviderManager ──
|
||||
'cmp.custom.title': 'Custom Providers',
|
||||
'cmp.custom.desc': 'Manage self-hosted LLM gateways and compatible endpoints. Switch the active review model.',
|
||||
'cmp.custom.add': 'Add',
|
||||
'cmp.custom.empty': 'No custom providers',
|
||||
'cmp.custom.addFirst': 'Add custom provider',
|
||||
'cmp.custom.currentUse': 'Currently using',
|
||||
'cmp.custom.model': 'Model',
|
||||
'cmp.custom.edit': 'Edit',
|
||||
'cmp.custom.setCurrent': 'Set as current',
|
||||
'cmp.custom.delete': 'Delete',
|
||||
|
||||
// ── FileList ──
|
||||
'cmp.fileList.pending': 'Pending files',
|
||||
'cmp.fileList.noChanges': 'No changed files',
|
||||
'cmp.fileList.viewDiff': 'Click to view diff',
|
||||
|
||||
// ── LogViewer ──
|
||||
'cmp.log.waiting': 'Waiting for output',
|
||||
|
||||
// ── CommentCard ──
|
||||
'cmp.comment.view': 'View',
|
||||
'cmp.comment.discard': 'Discard',
|
||||
|
||||
// ── PasswordInput ──
|
||||
'cmp.password.hideSecret': 'Hide secret',
|
||||
'cmp.password.showSecret': 'Show secret',
|
||||
|
||||
// ── Select ──
|
||||
'cmp.select.placeholder': 'Select',
|
||||
|
||||
// ── Extension ──
|
||||
'ext.commentController': 'Open Code Review',
|
||||
'ext.configPanelTitle': 'Model Configuration',
|
||||
'ext.config.legacyDisplayName': 'Legacy LLM Endpoint',
|
||||
'ext.comment.threadLabel': 'Code Review',
|
||||
'ext.comment.pending': '⏳ [Pending]',
|
||||
'ext.comment.noSuggestion': '_💡 No code suggestion, please handle manually_',
|
||||
'ext.comment.applyFailedStale': 'Apply failed: code location is stale, please refresh and retry.',
|
||||
'ext.comment.applyFailedLocked': 'Apply failed: cannot modify file, check if it is read-only or locked.',
|
||||
'ext.comment.statusApplied': '✅ [Applied]',
|
||||
'ext.comment.statusDiscarded': '✅ [Discarded]',
|
||||
'ext.comment.statusFalsePositive': '✅ [False Positive]',
|
||||
'ext.comment.jumpFailed': 'Cannot locate ',
|
||||
'ext.comment.jumpNotAFile': ': is not an openable file.',
|
||||
'ext.deleteProviderConfirm': 'Delete custom provider "{name}"?',
|
||||
'ext.deleteProviderConfirmBtn': 'Delete',
|
||||
'ext.git.justNow': 'just now',
|
||||
'ext.git.hoursAgo': '{h} hours ago',
|
||||
'ext.git.hourAgo': '1 hour ago',
|
||||
'ext.git.yesterday': 'yesterday',
|
||||
'ext.git.daysAgo': '{d} days ago',
|
||||
'ext.git.workspaceVsHead': 'Workspace ↔ HEAD',
|
||||
'ext.cli.installOk': '✓ Install complete',
|
||||
'ext.cli.installFail': '✗ Install failed (exit ',
|
||||
},
|
||||
|
||||
'zh-cn': {
|
||||
'view.idle.configFirst': '请先配置模型',
|
||||
'view.idle.reviewing': '审查中…',
|
||||
'view.idle.selectBranch': '请选择对比分支',
|
||||
'view.idle.selectCommit': '请选择提交',
|
||||
'view.idle.noFiles': '无可审查文件',
|
||||
'view.idle.reviewAll': '审查所有变更',
|
||||
'view.idle.workspace': '工作区',
|
||||
'view.idle.branch': '分支对比',
|
||||
'view.idle.commit': '单次提交',
|
||||
'view.idle.baseRef': '基础引用',
|
||||
'view.idle.targetRef': '目标引用',
|
||||
'view.idle.chooseBranch': '选择分支',
|
||||
'view.idle.commitHistory': '提交历史',
|
||||
'view.idle.customPrompt': '自定义审查提示词(可选)',
|
||||
'view.idle.manageCustom': '管理自定义 Provider',
|
||||
'view.idle.modelConfig': '模型配置',
|
||||
|
||||
'view.running.reviewLog': '审查日志',
|
||||
'view.running.cancel': '取消',
|
||||
|
||||
'view.done.comments': '条评论',
|
||||
'view.done.files': '个文件',
|
||||
'view.done.processLog': '过程日志',
|
||||
|
||||
'view.empty.noIssues': '未发现问题 · 已通过',
|
||||
'view.empty.processLog': '过程日志',
|
||||
|
||||
'view.cancelled.title': '审查已取消',
|
||||
|
||||
'view.failed.title': '审查失败。',
|
||||
'view.failed.checkConfig': '请检查模型配置后重试。',
|
||||
'view.failed.checkApiKey': '请检查 API Key 和网络连接。',
|
||||
'view.failed.retry': '重试',
|
||||
|
||||
'view.config.title': '模型配置',
|
||||
'view.config.desc': '连接 LLM Provider 以开始代码审查',
|
||||
'view.config.close': '关闭',
|
||||
'view.config.step1': '环境检测',
|
||||
'view.config.step2': 'Provider 配置',
|
||||
'view.config.checking': 'ocr 检测中…',
|
||||
'view.config.notInstalled': 'ocr 未安装',
|
||||
'view.config.official': '官方 Provider',
|
||||
'view.config.custom': '自定义 Provider',
|
||||
'view.config.currentUse': '当前使用',
|
||||
'view.config.notConfigured': '尚未配置 Provider',
|
||||
'view.config.officialLabel': '官方',
|
||||
'view.config.customLabel': '自定义',
|
||||
'view.config.legacyLabel': 'Legacy',
|
||||
'view.config.model': '模型',
|
||||
'view.config.customModel': '输入自定义模型…',
|
||||
'view.config.apiKey': 'API 密钥',
|
||||
'view.config.apiKeyEnvHint': '也可通过环境变量',
|
||||
'view.config.apiKeySaved': '已保存(留空保持不变)',
|
||||
'view.config.testing': '正在测试连接…',
|
||||
'view.config.testOk': '✓ 连接成功',
|
||||
'view.config.testFail': '✗ 连接失败',
|
||||
'view.config.testFailDetail': '✗ 连接失败:{message}',
|
||||
'view.config.previous': '上一步',
|
||||
'view.config.test': '测试连接',
|
||||
'view.config.save': '保存',
|
||||
'view.config.continueProvider': '继续配置 Provider',
|
||||
'view.config.providerName': 'Provider 名称',
|
||||
'view.config.protocol': '协议',
|
||||
'view.config.baseUrl': 'Base URL',
|
||||
'view.config.modelList': '模型列表',
|
||||
'view.config.modelListPlaceholder': '逗号分隔,如 model-a, model-b',
|
||||
'view.config.authHeader': 'Auth Header',
|
||||
'view.config.authHeaderHint': 'Anthropic 协议下可选 x-api-key 或 authorization',
|
||||
'view.config.authHeaderDefault': '默认 (Authorization)',
|
||||
'view.config.backToList': '← 返回列表',
|
||||
'view.config.optional': '(可选)',
|
||||
'view.config.ocrVersionTooltip': 'Open Code Review CLI 版本',
|
||||
|
||||
'view.env.installing': '正在安装 ocr CLI…',
|
||||
'view.env.checking': '正在检测,请稍候…',
|
||||
'view.env.ready': '环境已就绪,可继续配置 Provider。',
|
||||
'view.env.stepLead': '按顺序完成环境准备,通过一项后再进行下一项。',
|
||||
'view.env.nodeHint': '未检测到 Node.js。请前往 nodejs.org 安装 LTS 版本,完成后重启 VS Code。',
|
||||
'view.env.npmHint': '未检测到 npm。npm 通常随 Node 一起安装,请确认 Node 安装完整。',
|
||||
'view.env.ocrHint': '在终端全局安装 open-code-review,或点击下方「一键安装」。',
|
||||
'view.env.oneClickInstall': '一键安装',
|
||||
'view.env.redetect': '重新检测',
|
||||
'view.env.checkingStatus': '检测中',
|
||||
'view.env.readyStatus': '就绪',
|
||||
'view.env.notReady': '未就绪',
|
||||
'view.env.pass': '通过',
|
||||
'view.env.fail': '未通过',
|
||||
'view.env.waitPrev': '等待上一步',
|
||||
'view.env.copy': '复制',
|
||||
'view.env.copiedToast': '已复制到剪贴板 ✓',
|
||||
|
||||
'cmp.custom.title': '自定义 Provider',
|
||||
'cmp.custom.desc': '管理自建 LLM 网关与兼容端点,可切换为当前审查模型。',
|
||||
'cmp.custom.add': '添加',
|
||||
'cmp.custom.empty': '暂无自定义 Provider',
|
||||
'cmp.custom.addFirst': '添加自定义 Provider',
|
||||
'cmp.custom.currentUse': '当前使用',
|
||||
'cmp.custom.model': '模型',
|
||||
'cmp.custom.edit': '编辑',
|
||||
'cmp.custom.setCurrent': '设为当前',
|
||||
'cmp.custom.delete': '删除',
|
||||
|
||||
'cmp.fileList.pending': '待审查文件',
|
||||
'cmp.fileList.noChanges': '无变更文件',
|
||||
'cmp.fileList.viewDiff': '点击查看 diff',
|
||||
|
||||
'cmp.log.waiting': '等待输出',
|
||||
|
||||
'cmp.comment.view': '查看',
|
||||
'cmp.comment.discard': '忽略',
|
||||
|
||||
'cmp.password.hideSecret': '隐藏密钥',
|
||||
'cmp.password.showSecret': '显示密钥',
|
||||
|
||||
'cmp.select.placeholder': '请选择',
|
||||
|
||||
'ext.commentController': 'Open Code Review',
|
||||
'ext.configPanelTitle': '模型配置',
|
||||
'ext.config.legacyDisplayName': 'Legacy LLM 端点',
|
||||
'ext.comment.threadLabel': 'Code Review',
|
||||
'ext.comment.pending': '⏳ [未处理]',
|
||||
'ext.comment.noSuggestion': '_💡 无代码建议,请手动处理_',
|
||||
'ext.comment.applyFailedStale': '应用失败:代码位置已失效,请刷新后重试。',
|
||||
'ext.comment.applyFailedLocked': '应用失败:无法修改文件,请检查文件是否被占用或处于只读状态。',
|
||||
'ext.comment.statusApplied': '✅ [已应用]',
|
||||
'ext.comment.statusDiscarded': '✅ [已忽略]',
|
||||
'ext.comment.statusFalsePositive': '✅ [已误报]',
|
||||
'ext.comment.jumpFailed': '无法定位到 ',
|
||||
'ext.comment.jumpNotAFile': ':该路径不是可打开的文件。',
|
||||
'ext.deleteProviderConfirm': '确定删除自定义 Provider「{name}」?',
|
||||
'ext.deleteProviderConfirmBtn': '删除',
|
||||
'ext.git.justNow': '刚刚',
|
||||
'ext.git.hoursAgo': '{h} 小时前',
|
||||
'ext.git.hourAgo': '1 小时前',
|
||||
'ext.git.yesterday': '昨天',
|
||||
'ext.git.daysAgo': '{d} 天前',
|
||||
'ext.git.workspaceVsHead': '工作区 ↔ HEAD',
|
||||
'ext.cli.installOk': '✓ 安装完成',
|
||||
'ext.cli.installFail': '✗ 安装失败 (exit ',
|
||||
},
|
||||
};
|
||||
|
||||
export function t(locale: SupportedLocale, key: string): string {
|
||||
return messages[locale]?.[key] ?? messages.en[key] ?? key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a VS Code locale string to a {@link SupportedLocale}.
|
||||
* Only `zh-cn` (case-insensitive) maps to Simplified Chinese;
|
||||
* other Chinese variants like `zh-tw` / `zh-hk` fall back to English
|
||||
* until their translations are added.
|
||||
*/
|
||||
export function resolveLocale(raw: string): SupportedLocale {
|
||||
if (raw.toLowerCase() === 'zh-cn') return 'zh-cn';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a {@link SupportedLocale} to the BCP 47 HTML `lang` attribute value.
|
||||
* `zh-cn` → `zh-CN`, others stay as-is.
|
||||
*/
|
||||
export function toHtmlLang(locale: SupportedLocale): string {
|
||||
return locale === 'zh-cn' ? 'zh-CN' : locale;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import {
|
|||
OcrConfig, ReviewMode, ReviewState,
|
||||
} from './types';
|
||||
import { ConfigPanelFocus } from './configUtils';
|
||||
import { SupportedLocale } from './i18n';
|
||||
|
||||
export type WebviewToHost =
|
||||
| { type: 'ready' }
|
||||
|
|
@ -28,7 +29,7 @@ export type WebviewToHost =
|
|||
| { type: 'commentAction'; index: number; action: 'apply' | 'discard' | 'falsePositive' };
|
||||
|
||||
export type HostToWebview =
|
||||
| { type: 'init'; config: OcrConfig | null; gitState: GitState }
|
||||
| { type: 'init'; config: OcrConfig | null; gitState: GitState; locale: SupportedLocale }
|
||||
| { type: 'gitState'; gitState: GitState }
|
||||
| { type: 'modeFiles'; mode: ReviewMode; files: FileChange[] }
|
||||
| { type: 'logLine'; line: LogLine }
|
||||
|
|
@ -38,7 +39,7 @@ export type HostToWebview =
|
|||
| { type: 'commentSync'; comments: CommentSyncState[] };
|
||||
|
||||
export type ConfigPanelHostToWebview =
|
||||
| { type: 'configPanelInit'; config: OcrConfig | null; focus?: ConfigPanelFocus | null; env?: EnvCheckResult | null; skipEnvCheck?: boolean }
|
||||
| { type: 'configPanelInit'; config: OcrConfig | null; focus?: ConfigPanelFocus | null; env?: EnvCheckResult | null; skipEnvCheck?: boolean; locale: SupportedLocale }
|
||||
| { type: 'configPanelFocus'; focus?: ConfigPanelFocus | null }
|
||||
| { type: 'config'; config: OcrConfig | null }
|
||||
| { type: 'connectionResult'; ok: boolean; message?: string }
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { I18nContext, resolveLocale } from './I18nProvider';
|
||||
import { useEffect, useReducer } from 'preact/hooks';
|
||||
import { reducer, initialState } from './store';
|
||||
import { bridge } from './bridge';
|
||||
|
|
@ -38,32 +39,34 @@ export function App() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div class="ocr-root">
|
||||
<div class="action-region">
|
||||
<IdleView gitState={state.gitState} modeFiles={state.modeFiles} filesLoading={state.filesLoading}
|
||||
configured={configured} onModeChange={onModeChange} onRequestModeFiles={requestModeFiles}
|
||||
onOpenFile={openFile} onStart={start} onOpenConfig={() => bridge.post({ type: 'openConfigPanel' })}
|
||||
onOpenCustomProviders={() => bridge.post({
|
||||
type: 'openConfigPanel',
|
||||
focus: { step: 2, tab: 'custom', customView: 'list' },
|
||||
})}
|
||||
running={state.view === 'running'} />
|
||||
<I18nContext.Provider value={resolveLocale(state.locale)}>
|
||||
<div class="ocr-root">
|
||||
<div class="action-region">
|
||||
<IdleView gitState={state.gitState} modeFiles={state.modeFiles} filesLoading={state.filesLoading}
|
||||
configured={configured} onModeChange={onModeChange} onRequestModeFiles={requestModeFiles}
|
||||
onOpenFile={openFile} onStart={start} onOpenConfig={() => bridge.post({ type: 'openConfigPanel' })}
|
||||
onOpenCustomProviders={() => bridge.post({
|
||||
type: 'openConfigPanel',
|
||||
focus: { step: 2, tab: 'custom', customView: 'list' },
|
||||
})}
|
||||
running={state.view === 'running'} />
|
||||
|
||||
{state.view !== 'idle' && (
|
||||
<div class="result-region">
|
||||
{state.view === 'running' && <RunningView logs={state.logs} onCancel={() => bridge.post({ type: 'cancelReview' })} />}
|
||||
{state.view === 'done' && state.session.result && (
|
||||
<DoneView result={state.session.result} commentStatus={state.commentStatus} logs={state.logs}
|
||||
canJump={state.reviewMode === 'workspace'}
|
||||
onOpen={(i) => bridge.post({ type: 'jumpToComment', index: i })}
|
||||
onAction={(i, action) => bridge.post({ type: 'commentAction', index: i, action })} />
|
||||
)}
|
||||
{state.view === 'empty' && <EmptyView logs={state.logs} />}
|
||||
{state.view === 'cancelled' && <CancelledView />}
|
||||
{state.view === 'failed' && <FailedView error={state.session.error} onRetry={() => start({ mode: 'workspace' })} />}
|
||||
</div>
|
||||
)}
|
||||
{state.view !== 'idle' && (
|
||||
<div class="result-region">
|
||||
{state.view === 'running' && <RunningView logs={state.logs} onCancel={() => bridge.post({ type: 'cancelReview' })} />}
|
||||
{state.view === 'done' && state.session.result && (
|
||||
<DoneView result={state.session.result} commentStatus={state.commentStatus} logs={state.logs}
|
||||
canJump={state.reviewMode === 'workspace'}
|
||||
onOpen={(i) => bridge.post({ type: 'jumpToComment', index: i })}
|
||||
onAction={(i, action) => bridge.post({ type: 'commentAction', index: i, action })} />
|
||||
)}
|
||||
{state.view === 'empty' && <EmptyView logs={state.logs} />}
|
||||
{state.view === 'cancelled' && <CancelledView />}
|
||||
{state.view === 'failed' && <FailedView error={state.session.error} onRetry={() => start({ mode: 'workspace' })} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { I18nContext, resolveLocale } from './I18nProvider';
|
||||
import { useEffect, useReducer } from 'preact/hooks';
|
||||
import { bridge } from './bridge';
|
||||
import { ConfigView } from './views/ConfigView';
|
||||
|
|
@ -38,6 +39,7 @@ export function ConfigPanelApp() {
|
|||
}, [state.errorHint]);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={resolveLocale(state.locale)}>
|
||||
<div class="config-panel-root">
|
||||
{state.copyHint && <div class="config-toast">{state.copyHint}</div>}
|
||||
{state.errorHint && <div class="config-toast error">{state.errorHint}</div>}
|
||||
|
|
@ -63,5 +65,6 @@ export function ConfigPanelApp() {
|
|||
onClose={() => bridge.post({ type: 'closeConfigPanel' })}
|
||||
/>
|
||||
</div>
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
13
extensions/vscode/src/webview/I18nProvider.tsx
Normal file
13
extensions/vscode/src/webview/I18nProvider.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { createContext } from 'preact';
|
||||
import { useContext } from 'preact/hooks';
|
||||
import { t, resolveLocale, SupportedLocale } from '../shared/i18n';
|
||||
|
||||
export const I18nContext = createContext<SupportedLocale>('en');
|
||||
|
||||
export function useT(): (key: string) => string {
|
||||
const locale = useContext(I18nContext);
|
||||
return (key: string) => t(locale, key);
|
||||
}
|
||||
|
||||
export { resolveLocale, t };
|
||||
export type { SupportedLocale };
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useT } from '../I18nProvider';
|
||||
import { ReviewComment, CommentStatus } from '../../shared/types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -10,6 +11,7 @@ interface Props {
|
|||
}
|
||||
|
||||
export function CommentCard({ comment, index, status, canJump, onOpen, onAction }: Props) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div class={`comment-card${status !== 'pending' ? ' dismissed' : ''}`}>
|
||||
<div class="comment-header">
|
||||
|
|
@ -18,8 +20,8 @@ export function CommentCard({ comment, index, status, canJump, onOpen, onAction
|
|||
</div>
|
||||
<div class="comment-body">{comment.content}</div>
|
||||
<div class="comment-actions">
|
||||
{canJump && <button onClick={() => onOpen(index)}>查看</button>}
|
||||
<button onClick={() => onAction(index, 'discard')}>忽略</button>
|
||||
{canJump && <button onClick={() => onOpen(index)}>{t('cmp.comment.view')}</button>}
|
||||
<button onClick={() => onAction(index, 'discard')}>{t('cmp.comment.discard')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { listCustomProviderNames } from '../../shared/configUtils';
|
||||
import { OcrConfig, ProviderEntry } from '../../shared/types';
|
||||
import { useT } from '../I18nProvider';
|
||||
|
||||
interface Props {
|
||||
config: OcrConfig | null;
|
||||
|
|
@ -16,6 +17,7 @@ function formatModels(entry: ProviderEntry): string {
|
|||
}
|
||||
|
||||
export function CustomProviderManager({ config, onAdd, onEdit, onActivate, onDelete }: Props) {
|
||||
const t = useT();
|
||||
const names = listCustomProviderNames(config);
|
||||
const activeProvider = config?.provider ?? '';
|
||||
|
||||
|
|
@ -23,16 +25,16 @@ export function CustomProviderManager({ config, onAdd, onEdit, onActivate, onDel
|
|||
<div class="custom-provider-manager">
|
||||
<div class="custom-provider-manager-header">
|
||||
<div>
|
||||
<h2 class="custom-provider-manager-title">自定义 Provider</h2>
|
||||
<p class="custom-provider-manager-desc">管理自建 LLM 网关与兼容端点,可切换为当前审查模型。</p>
|
||||
<h2 class="custom-provider-manager-title">{t('cmp.custom.title')}</h2>
|
||||
<p class="custom-provider-manager-desc">{t('cmp.custom.desc')}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-primary" onClick={onAdd}>添加</button>
|
||||
<button type="button" class="btn-primary" onClick={onAdd}>{t('cmp.custom.add')}</button>
|
||||
</div>
|
||||
|
||||
{names.length === 0 ? (
|
||||
<div class="custom-provider-empty">
|
||||
<p>暂无自定义 Provider</p>
|
||||
<button type="button" class="btn-default" onClick={onAdd}>添加自定义 Provider</button>
|
||||
<p>{t('cmp.custom.empty')}</p>
|
||||
<button type="button" class="btn-default" onClick={onAdd}>{t('cmp.custom.addFirst')}</button>
|
||||
</div>
|
||||
) : (
|
||||
<div class="custom-provider-list">
|
||||
|
|
@ -45,21 +47,21 @@ export function CustomProviderManager({ config, onAdd, onEdit, onActivate, onDel
|
|||
<div class="custom-provider-card-main">
|
||||
<div class="custom-provider-card-title">
|
||||
<span class="custom-provider-name">{name}</span>
|
||||
{isActive && <span class="custom-provider-badge">当前使用</span>}
|
||||
{isActive && <span class="custom-provider-badge">{t('cmp.custom.currentUse')}</span>}
|
||||
</div>
|
||||
<div class="custom-provider-card-meta">
|
||||
<span>{entry.protocol || '—'}</span>
|
||||
<span class="custom-provider-card-dot">·</span>
|
||||
<span class="custom-provider-card-url" title={entry.url}>{entry.url || '—'}</span>
|
||||
</div>
|
||||
<div class="custom-provider-card-model">模型:{formatModels(entry)}</div>
|
||||
<div class="custom-provider-card-model">{t('cmp.custom.model')}: {formatModels(entry)}</div>
|
||||
</div>
|
||||
<div class="custom-provider-card-actions">
|
||||
<button type="button" class="btn-text" onClick={() => onEdit(name)}>编辑</button>
|
||||
<button type="button" class="btn-text" onClick={() => onEdit(name)}>{t('cmp.custom.edit')}</button>
|
||||
{!isActive && (
|
||||
<button type="button" class="btn-text" onClick={() => onActivate(name)}>设为当前</button>
|
||||
<button type="button" class="btn-text" onClick={() => onActivate(name)}>{t('cmp.custom.setCurrent')}</button>
|
||||
)}
|
||||
<button type="button" class="btn-text danger" onClick={() => onDelete(name)}>删除</button>
|
||||
<button type="button" class="btn-text danger" onClick={() => onDelete(name)}>{t('cmp.custom.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { EnvCheckResult, LogLine } from '../../shared/types';
|
||||
import { CliStatus } from '../configStore';
|
||||
import { LogViewer } from './LogViewer';
|
||||
import { useT } from '../I18nProvider';
|
||||
|
||||
export const OCR_INSTALL_CMD = 'npm install -g @alibaba-group/open-code-review';
|
||||
|
||||
|
|
@ -40,12 +41,13 @@ export function EnvSetupGuide({
|
|||
layout, cliStatus, envCheck, skipEnvCheck = false, installing, installLogs,
|
||||
onInstall, onCheckEnv, onCopy, onNext,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const checking = cliStatus === 'checking' || cliStatus === 'unknown';
|
||||
|
||||
if (installing) {
|
||||
return (
|
||||
<div class="wizard-body">
|
||||
<EnvCheckingBanner label="正在安装 ocr CLI…" />
|
||||
<EnvCheckingBanner label={t('view.env.installing')} />
|
||||
<LogViewer logs={installLogs} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -54,7 +56,7 @@ export function EnvSetupGuide({
|
|||
if (checking) {
|
||||
return (
|
||||
<div class="wizard-body">
|
||||
<EnvCheckingBanner label="正在检测,请稍候…" />
|
||||
<EnvCheckingBanner label={t('view.env.checking')} />
|
||||
<EnvChecklist checking />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -66,7 +68,7 @@ export function EnvSetupGuide({
|
|||
<EnvChecklist env={envCheck} />
|
||||
<div class={`form-footer${layout === 'panel' ? ' page-footer' : ''}`}>
|
||||
<div class={`form-actions${layout === 'panel' ? ' panel-actions' : ''}`}>
|
||||
<button type="button" class="btn-primary" onClick={onNext}>继续配置 Provider</button>
|
||||
<button type="button" class="btn-primary" onClick={onNext}>{t('view.config.continueProvider')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -76,10 +78,10 @@ export function EnvSetupGuide({
|
|||
if (cliStatus === 'installed' && skipEnvCheck) {
|
||||
return (
|
||||
<div class="wizard-body">
|
||||
<p class="env-guide-lead">环境已就绪,可继续配置 Provider。</p>
|
||||
<p class="env-guide-lead">{t('view.env.ready')}</p>
|
||||
<div class={`form-footer${layout === 'panel' ? ' page-footer' : ''}`}>
|
||||
<div class={`form-actions${layout === 'panel' ? ' panel-actions' : ''}`}>
|
||||
<button type="button" class="btn-primary" onClick={onNext}>继续配置 Provider</button>
|
||||
<button type="button" class="btn-primary" onClick={onNext}>{t('view.config.continueProvider')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -92,7 +94,7 @@ export function EnvSetupGuide({
|
|||
|
||||
return (
|
||||
<div class="wizard-body">
|
||||
<p class="env-guide-lead">按顺序完成环境准备,通过一项后再进行下一项。</p>
|
||||
<p class="env-guide-lead">{t('view.env.stepLead')}</p>
|
||||
|
||||
<div class="env-timeline">
|
||||
<EnvTimelineItem
|
||||
|
|
@ -100,21 +102,21 @@ export function EnvSetupGuide({
|
|||
state={resolveStepState(nodeActive, false, envCheck?.node.ok)}
|
||||
version={envCheck?.node.version}
|
||||
command="node --version"
|
||||
hint="未检测到 Node.js。请前往 nodejs.org 安装 LTS 版本,完成后重启 VS Code。"
|
||||
hint={t('view.env.nodeHint')}
|
||||
/>
|
||||
<EnvTimelineItem
|
||||
title="npm"
|
||||
state={resolveStepState(npmActive, false, envCheck?.npm.ok)}
|
||||
version={envCheck?.npm.version}
|
||||
command="npm --version"
|
||||
hint="未检测到 npm。npm 通常随 Node 一起安装,请确认 Node 安装完整。"
|
||||
hint={t('view.env.npmHint')}
|
||||
/>
|
||||
<EnvTimelineItem
|
||||
title="ocr CLI"
|
||||
state={resolveStepState(ocrActive, false, envCheck?.ocr.ok)}
|
||||
version={envCheck?.ocr.version}
|
||||
command={OCR_INSTALL_CMD}
|
||||
hint="在终端全局安装 open-code-review,或点击下方「一键安装」。"
|
||||
hint={t('view.env.ocrHint')}
|
||||
onCopy={onCopy}
|
||||
last
|
||||
/>
|
||||
|
|
@ -124,9 +126,9 @@ export function EnvSetupGuide({
|
|||
|
||||
<div class={`form-footer${layout === 'panel' ? ' page-footer' : ''}`}>
|
||||
<div class={`form-actions${layout === 'panel' ? ' panel-actions' : ''}`}>
|
||||
<button type="button" class="btn-default" onClick={onCheckEnv}>重新检测</button>
|
||||
<button type="button" class="btn-default" onClick={onCheckEnv}>{t('view.env.redetect')}</button>
|
||||
{ocrActive && !envCheck?.ocr.ok && (
|
||||
<button type="button" class="btn-primary" onClick={onInstall}>一键安装</button>
|
||||
<button type="button" class="btn-primary" onClick={onInstall}>{t('view.env.oneClickInstall')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -144,6 +146,7 @@ function EnvCheckingBanner({ label }: { label: string }) {
|
|||
}
|
||||
|
||||
function EnvChecklist({ checking, env }: { checking?: boolean; env?: EnvCheckResult }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<ul class={`env-checklist${checking ? ' is-checking' : ''}${env ? ' is-done' : ''}`}>
|
||||
{CHECK_ITEMS.map(({ key, label }, i) => {
|
||||
|
|
@ -155,9 +158,9 @@ function EnvChecklist({ checking, env }: { checking?: boolean; env?: EnvCheckRes
|
|||
<span class="env-checklist-marker" aria-hidden="true" />
|
||||
<span class="env-checklist-label">{label}</span>
|
||||
<span class="env-checklist-meta">
|
||||
{checking && '检测中'}
|
||||
{!checking && ok && (item?.version ?? '就绪')}
|
||||
{!checking && env && !ok && '未就绪'}
|
||||
{checking && t('view.env.checkingStatus')}
|
||||
{!checking && ok && (item?.version ?? t('view.env.readyStatus'))}
|
||||
{!checking && env && !ok && t('view.env.notReady')}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
|
@ -177,6 +180,7 @@ function EnvTimelineItem({
|
|||
onCopy?: (text: string) => void;
|
||||
last?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const showDetail = state === 'fail' || state === 'ok';
|
||||
return (
|
||||
<div class={`env-timeline-item ${state}${last ? ' last' : ''}`}>
|
||||
|
|
@ -188,10 +192,10 @@ function EnvTimelineItem({
|
|||
<div class="env-timeline-head">
|
||||
<span class="env-timeline-title">{title}</span>
|
||||
<span class={`env-timeline-status ${state}`}>
|
||||
{state === 'ok' && (version ?? '通过')}
|
||||
{state === 'fail' && '未通过'}
|
||||
{state === 'pending' && '等待上一步'}
|
||||
{state === 'checking' && '检测中'}
|
||||
{state === 'ok' && (version ?? t('view.env.pass'))}
|
||||
{state === 'fail' && t('view.env.fail')}
|
||||
{state === 'pending' && t('view.env.waitPrev')}
|
||||
{state === 'checking' && t('view.env.checkingStatus')}
|
||||
</span>
|
||||
</div>
|
||||
{showDetail && (
|
||||
|
|
@ -200,7 +204,7 @@ function EnvTimelineItem({
|
|||
<div class="env-cmd-block">
|
||||
<code>{command}</code>
|
||||
{onCopy && (
|
||||
<button type="button" class="env-cmd-copy" onClick={() => onCopy(command)}>复制</button>
|
||||
<button type="button" class="env-cmd-copy" onClick={() => onCopy(command)}>{t('view.env.copy')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useT } from '../I18nProvider';
|
||||
import { FileChange } from '../../shared/types';
|
||||
|
||||
const BADGE: Record<FileChange['status'], string> = {
|
||||
|
|
@ -7,9 +8,10 @@ const BADGE: Record<FileChange['status'], string> = {
|
|||
interface Props { files: FileChange[]; loading?: boolean; onOpenFile?: (file: FileChange) => void; }
|
||||
|
||||
export function FileList({ files, loading, onOpenFile }: Props) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div class="file-list">
|
||||
<div class="files-label">待审查文件 {loading ? '' : `(${files.length})`}</div>
|
||||
<div class="files-label">{t('cmp.fileList.pending')} {loading ? '' : `(${files.length})`}</div>
|
||||
{loading ? (
|
||||
<div class="file-loading">
|
||||
{[68, 52, 60].map((w, i) => (
|
||||
|
|
@ -19,11 +21,11 @@ export function FileList({ files, loading, onOpenFile }: Props) {
|
|||
))}
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div class="file-empty">无变更文件</div>
|
||||
<div class="file-empty">{t('cmp.fileList.noChanges')}</div>
|
||||
) : (
|
||||
<div class="file-scroll">
|
||||
{files.map((f) => (
|
||||
<div class="file-row" key={f.path} title={onOpenFile ? '点击查看 diff' : undefined}
|
||||
<div class="file-row" key={f.path} title={onOpenFile ? t('cmp.fileList.viewDiff') : undefined}
|
||||
onClick={onOpenFile ? () => onOpenFile(f) : undefined}>
|
||||
<span class="file-name">{f.path}</span>
|
||||
<span class={`file-badge ${f.status}`}>{BADGE[f.status]}</span>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useT } from '../I18nProvider';
|
||||
import { useRef, useEffect } from 'preact/hooks';
|
||||
import { LogLine } from '../../shared/types';
|
||||
|
||||
|
|
@ -5,6 +6,7 @@ interface Props { logs: LogLine[]; }
|
|||
|
||||
export function LogViewer({ logs }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const t = useT();
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
|
|
@ -13,7 +15,7 @@ export function LogViewer({ logs }: Props) {
|
|||
return (
|
||||
<div class="log-viewer" ref={ref}>
|
||||
{logs.length === 0 ? (
|
||||
<div class="log-line"><span class="log-dim">等待输出</span><span class="log-cursor"></span></div>
|
||||
<div class="log-line"><span class="log-dim">{t('cmp.log.waiting')}</span><span class="log-cursor"></span></div>
|
||||
) : (
|
||||
logs.map((l, i) => (
|
||||
<div class={`log-line ${l.level === 'warn' ? 'log-warn' : ''}`} key={i}>{l.text}</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useT } from '../I18nProvider';
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -29,6 +30,7 @@ function EyeOffIcon() {
|
|||
|
||||
export function PasswordInput({ value, placeholder, className, onInput }: Props) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<div class="password-input-wrap">
|
||||
|
|
@ -43,7 +45,7 @@ export function PasswordInput({ value, placeholder, className, onInput }: Props)
|
|||
type="button"
|
||||
class="password-toggle"
|
||||
onClick={() => setVisible(!visible)}
|
||||
aria-label={visible ? '隐藏密钥' : '显示密钥'}
|
||||
aria-label={visible ? t('cmp.password.hideSecret') : t('cmp.password.showSecret')}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{visible ? <EyeOffIcon /> : <EyeIcon />}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useT } from '../I18nProvider';
|
||||
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||
|
||||
export interface SelectOption {
|
||||
|
|
@ -12,7 +13,9 @@ interface Props {
|
|||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function Select({ value, options, placeholder = '请选择', onChange }: Props) {
|
||||
export function Select({ value, options, placeholder, onChange }: Props) {
|
||||
const t = useT();
|
||||
const resolvedPlaceholder = placeholder ?? t('cmp.select.placeholder');
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -33,7 +36,7 @@ export function Select({ value, options, placeholder = '请选择', onChange }:
|
|||
<div class={`select${open ? ' open' : ''}`} ref={ref}>
|
||||
<button type="button" class="select-trigger" onClick={() => setOpen(!open)}>
|
||||
<span class={`select-value${selected ? '' : ' placeholder'}`}>
|
||||
{selected ? selected.label : placeholder}
|
||||
{selected ? selected.label : resolvedPlaceholder}
|
||||
</span>
|
||||
<span class="select-arrow"></span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ConfigPanelFocus, isConfigReady } from '../shared/configUtils';
|
||||
import { ConfigPanelHostToWebview, HostToWebview } from '../shared/messages';
|
||||
import { EnvCheckResult, OcrConfig, LogLine } from '../shared/types';
|
||||
import { resolveLocale, t, SupportedLocale } from '../shared/i18n';
|
||||
|
||||
export type CliStatus = 'unknown' | 'checking' | 'installed' | 'missing';
|
||||
export type ConnTest = { status: 'idle' | 'testing' | 'ok' | 'fail'; message?: string };
|
||||
|
|
@ -16,6 +17,7 @@ export interface ConfigPanelState {
|
|||
connTest: ConnTest;
|
||||
copyHint: string;
|
||||
errorHint: string;
|
||||
locale: SupportedLocale;
|
||||
}
|
||||
|
||||
export const configPanelInitialState: ConfigPanelState = {
|
||||
|
|
@ -29,6 +31,7 @@ export const configPanelInitialState: ConfigPanelState = {
|
|||
connTest: { status: 'idle' },
|
||||
copyHint: '',
|
||||
errorHint: '',
|
||||
locale: 'en',
|
||||
};
|
||||
|
||||
export type ConfigPanelLocalAction =
|
||||
|
|
@ -66,6 +69,7 @@ export function configPanelReducer(
|
|||
skipEnvCheck,
|
||||
envCheck: env,
|
||||
cliStatus: env ? envToCliStatus(env) : (skipEnvCheck ? 'installed' : 'unknown'),
|
||||
locale: msg.locale,
|
||||
};
|
||||
}
|
||||
case 'configPanelFocus':
|
||||
|
|
@ -90,7 +94,7 @@ export function configPanelReducer(
|
|||
case 'installDone':
|
||||
return { ...state, installing: false };
|
||||
case 'copyDone':
|
||||
return { ...state, copyHint: '已复制到剪贴板' };
|
||||
return { ...state, copyHint: t(resolveLocale(state.locale), 'view.env.copiedToast') };
|
||||
case 'clearCopyHint':
|
||||
return { ...state, copyHint: '' };
|
||||
case 'clearErrorHint':
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { CliResult, CommentStatus, FileChange, GitState, LogLine, OcrConfig, ReviewMode, ReviewState } from '../shared/types';
|
||||
import { SupportedLocale } from '../shared/i18n';
|
||||
import { HostToWebview } from '../shared/messages';
|
||||
|
||||
export type AppView = 'idle' | 'running' | 'done' | 'empty' | 'cancelled' | 'failed';
|
||||
|
|
@ -13,6 +14,7 @@ export interface AppState {
|
|||
session: { state: ReviewState; result: CliResult | null; error?: string };
|
||||
commentStatus: Record<number, CommentStatus>;
|
||||
reviewMode: ReviewMode;
|
||||
locale: SupportedLocale;
|
||||
}
|
||||
|
||||
export const initialState: AppState = {
|
||||
|
|
@ -25,6 +27,7 @@ export const initialState: AppState = {
|
|||
session: { state: 'idle', result: null },
|
||||
commentStatus: {},
|
||||
reviewMode: 'workspace',
|
||||
locale: 'en',
|
||||
};
|
||||
|
||||
const STATE_TO_VIEW: Record<ReviewState, AppView> = {
|
||||
|
|
@ -49,6 +52,7 @@ export function reducer(state: AppState, msg: HostToWebview | LocalAction): AppS
|
|||
gitState: msg.gitState,
|
||||
view: 'idle',
|
||||
filesLoading: false,
|
||||
locale: msg.locale,
|
||||
};
|
||||
case 'gitState':
|
||||
return { ...state, gitState: msg.gitState, filesLoading: false };
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useT } from '../I18nProvider';
|
||||
export function CancelledView() {
|
||||
const t = useT();
|
||||
return (
|
||||
<div class="action-cancelled" style="display:block">
|
||||
<div class="cancelled-note">审查已取消</div>
|
||||
<div class="cancelled-note">{t('view.cancelled.title')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { EnvCheckResult, LogLine, OcrConfig } from '../../shared/types';
|
|||
import { CliStatus, ConnTest } from '../configStore';
|
||||
import { CustomProviderManager } from '../components/CustomProviderManager';
|
||||
import { EnvSetupGuide } from '../components/EnvSetupGuide';
|
||||
import { LogViewer } from '../components/LogViewer';
|
||||
import { PasswordInput } from '../components/PasswordInput';
|
||||
import { Select } from '../components/Select';
|
||||
import { useT } from '../I18nProvider';
|
||||
|
||||
interface Props {
|
||||
layout?: 'modal' | 'panel';
|
||||
|
|
@ -71,15 +71,16 @@ export function ConfigView({
|
|||
}, [panelFocus, config, onClearConnTest]);
|
||||
|
||||
const wide = layout === 'panel';
|
||||
const t = useT();
|
||||
const stepper = (
|
||||
<div class="config-stepper">
|
||||
<div class={`config-step-pill${step === 1 ? ' active' : ''}${cliStatus === 'installed' ? ' done' : ''}`}>
|
||||
<span class="config-step-num">1</span>
|
||||
<span>环境检测</span>
|
||||
<span>{t('view.config.step1')}</span>
|
||||
</div>
|
||||
<div class={`config-step-pill${step === 2 ? ' active' : ''}`}>
|
||||
<span class="config-step-num">2</span>
|
||||
<span>Provider 配置</span>
|
||||
<span>{t('view.config.step2')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -129,8 +130,8 @@ export function ConfigView({
|
|||
<header class="config-page-header">
|
||||
<div class="config-page-header-row">
|
||||
<div>
|
||||
<h1 class="config-page-title">模型配置</h1>
|
||||
<p class="config-page-desc">连接 LLM Provider 以开始代码审查</p>
|
||||
<h1 class="config-page-title">{t('view.config.title')}</h1>
|
||||
<p class="config-page-desc">{t('view.config.desc')}</p>
|
||||
</div>
|
||||
<OcrVersionMeta envCheck={envCheck} cliStatus={cliStatus} />
|
||||
</div>
|
||||
|
|
@ -147,10 +148,10 @@ export function ConfigView({
|
|||
<>
|
||||
<div class="config-form-header">
|
||||
<div class="config-form-heading">
|
||||
<span class="config-form-title">模型配置</span>
|
||||
<span class="config-form-subtitle">连接 LLM Provider 以开始代码审查</span>
|
||||
<span class="config-form-title">{t('view.config.title')}</span>
|
||||
<span class="config-form-subtitle">{t('view.config.desc')}</span>
|
||||
</div>
|
||||
<button type="button" class="config-list-close" onClick={onClose} aria-label="关闭">×</button>
|
||||
<button type="button" class="config-list-close" onClick={onClose} aria-label={t('view.config.close')}>×</button>
|
||||
</div>
|
||||
{stepper}
|
||||
{stepContent}
|
||||
|
|
@ -167,16 +168,17 @@ export function ConfigView({
|
|||
}
|
||||
|
||||
function OcrVersionMeta({ envCheck, cliStatus }: { envCheck: EnvCheckResult | null; cliStatus: CliStatus }) {
|
||||
let label = 'ocr 检测中…';
|
||||
const t = useT();
|
||||
let label = t('view.config.checking');
|
||||
if (envCheck?.ocr.ok && envCheck.ocr.version) {
|
||||
label = envCheck.ocr.version.startsWith('v') ? envCheck.ocr.version : `v${envCheck.ocr.version}`;
|
||||
} else if (envCheck && !envCheck.ocr.ok) {
|
||||
label = 'ocr 未安装';
|
||||
label = t('view.config.notInstalled');
|
||||
} else if (cliStatus !== 'checking' && cliStatus === 'missing') {
|
||||
label = 'ocr 未安装';
|
||||
label = t('view.config.notInstalled');
|
||||
}
|
||||
return (
|
||||
<div class="config-page-meta" title="Open Code Review CLI 版本">
|
||||
<div class="config-page-meta" title={t('view.config.ocrVersionTooltip')}>
|
||||
<span class="config-page-meta-label">OCR</span>
|
||||
<span class="config-page-meta-value">{label}</span>
|
||||
</div>
|
||||
|
|
@ -184,21 +186,28 @@ function OcrVersionMeta({ envCheck, cliStatus }: { envCheck: EnvCheckResult | nu
|
|||
}
|
||||
|
||||
function ActiveProviderBanner({ config }: { config: OcrConfig | null }) {
|
||||
const t = useT();
|
||||
const active = describeActiveProvider(config);
|
||||
if (!active) {
|
||||
return (
|
||||
<div class="active-provider-banner empty">
|
||||
<span class="active-provider-label">当前使用</span>
|
||||
<span class="active-provider-empty-text">尚未配置 Provider</span>
|
||||
<span class="active-provider-label">{t('view.config.currentUse')}</span>
|
||||
<span class="active-provider-empty-text">{t('view.config.notConfigured')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const kindLabel = active.kind === 'official' ? '官方' : active.kind === 'custom' ? '自定义' : 'Legacy';
|
||||
const kindLabelMap: Record<string, string> = {
|
||||
official: t('view.config.officialLabel'),
|
||||
custom: t('view.config.customLabel'),
|
||||
legacy: t('view.config.legacyLabel'),
|
||||
};
|
||||
const kindLabel = kindLabelMap[active.kind] ?? t('view.config.legacyLabel');
|
||||
const displayName = active.kind === 'legacy' ? t('ext.config.legacyDisplayName') : active.displayName;
|
||||
return (
|
||||
<div class="active-provider-banner">
|
||||
<span class="active-provider-label">当前使用</span>
|
||||
<span class="active-provider-label">{t('view.config.currentUse')}</span>
|
||||
<span class="active-provider-badge">{kindLabel}</span>
|
||||
<span class="active-provider-name">{active.displayName}</span>
|
||||
<span class="active-provider-name">{displayName}</span>
|
||||
<span class="active-provider-dot">·</span>
|
||||
<span class="active-provider-model">{active.model}</span>
|
||||
{active.detail && (
|
||||
|
|
@ -231,12 +240,13 @@ function ProviderStep({
|
|||
onActivateCustomProvider?: (name: string) => void;
|
||||
onClearConnTest?: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div class="wizard-body provider-step">
|
||||
<div class="segmented-control">
|
||||
{([
|
||||
['official', '官方 Provider'],
|
||||
['custom', '自定义 Provider'],
|
||||
['official', t('view.config.official')],
|
||||
['custom', t('view.config.custom')],
|
||||
] as const).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
|
|
@ -297,11 +307,12 @@ function FormItem({
|
|||
hint?: string;
|
||||
children: ComponentChildren;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div class={`form-item${span === 2 ? ' span-2' : ''}`}>
|
||||
<label class="form-label">
|
||||
{label}
|
||||
{optional && <span class="optional">(可选)</span>}
|
||||
{optional && <span class="optional">{t('view.config.optional')}</span>}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <div class="form-hint">{hint}</div>}
|
||||
|
|
@ -310,6 +321,7 @@ function FormItem({
|
|||
}
|
||||
|
||||
function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormProps) {
|
||||
const t = useT();
|
||||
const initialProvider = config?.provider && PROVIDER_PRESETS.some((p) => p.name === config.provider)
|
||||
? config.provider
|
||||
: PROVIDER_PRESETS[0].name;
|
||||
|
|
@ -373,13 +385,13 @@ function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormPr
|
|||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="模型">
|
||||
<FormItem label={t('view.config.model')}>
|
||||
<Select
|
||||
value={modelChoice}
|
||||
onChange={setModelChoice}
|
||||
options={[
|
||||
...modelOptions.map((m) => ({ value: m, label: m })),
|
||||
{ value: MODEL_CUSTOM, label: '输入自定义模型…' },
|
||||
{ value: MODEL_CUSTOM, label: t('view.config.customModel') },
|
||||
]}
|
||||
/>
|
||||
{modelChoice === MODEL_CUSTOM && (
|
||||
|
|
@ -393,13 +405,13 @@ function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormPr
|
|||
</FormItem>
|
||||
|
||||
<FormItem
|
||||
label="API 密钥"
|
||||
hint={`也可通过环境变量 ${preset.envVar} 提供密钥`}
|
||||
label={t('view.config.apiKey')}
|
||||
hint={`${t('view.config.apiKeyEnvHint')} ${preset.envVar}`}
|
||||
>
|
||||
<PasswordInput
|
||||
value={apiKey}
|
||||
onInput={(v) => { setApiKey(v); setApiKeyTouched(true); }}
|
||||
placeholder={hasStoredKey && !apiKeyTouched ? '已保存(留空保持不变)' : 'sk-...'}
|
||||
placeholder={hasStoredKey && !apiKeyTouched ? t('view.config.apiKeySaved') : 'sk-...'}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
|
|
@ -414,6 +426,7 @@ function CustomForm({
|
|||
selection: string;
|
||||
onBackToList?: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const isCreate = selection === CUSTOM_NEW;
|
||||
const entry = !isCreate ? config?.customProviders[selection] : undefined;
|
||||
|
||||
|
|
@ -470,11 +483,11 @@ function CustomForm({
|
|||
<FormSection wide={wide}>
|
||||
{onBackToList && (
|
||||
<div class="form-item span-2">
|
||||
<button type="button" class="btn-text back-link" onClick={onBackToList}>← 返回列表</button>
|
||||
<button type="button" class="btn-text back-link" onClick={onBackToList}>{t('view.config.backToList')}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormItem label="Provider 名称">
|
||||
<FormItem label={t('view.config.providerName')}>
|
||||
<input
|
||||
class="form-input"
|
||||
value={name}
|
||||
|
|
@ -483,7 +496,7 @@ function CustomForm({
|
|||
placeholder="my-llm"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="协议">
|
||||
<FormItem label={t('view.config.protocol')}>
|
||||
<Select
|
||||
value={protocol}
|
||||
onChange={(v) => setProtocol(v as 'anthropic' | 'openai')}
|
||||
|
|
@ -493,26 +506,26 @@ function CustomForm({
|
|||
]}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="Base URL" span={2}>
|
||||
<FormItem label={t('view.config.baseUrl')} span={2}>
|
||||
<input class="form-input" value={url} onInput={(e) => setUrl((e.target as HTMLInputElement).value)} placeholder="https://api.example.com/v1" />
|
||||
</FormItem>
|
||||
<FormItem label="模型">
|
||||
<FormItem label={t('view.config.model')}>
|
||||
<input class="form-input" value={model} onInput={(e) => setModel((e.target as HTMLInputElement).value)} placeholder="model name" />
|
||||
</FormItem>
|
||||
<FormItem label="模型列表" optional>
|
||||
<input class="form-input" value={models} onInput={(e) => setModels((e.target as HTMLInputElement).value)} placeholder="逗号分隔,如 model-a, model-b" />
|
||||
<FormItem label={t('view.config.modelList')} optional>
|
||||
<input class="form-input" value={models} onInput={(e) => setModels((e.target as HTMLInputElement).value)} placeholder={t('view.config.modelListPlaceholder')} />
|
||||
</FormItem>
|
||||
<FormItem label="API 密钥" span={2}>
|
||||
<FormItem label={t('view.config.apiKey')} span={2}>
|
||||
<PasswordInput
|
||||
value={apiKey}
|
||||
onInput={(v) => { setApiKey(v); setApiKeyTouched(true); }}
|
||||
placeholder={!isCreate && entry?.apiKey && !apiKeyTouched ? '已保存(留空保持不变)' : 'sk-...'}
|
||||
placeholder={!isCreate && entry?.apiKey && !apiKeyTouched ? t('view.config.apiKeySaved') : 'sk-...'}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="Auth Header" optional hint="Anthropic 协议下可选 x-api-key 或 authorization">
|
||||
<Select value={authHeader} placeholder="默认 (Authorization)" onChange={setAuthHeader}
|
||||
<FormItem label={t('view.config.authHeader')} optional hint={t('view.config.authHeaderHint')}>
|
||||
<Select value={authHeader} placeholder={t('view.config.authHeaderDefault')} onChange={setAuthHeader}
|
||||
options={[
|
||||
{ value: '', label: '默认 (Authorization)' },
|
||||
{ value: '', label: t('view.config.authHeaderDefault') },
|
||||
{ value: 'x-api-key', label: 'x-api-key' },
|
||||
{ value: 'authorization', label: 'authorization' },
|
||||
]} />
|
||||
|
|
@ -536,20 +549,21 @@ function ConnActions({ wide, connTest, canSave, onBack, onTest, onSave }: {
|
|||
connTest: ConnTest; canSave: boolean;
|
||||
onBack: () => void; onTest: () => void; onSave: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div class={`form-footer${wide ? ' page-footer' : ''}`}>
|
||||
{connTest.status !== 'idle' && (
|
||||
<div class={`conn-result ${connTest.status}`}>
|
||||
{connTest.status === 'testing' && '正在测试连接…'}
|
||||
{connTest.status === 'ok' && '✓ 连接成功'}
|
||||
{connTest.status === 'fail' && `✗ 连接失败${connTest.message ? ':' + connTest.message : ''}`}
|
||||
{connTest.status === 'testing' && t('view.config.testing')}
|
||||
{connTest.status === 'ok' && t('view.config.testOk')}
|
||||
{connTest.status === 'fail' && t(connTest.message ? 'view.config.testFailDetail' : 'view.config.testFail').replace('{message}', connTest.message ?? '')}
|
||||
</div>
|
||||
)}
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-default" onClick={onBack}>上一步</button>
|
||||
<button type="button" class="btn-default" disabled={connTest.status === 'testing' || !canSave} onClick={onTest}>测试连接</button>
|
||||
<button type="button" class="btn-primary" disabled={!canSave} onClick={onSave}>保存</button>
|
||||
<button type="button" class="btn-default" onClick={onBack}>{t('view.config.previous')}</button>
|
||||
<button type="button" class="btn-default" disabled={connTest.status === 'testing' || !canSave} onClick={onTest}>{t('view.config.test')}</button>
|
||||
<button type="button" class="btn-primary" disabled={!canSave} onClick={onSave}>{t('view.config.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { useState } from 'preact/hooks';
|
|||
import { CliResult, CommentStatus, LogLine } from '../../shared/types';
|
||||
import { CommentCard } from '../components/CommentCard';
|
||||
import { LogViewer } from '../components/LogViewer';
|
||||
import { useT } from '../I18nProvider';
|
||||
|
||||
interface Props {
|
||||
result: CliResult;
|
||||
|
|
@ -14,19 +15,20 @@ interface Props {
|
|||
|
||||
export function DoneView({ result, commentStatus, logs, canJump, onOpen, onAction }: Props) {
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const t = useT();
|
||||
const s = result.summary;
|
||||
return (
|
||||
<div class="action-done" style="display:block">
|
||||
<div class="done-summary">
|
||||
<span class="ds-dot"></span>
|
||||
<span>{result.comments.length} 条评论 · {s?.filesReviewed ?? 0} 个文件 · {s?.elapsed ?? ''}</span>
|
||||
<span>{result.comments.length} {t('view.done.comments')} · {s?.filesReviewed ?? 0} {t('view.done.files')} · {s?.elapsed ?? ''}</span>
|
||||
</div>
|
||||
|
||||
{logs.length > 0 && (
|
||||
<div class="logs-disclosure">
|
||||
<button class="logs-toggle" onClick={() => setShowLogs(!showLogs)}>
|
||||
<span class={`logs-toggle-arrow${showLogs ? ' open' : ''}`}></span>
|
||||
过程日志
|
||||
{t('view.done.processLog')}
|
||||
</button>
|
||||
{showLogs && <LogViewer logs={logs} />}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,25 @@
|
|||
import { useState } from 'preact/hooks';
|
||||
import { LogLine } from '../../shared/types';
|
||||
import { LogViewer } from '../components/LogViewer';
|
||||
import { useT } from '../I18nProvider';
|
||||
|
||||
interface Props { logs?: LogLine[]; }
|
||||
|
||||
export function EmptyView({ logs = [] }: Props) {
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const t = useT();
|
||||
return (
|
||||
<div class="action-empty" style="display:block">
|
||||
<div class="empty-note">
|
||||
<div class="en-dot"></div>
|
||||
<div class="en-text">未发现问题 · 已通过</div>
|
||||
<div class="en-text">{t('view.empty.noIssues')}</div>
|
||||
</div>
|
||||
|
||||
{logs.length > 0 && (
|
||||
<div class="logs-disclosure">
|
||||
<button class="logs-toggle" onClick={() => setShowLogs(!showLogs)}>
|
||||
<span class={`logs-toggle-arrow${showLogs ? ' open' : ''}`}></span>
|
||||
过程日志
|
||||
{t('view.empty.processLog')}
|
||||
</button>
|
||||
{showLogs && <LogViewer logs={logs} />}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { useT } from '../I18nProvider';
|
||||
|
||||
interface Props { onRetry: () => void; error?: string; }
|
||||
export function FailedView({ onRetry, error }: Props) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div class="action-failed" style="display:block">
|
||||
<div class="failed-card">
|
||||
<div class="fc-msg">审查失败。<br/>{error ? '请检查模型配置后重试。' : '请检查 API Key 和网络连接。'}</div>
|
||||
<div class="fc-msg">{t('view.failed.title')}<br/>{error ? t('view.failed.checkConfig') : t('view.failed.checkApiKey')}</div>
|
||||
{error && <div class="fc-detail">{error}</div>}
|
||||
<button class="retry-pill" onClick={onRetry}>重试</button>
|
||||
<button class="retry-pill" onClick={onRetry}>{t('view.failed.retry')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,23 +1,9 @@
|
|||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { useT } from '../I18nProvider';
|
||||
import { GitState, ReviewMode, CliRunOptions, FileChange } from '../../shared/types';
|
||||
import { FileList } from '../components/FileList';
|
||||
import { Select } from '../components/Select';
|
||||
|
||||
function getPrimaryLabel(params: {
|
||||
configured: boolean;
|
||||
running?: boolean;
|
||||
selectionReady: boolean;
|
||||
mode: ReviewMode;
|
||||
filesCount: number;
|
||||
}): string {
|
||||
if (!params.configured) return '请先配置模型';
|
||||
if (params.running) return '审查中…';
|
||||
if (!params.selectionReady) {
|
||||
return params.mode === 'branch' ? '请选择对比分支' : '请选择提交';
|
||||
}
|
||||
if (params.filesCount === 0) return '无可审查文件';
|
||||
return '审查所有变更';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
gitState: GitState;
|
||||
|
|
@ -39,6 +25,17 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
|
|||
const [to, setTo] = useState('');
|
||||
const [commit, setCommit] = useState('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const t = useT();
|
||||
|
||||
const getPrimaryLabel = () => {
|
||||
if (!configured) return t('view.idle.configFirst');
|
||||
if (running) return t('view.idle.reviewing');
|
||||
if (!selectionReady) {
|
||||
return mode === 'branch' ? t('view.idle.selectBranch') : t('view.idle.selectCommit');
|
||||
}
|
||||
if (files.length === 0) return t('view.idle.noFiles');
|
||||
return t('view.idle.reviewAll');
|
||||
};
|
||||
|
||||
const switchMode = (m: ReviewMode) => { setMode(m); onModeChange(m); };
|
||||
|
||||
|
|
@ -75,25 +72,25 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
|
|||
<div class="mode-tabs">
|
||||
{(['workspace', 'branch', 'commit'] as ReviewMode[]).map((m) => (
|
||||
<button key={m} class={`mode-tab${mode === m ? ' active' : ''}`} onClick={() => switchMode(m)}>
|
||||
{m === 'workspace' ? '工作区' : m === 'branch' ? '分支对比' : '单次提交'}
|
||||
{m === 'workspace' ? t('view.idle.workspace') : m === 'branch' ? t('view.idle.branch') : t('view.idle.commit')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === 'branch' && (
|
||||
<div class="mode-params active">
|
||||
<div class="mode-param-label">基础引用</div>
|
||||
<Select value={from} placeholder="选择分支" onChange={setFrom}
|
||||
<div class="mode-param-label">{t('view.idle.baseRef')}</div>
|
||||
<Select value={from} placeholder={t('view.idle.chooseBranch')} onChange={setFrom}
|
||||
options={gitState.branches.map((b) => ({ value: b, label: b }))} />
|
||||
<div class="mode-param-label">目标引用</div>
|
||||
<Select value={to} placeholder="选择分支" onChange={setTo}
|
||||
<div class="mode-param-label">{t('view.idle.targetRef')}</div>
|
||||
<Select value={to} placeholder={t('view.idle.chooseBranch')} onChange={setTo}
|
||||
options={gitState.branches.map((b) => ({ value: b, label: b }))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'commit' && (
|
||||
<div class="mode-params active">
|
||||
<div class="files-label">提交历史</div>
|
||||
<div class="files-label">{t('view.idle.commitHistory')}</div>
|
||||
<div class="commit-list">
|
||||
{gitState.recentCommits.map((c) => (
|
||||
<label key={c.sha} class={`commit-row${commit === c.sha ? ' active' : ''}`} onClick={() => setCommit(c.sha)}>
|
||||
|
|
@ -111,14 +108,14 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
|
|||
<FileList files={files} loading={loading}
|
||||
onOpenFile={(f) => onOpenFile(f, mode, from, to, commit)} />
|
||||
|
||||
<textarea class="mode-param-input" rows={3} placeholder="自定义审查提示词(可选)"
|
||||
<textarea class="mode-param-input" rows={3} placeholder={t('view.idle.customPrompt')}
|
||||
value={prompt} onInput={(e) => setPrompt((e.target as HTMLTextAreaElement).value)} />
|
||||
|
||||
{configured && (
|
||||
<div class="setup-secondary">
|
||||
<button type="button" class="link-btn" onClick={onOpenCustomProviders}>管理自定义 Provider</button>
|
||||
<button type="button" class="link-btn" onClick={onOpenCustomProviders}>{t('view.idle.manageCustom')}</button>
|
||||
<span class="setup-secondary-sep">·</span>
|
||||
<button type="button" class="link-btn" onClick={onOpenConfig}>模型配置</button>
|
||||
<button type="button" class="link-btn" onClick={onOpenConfig}>{t('view.idle.modelConfig')}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -127,7 +124,7 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
|
|||
) : (
|
||||
<button class={`primary-btn${!configured ? ' configure' : ''}`} disabled={primaryDisabled}
|
||||
onClick={handlePrimary}>
|
||||
{getPrimaryLabel({ configured, running, selectionReady, mode, filesCount: files.length })}
|
||||
{getPrimaryLabel()}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { useT } from '../I18nProvider';
|
||||
import { LogLine } from '../../shared/types';
|
||||
import { LogViewer } from '../components/LogViewer';
|
||||
|
||||
interface Props { logs: LogLine[]; onCancel: () => void; }
|
||||
|
||||
export function RunningView({ logs, onCancel }: Props) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div class="action-running" style="display:block">
|
||||
<div class="files-label">审查日志</div>
|
||||
<div class="files-label">{t('view.running.reviewLog')}</div>
|
||||
<LogViewer logs={logs} />
|
||||
<button class="cancel-pill" onClick={onCancel}>取消</button>
|
||||
<button class="cancel-pill" onClick={onCancel}>{t('view.running.cancel')}</button>
|
||||
<div style="clear:both"></div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue