feat(i18n): 推进多语言剩余切片闭环

- 补齐 DataGrid、DataViewer、DefinitionViewer、JVM 等模块多语言文案与回归测试
- 收口 JVM 前后端展示、诊断、监控和资源呈现相关多语言路径
- 更新六语言共享词典并保留 raw 边界
This commit is contained in:
tianqijiuyun-latiao
2026-06-16 12:40:33 +08:00
parent 558966a129
commit 5fc29a6fd3
69 changed files with 6551 additions and 1276 deletions

View File

@@ -10,6 +10,7 @@ import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveA
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { resolveLanguage, t as translate, type I18nParams } from '../i18n';
import {
buildEffectiveFilterConditions,
normalizeQuickWhereCondition,
@@ -38,6 +39,8 @@ type ViewerPaginationState = {
totalCountCancelled: boolean;
};
type DataViewerTranslator = (key: string, params?: I18nParams) => string;
const JS_MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
const isIntegerText = (text: string): boolean => /^[+-]?\d+$/.test(text);
@@ -103,6 +106,47 @@ const buildDataViewerReadOnlyLocator = (reason: string): EditRowLocator => ({
reason,
});
const READ_ONLY_REASON_NO_SAFE_LOCATOR = '\u672a\u68c0\u6d4b\u5230\u4e3b\u952e\u6216\u53ef\u7528\u552f\u4e00\u7d22\u5f15\uff0c\u65e0\u6cd5\u5b89\u5168\u63d0\u4ea4\u4fee\u6539\u3002';
const READ_ONLY_REASON_ORACLE_ROWID_MISSING = '\u672a\u68c0\u6d4b\u5230\u4e3b\u952e\u6216\u53ef\u7528\u552f\u4e00\u7d22\u5f15\uff0c\u4e14\u7ed3\u679c\u4e2d\u7f3a\u5c11 Oracle ROWID\uff0c\u65e0\u6cd5\u5b89\u5168\u63d0\u4ea4\u4fee\u6539\u3002';
const READ_ONLY_REASON_PRIMARY_KEY_MISSING_PREFIX = '\u7ed3\u679c\u96c6\u4e2d\u7f3a\u5c11\u4e3b\u952e\u5217 ';
const READ_ONLY_REASON_SAFE_SUBMIT_SUFFIX = '\uff0c\u65e0\u6cd5\u5b89\u5168\u63d0\u4ea4\u4fee\u6539\u3002';
const localizeDataViewerReadOnlyReason = (reason: string | undefined, tr: DataViewerTranslator): string => {
const text = String(reason || '').trim();
if (!text) return tr('data_viewer.read_only.reason.no_safe_locator');
if (text === READ_ONLY_REASON_NO_SAFE_LOCATOR) {
return tr('data_viewer.read_only.reason.no_safe_locator');
}
if (text === READ_ONLY_REASON_ORACLE_ROWID_MISSING) {
return tr('data_viewer.read_only.reason.oracle_rowid_missing');
}
if (text.startsWith(READ_ONLY_REASON_PRIMARY_KEY_MISSING_PREFIX) && text.endsWith(READ_ONLY_REASON_SAFE_SUBMIT_SUFFIX)) {
const columns = text.slice(READ_ONLY_REASON_PRIMARY_KEY_MISSING_PREFIX.length, -READ_ONLY_REASON_SAFE_SUBMIT_SUFFIX.length);
return tr('data_viewer.read_only.reason.primary_key_column_missing', { columns });
}
return text;
};
const localizeDataViewerReadOnlyLocator = (locator: EditRowLocator, tr: DataViewerTranslator): EditRowLocator => {
if (!locator.readOnly) return locator;
return { ...locator, reason: localizeDataViewerReadOnlyReason(locator.reason, tr) };
};
const warnDataViewerReadOnly = (
kind: 'table' | 'collection',
target: string,
reason: string | undefined,
tr: DataViewerTranslator,
) => {
const key = kind === 'table'
? 'data_viewer.read_only.warning.table'
: 'data_viewer.read_only.warning.collection';
message.warning(tr(key, {
target,
reason: localizeDataViewerReadOnlyReason(reason, tr),
}));
};
const formatDataViewerTableName = (dbName: string, tableName: string): string => (
dbName ? `${dbName}.${tableName}` : tableName
);
@@ -116,13 +160,13 @@ const getTableColumnNames = (columns: ColumnDefinition[] | undefined): string[]
const MONGODB_ID_COLUMN = '_id';
const MONGODB_ID_LOCATOR_COLUMN = '__gonavi_mongodb_id_locator__';
const buildMongoDataViewerEditLocator = (resultColumns: string[]): EditRowLocator => {
const buildMongoDataViewerEditLocator = (resultColumns: string[], tr: DataViewerTranslator): EditRowLocator => {
const columns = (resultColumns || [])
.map((column) => String(column || '').trim())
.filter(Boolean);
const idColumn = columns.find((column) => column.toLowerCase() === MONGODB_ID_COLUMN);
if (!idColumn) {
return buildDataViewerReadOnlyLocator('MongoDB 结果集中缺少 _id无法安全提交修改。');
return buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.mongo_id_missing'));
}
const locatorValueColumn = columns.find((column) => column === MONGODB_ID_LOCATOR_COLUMN) || idColumn;
@@ -210,16 +254,16 @@ const isDuckDBComplexColumnType = (columnType?: string): boolean => {
return raw.includes('map') || raw.includes('struct') || raw.includes('union') || raw.includes('array') || raw.includes('list');
};
const formatDataViewerQueryError = (dbType: string, messageText: unknown): string => {
const rawMessage = String(messageText || '查询失败').trim() || '查询失败';
const formatDataViewerQueryError = (dbType: string, messageText: unknown, tr: DataViewerTranslator): string => {
const rawMessage = String(messageText || tr('data_viewer.message.query_failed')).trim() || tr('data_viewer.message.query_failed');
const lower = rawMessage.toLowerCase();
const isTimeout = lower.includes('context deadline exceeded') || lower.includes('deadline exceeded') || lower.includes('timeout') || lower.includes('timed out') || lower.includes('超时');
const isTimeout = lower.includes('context deadline exceeded') || lower.includes('deadline exceeded') || lower.includes('timeout') || lower.includes('timed out') || lower.includes('\u8d85\u65f6');
const isDuckDBInterrupted = String(dbType || '').trim().toLowerCase() === 'duckdb' && (lower.includes('interrupt error') || lower.includes('interrupted'));
if (isTimeout || isDuckDBInterrupted) {
if (String(dbType || '').trim().toLowerCase() === 'duckdb') {
return 'DuckDB 查询超过连接超时时间,已中断。请调大连接超时时间,或减少排序/筛选范围后重试。';
return tr('data_viewer.message.duckdb_query_timeout');
}
return '查询超过连接超时时间,已中断。请调大连接超时时间,或减少查询范围后重试。';
return tr('data_viewer.message.query_timeout');
}
return rawMessage;
};
@@ -286,6 +330,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
const connections = useStore(state => state.connections);
const addSqlLog = useStore(state => state.addSqlLog);
const appearance = useStore(state => state.appearance);
const languagePreference = useStore(state => state.languagePreference);
const language = resolveLanguage(languagePreference);
const tr = useCallback((key: string, params?: I18nParams) => translate(key, params, language), [language]);
const isV2Ui = appearance?.uiVersion === 'v2';
const fetchSeqRef = useRef(0);
const countSeqRef = useRef(0);
@@ -433,7 +480,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
const countKey = latestCountKeyRef.current;
if (!config || !countSql || !countKey) {
message.warning('当前结果集尚未就绪,请先执行一次加载');
message.warning(tr('data_viewer.message.result_not_ready'));
return;
}
@@ -452,7 +499,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
sql: countSql,
status: resCount?.success ? 'success' : 'error',
duration: countDuration,
message: resCount?.success ? '' : String(resCount?.message || '统计失败'),
message: resCount?.success ? '' : String(resCount?.message || tr('data_viewer.message.total_count_failed')),
dbName
});
@@ -461,7 +508,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
if (!resCount?.success) {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
message.error(String(resCount?.message || '统计总数失败'));
message.error(String(resCount?.message || tr('data_viewer.message.total_count_failed')));
return;
}
if (!Array.isArray(resCount.data) || resCount.data.length === 0) {
@@ -472,7 +519,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
const total = parseTotalFromCountRow(resCount.data[0]);
if (total === null) {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
message.error('统计结果解析失败');
message.error(tr('data_viewer.message.total_count_parse_failed'));
return;
}
@@ -489,9 +536,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
if (manualCountSeqRef.current !== countSeq) return;
if (manualCountKeyRef.current !== countKey) return;
setPagination(prev => ({ ...prev, totalCountLoading: false }));
message.error(`统计总数失败: ${String(e?.message || e)}`);
message.error(tr('data_viewer.message.total_count_failed_detail', { detail: String(e?.message || e) }));
}
}, [addSqlLog]);
}, [addSqlLog, tr]);
const handleCancelManualTotalCount = useCallback(() => {
manualCountSeqRef.current++;
@@ -503,7 +550,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
setLoading(true);
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
message.error("Connection not found");
message.error(tr('data_viewer.message.connection_not_found'));
if (fetchSeqRef.current === seq) setLoading(false);
return;
}
@@ -537,7 +584,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
try {
mongoFilter = buildMongoFilter(effectiveFilterConditions);
} catch (e: any) {
message.error(`Mongo 筛选条件无效:${String(e?.message || e || '解析失败')}`);
const detail = String(e?.message || e || tr('data_viewer.message.mongo_filter_parse_failed'));
message.error(tr('data_viewer.message.mongo_filter_invalid_detail', { detail }));
if (fetchSeqRef.current === seq) setLoading(false);
return;
}
@@ -561,19 +609,19 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
const [resCols, resIndexes] = await Promise.all([
DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName),
DBGetIndexes(buildRpcConnectionConfig(config) as any, dbName, tableName)
.catch((error: any) => ({ success: false, message: String(error?.message || error || '加载索引失败'), data: [] })),
.catch((error: any) => ({ success: false, message: String(error?.message || error || 'Failed to load indexes'), data: [] })),
]);
if (fetchSeqRef.current !== seq) return;
if (pkSeqRef.current !== locatorSeq) return;
if (pkKeyRef.current !== locatorKey) return;
if (!resCols?.success || !Array.isArray(resCols.data)) {
const nextLocator = buildDataViewerReadOnlyLocator('无法加载主键/唯一索引元数据,无法安全提交修改。');
const nextLocator = buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.metadata_unavailable'));
pkColumnsForQuery = [];
editLocatorForQuery = nextLocator;
setPkColumns([]);
setEditLocator(nextLocator);
message.warning(`${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason}`);
warnDataViewerReadOnly('table', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr);
} else {
const columnDefs = resCols.data as ColumnDefinition[];
const primaryKeys = columnDefs
@@ -587,16 +635,16 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
const locatorColumns = isOracleLikeDialect(dbType)
? [...resultColumns, ORACLE_ROWID_LOCATOR_COLUMN]
: resultColumns;
let nextLocator = resolveEditRowLocator({
let nextLocator = localizeDataViewerReadOnlyLocator(resolveEditRowLocator({
dbType,
resultColumns: locatorColumns,
primaryKeys,
indexes,
allowOracleRowID: true,
});
}), tr);
if (nextLocator.readOnly && primaryKeys.length === 0 && !resIndexes?.success && !isOracleLikeDialect(dbType)) {
nextLocator = buildDataViewerReadOnlyLocator('无法加载唯一索引元数据,无法安全提交修改。');
nextLocator = buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.index_metadata_unavailable'));
}
pkColumnsForQuery = primaryKeys;
@@ -604,19 +652,19 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
setPkColumns(primaryKeys);
setEditLocator(nextLocator);
if (nextLocator.readOnly) {
message.warning(`${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason || '当前结果没有可用的安全行定位方式,无法提交修改。'}`);
warnDataViewerReadOnly('table', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr);
}
}
} catch {
if (fetchSeqRef.current !== seq) return;
if (pkSeqRef.current !== locatorSeq) return;
if (pkKeyRef.current !== locatorKey) return;
const nextLocator = buildDataViewerReadOnlyLocator('无法加载主键/唯一索引元数据,无法安全提交修改。');
const nextLocator = buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.metadata_unavailable'));
pkColumnsForQuery = [];
editLocatorForQuery = nextLocator;
setPkColumns([]);
setEditLocator(nextLocator);
message.warning(`${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason}`);
warnDataViewerReadOnly('table', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr);
}
}
}
@@ -660,8 +708,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
} else {
const baseSql = buildDataViewerBaseSelectSQL(dbType, tableName, whereSQL, editLocatorForQuery);
sql = `${baseSql}${orderBySQL}`;
// ClickHouse 深分页在超大 OFFSET 下容易超时。对于总数已知且存在 ORDER BY 的场景,
// 当“尾部偏移”小于“头部偏移”时,改为反向 ORDER BY + 小 OFFSET并在前端翻转结果。
// ClickHouse deep pagination with very large OFFSET can be slow. When the tail offset is smaller,
// query in reverse ORDER BY with a smaller OFFSET, then reverse rows in the frontend.
if (isClickHouse && totalKnown && offset > 0 && reverseOrderSQL) {
const pageRowCount = Math.max(0, Math.min(size, totalRows - offset));
if (pageRowCount > 0) {
@@ -715,7 +763,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
const hasSort = hasExplicitSort(sortInfo);
const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || ''));
let resData = await executeDataQuery(sql, '主查询');
let resData = await executeDataQuery(sql, tr('data_viewer.sql_log.phase.main_query'));
if (!resData.success && dbTypeLower === 'duckdb' && isDuckDBUnsupportedTypeError(String(resData.message || ''))) {
const cacheKey = `${tab.connectionId}|${dbName}|${tableName}`;
@@ -748,7 +796,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
let fallbackSql = `SELECT ${safeSelect} FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, resolveDataViewerOrderFallbackColumns(editLocatorForQuery, pkColumnsForQuery)), size + 1, offset);
executedSql = fallbackSql;
resData = await executeDataQuery(fallbackSql, '复杂类型降级重试');
resData = await executeDataQuery(fallbackSql, tr('data_viewer.sql_log.phase.complex_type_fallback_retry'));
}
}
@@ -756,17 +804,17 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
const retrySql32MB = withSortBufferTuningSQL(dbType, sql, 32 * 1024 * 1024);
if (retrySql32MB !== sql) {
executedSql = retrySql32MB;
resData = await executeDataQuery(retrySql32MB, '重试(32MB sort_buffer)');
resData = await executeDataQuery(retrySql32MB, tr('data_viewer.sql_log.phase.sort_buffer_retry', { size: '32MB' }));
}
if (!resData.success && isSortMemoryErr(resData.message)) {
const retrySql128MB = withSortBufferTuningSQL(dbType, sql, 128 * 1024 * 1024);
if (retrySql128MB !== executedSql) {
executedSql = retrySql128MB;
resData = await executeDataQuery(retrySql128MB, '重试(128MB sort_buffer)');
resData = await executeDataQuery(retrySql128MB, tr('data_viewer.sql_log.phase.sort_buffer_retry', { size: '128MB' }));
}
}
if (resData.success) {
message.warning('已自动提升排序缓冲并重试成功。');
message.warning(tr('data_viewer.message.sort_buffer_retry_succeeded'));
}
}
@@ -788,13 +836,13 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
}
if (fetchSeqRef.current !== seq) return;
if (isMongoDB && !forceReadOnly && tableName) {
const nextLocator = buildMongoDataViewerEditLocator(fieldNames);
const nextLocator = buildMongoDataViewerEditLocator(fieldNames, tr);
pkColumnsForQuery = nextLocator.readOnly ? [] : [MONGODB_ID_COLUMN];
editLocatorForQuery = nextLocator;
setPkColumns(pkColumnsForQuery);
setEditLocator(nextLocator);
if (nextLocator.readOnly && resultData.length > 0) {
message.warning(`集合 ${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason || '当前结果没有可用的安全行定位方式,无法提交修改。'}`);
warnDataViewerReadOnly('collection', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr);
}
}
setColumnNames(fieldNames);
@@ -880,8 +928,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
countKeyRef.current = countKey;
const countSeq = ++countSeqRef.current;
const countStart = Date.now();
// 大表 COUNT(*) 可能非常慢,且在部分运行时环境下会影响后续操作响应;
// DuckDB 大文件场景下该统计会显著拖慢翻页,已禁用后台 COUNT。
// Large-table COUNT(*) can be slow and may delay later operations in some runtimes.
// DuckDB large-file scenarios disable background COUNT because it can slow pagination significantly.
const countConfig = buildRpcConnectionConfig(config, { timeout: 5 });
DBQuery(countConfig, dbName, countSql)
@@ -920,7 +968,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
.catch(() => {
if (countSeqRef.current !== countSeq) return;
if (countKeyRef.current !== countKey) return;
// 统计失败不影响主流程,不弹窗;可在日志里查看。
// Count failures do not block the main flow; details stay in the SQL log.
});
}
}
@@ -1009,11 +1057,11 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
}
}
} else {
message.error(formatDataViewerQueryError(dbTypeLower, resData.message));
message.error(formatDataViewerQueryError(dbTypeLower, resData.message, tr));
}
} catch (e: any) {
if (fetchSeqRef.current !== seq) return;
message.error(formatDataViewerQueryError(dbTypeLower, e?.message || e));
message.error(formatDataViewerQueryError(dbTypeLower, e?.message || e, tr));
addSqlLog({
id: `log-${Date.now()}-error`,
timestamp: Date.now(),
@@ -1025,7 +1073,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
});
}
if (fetchSeqRef.current === seq) setLoading(false);
}, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, editLocator, forceReadOnly, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
}, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, editLocator, forceReadOnly, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages, tr]);
// 依赖定位列:在无手动排序时可回退到安全定位列稳定排序。
// 定位信息只会在表上下文变化后重新加载,避免循环查询。