File size: 10,231 Bytes
bb59211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
/** Workflow Studio 的中英切换。
 *
 * 这是**独立的 React 应用**,不加载 `web/static/js/i18n.js`(那套是给 Jinja 模板
 * 用的)。两边靠两个约定打通:
 *
 *   1. 同一个 localStorage 键 `lang` —— 模板侧 `setLang()` 写、这里读,刷新后一致
 *   2. 自定义事件 `egodata:lang` —— 模板侧切换时派发,这里监听
 *
 * ★ 为什么不用 `storage` 事件:那个**只在别的标签页**触发,同一个标签页里
 *   localStorage 变了它不会响。而语言切换按钮就在本页的侧边栏上,正好是同标签页。
 *
 * 缺词时回落 key 本身(如 `palette.search`)而不是静默显示英文 —— 漏翻译一眼看得见。
 */
import { useEffect, useMemo, useState } from 'react';

export type Lang = 'en' | 'zh';

const LANG_KEY = 'lang';
const LANG_EVENT = 'egodata:lang';

/** 当前语言。默认英文(与模板侧一致)。隐私模式禁 storage 时回落默认。 */
export function currentLang(): Lang {
  try {
    return localStorage.getItem(LANG_KEY) === 'zh' ? 'zh' : 'en';
  } catch {
    return 'en';
  }
}

const DICT: Record<string, { en: string; zh: string }> = {
  // ── 调色板 ──
  'palette.input': { en: 'Input', zh: '输入' },
  'palette.review': { en: 'Review', zh: '审核' },
  'palette.process': { en: 'Process', zh: '处理' },
  'palette.export': { en: 'Export', zh: '导出' },
  'palette.search': { en: 'Search nodes...', zh: '搜索节点...' },
  'palette.hint': { en: 'Drag nodes onto canvas', zh: '拖拽节点到画布' },

  // ── 工具栏 ──
  'toolbar.new': { en: 'New', zh: '新建' },
  'toolbar.save': { en: 'Save', zh: '保存' },
  'toolbar.saveAs': { en: 'Save As', zh: '另存为' },
  'toolbar.export': { en: 'Export', zh: '导出' },
  'toolbar.workflowList': { en: 'Workflow list', zh: '工作流列表' },
  'toolbar.run': { en: 'Run', zh: '运行' },

  // ── 通用 ──
  'common.cancel': { en: 'Cancel', zh: '取消' },
  'common.save': { en: 'Save', zh: '保存' },
  'common.loading': { en: 'Loading...', zh: '加载中...' },

  // ── AI 标注设置弹窗 ──
  'ai.vendor': { en: 'API vendor', zh: 'API 供应商' },
  'ai.model': { en: 'API model', zh: 'API 模型' },
  'ai.key': { en: 'API key (stored with this workflow)', zh: 'API Key(随本工作流保存)' },
  'ai.labelLanguage': { en: 'Label language', zh: '标注语言' },

  // ── 抽屉 / 连线 / 节点 ──
  'drawer.workflows': { en: 'Workflows', zh: '工作流' },
  'drawer.noWorkflows': { en: 'No workflows yet', zh: '暂无工作流' },
  'edge.delete': { en: 'Delete connection', zh: '删除连线' },
  'node.projectOverride': { en: 'Project override', zh: '项目级覆盖' },
  'node.saveForProject': { en: 'Save for this project', zh: '仅保存到本项目' },
  'node.clearOverride': { en: 'Clear project override', zh: '清除项目级覆盖' },
  'node.clear': { en: 'Clear', zh: '清除' },
  'node.clearOverrideHint': { en: 'Project override — click to clear', zh: '项目级覆盖 —— 点击清除' },
  'node.aiSettings': { en: 'AI annotation settings', zh: 'AI 标注设置' },
  'node.aiSettingsShort': { en: 'Data quality settings', zh: '数据质检设置' },

  // ── AI 设置弹窗的测试结果 ──
  'ai.modelRequired': { en: 'Model required', zh: '请先填写模型' },
  'ai.keyRequired': { en: 'API key required', zh: '请先填写 API Key' },
  'ai.testFailed': { en: 'Test failed', zh: '测试失败' },
  'ai.requestFailed': { en: 'Request failed', zh: '请求失败' },
  'ai.networkError': { en: 'network error', zh: '网络错误' },
  'ai.appliesNextRun': { en: 'Applies to the next AI annotation run.', zh: '对下一次 AI 标注生效。' },
  'common.close': { en: 'Close', zh: '关闭' },

  // ── 画布提示 ──
  'toast.dupEdge': { en: 'Already connected: this port pair is already linked.',
                     zh: '已连接:这条端口对已经连过了。' },
  'toast.selfEdge': { en: 'Self-connection is not allowed', zh: '不能连接到自身' },
  'toast.cannotConnect': { en: 'Cannot connect: ', zh: '无法连接:' },
  'toast.portMismatch': {
    en: 'Port mismatch: this input expects different data.',
    zh: '端口类型不匹配:这个输入要的数据类型与上游输出不同。' },
  'toast.stereoPair': { en: 'Stereo pair connected', zh: '双目已配对连接' },

  // ── 数据清洗设置弹窗 ──
  'quality.title': { en: 'Data Cleaning · Settings', zh: '数据清洗 · 设置' },
  'quality.loadFailed': { en: 'Failed to load the check catalog: ', zh: '加载检查项目录失败:' },
  'quality.noDevice': { en: 'This node has no device connected yet', zh: '该节点还没有连接任何设备' },
  'quality.noChecks': { en: 'has no checks available yet', zh: '暂时没有可用的检查项' },

  // ── 顶栏 ──
  'app.template': { en: 'Template', zh: '模板' },
  'app.makeTemplate': { en: 'Make Template', zh: '设为模板' },
  'app.save': { en: 'Save', zh: '保存' },
  'app.del': { en: 'Del', zh: '删除' },
  'app.cancel': { en: 'Cancel', zh: '取消' },
  'app.newWorkflow': { en: 'New Workflow', zh: '新建工作流' },
  'app.startFromTemplate': { en: 'Start from Template', zh: '从模板开始' },
  'app.unsetTemplate': { en: 'Unset Template', zh: '取消模板' },
  'app.noTemplates': { en: 'No templates available — an admin can mark workflows as templates.',
                      zh: '暂无模板 —— 管理员可将工作流标记为模板。' },
  'app.templateHint': { en: 'Applying a template replaces the current canvas — your workflow name and ID stay the same.',
                       zh: '应用模板会替换当前画布 —— 工作流名称与 ID 保持不变。' },
};

export function translate(key: string, lang: Lang): string {
  const entry = DICT[key];
  if (!entry) return key;
  return entry[lang] || entry.en;
}

// ── 节点卡片名 ──────────────────────────────────────────────
//
// 卡片名是**后端声明的**(module_catalog 的 label),前端只是显示。这里给中文
// 对照,key = 模块 slug。查不到就回落到英文原文 —— 后端新增模块时不会显示成
// 空白的 key。
const NODE_LABELS_ZH: Record<string, string> = {
  mono_camera: 'RGB 相机',
  rgb_camera: 'RGB 相机',
  fisheye_camera: 'RGB 相机',
  rgbd_camera: 'RGB-D 相机',
  stereo_camera: '双目 RGB 相机',
  stereo_rgbd_camera: '双目 RGB-D 相机',
  glove_sensor: '触觉手套',
  gripper_device: 'UMI 夹爪',
  ai_annotation: 'AI 标注',
  annotation: '人工标注',
  mediapipe_hand: 'MediaPipe 手部',
  rgb_to_2d_bare_hand: 'RGB_2D_裸手',
  rgb_to_2d_black_glove: 'RGB_2D_黑手套',
  rgbd_to_3d_bare_hand: 'RGB-D_3D_裸手',
  rgbd_to_3d_black_glove: 'RGB-D_3D_黑手套',
  umi_slam_action: 'UMI SLAM 动作',
  data_quality: '数据清洗',
  human_review: '人工审核',
  lerobot_export: 'LeRobot 导出',
  hdf5_export: 'HDF5 导出',
};

/** 相机卡的分类名走的是另一条路(cameraCategoryLabel 直接返回展示串),
 *  按英文原文再兜一层。 */
const DISPLAY_LABELS_ZH: Record<string, string> = {
  'RGB Camera': 'RGB 相机',
  'Stereo RGB Camera': '双目 RGB 相机',
  'RGB-D Camera': 'RGB-D 相机',
  'Stereo RGB-D Camera': '双目 RGB-D 相机',
  'Glove Sensor': '触觉手套',
  'UMI Gripper': 'UMI 夹爪',
};

export function nodeLabel(type: string, fallback: string, lang: Lang): string {
  if (lang !== 'zh') return fallback;
  return NODE_LABELS_ZH[type] || DISPLAY_LABELS_ZH[fallback] || fallback;
}

// ── 端口名 ──────────────────────────────────────────────────
//
// key 用 ``*:端口key`` 表示"所有节点都这么叫",``节点:端口key`` 用于同一个
// 端口在不同卡上叫法不同的情况 —— 最典型的是 ``data``:导出卡上是"可导出数据",
// 质检卡上就是"数据",审核卡上是"待审核数据"。只按端口 key 查会串味。
const PORT_LABELS_ZH: Record<string, string> = {
  '*:video': 'RGB 视频',
  '*:depth': '深度',
  '*:video_left': '左目 RGB 视频',
  '*:video_right': '右目 RGB 视频',
  '*:rgb_video': 'RGB 视频',
  '*:gripper_state': 'ESP 夹爪状态',
  '*:slam_trajectory': 'SLAM 轨迹',
  '*:tactile_force_matrices': '左右力矩阵',
  '*:sensor_data': '手套传感器数据',
  '*:annotation': '标注',
  '*:hand_keypoints': '手部 2D',
  '*:hand_3d': '手部 3D',
  '*:action': '动作',
  '*:reviewed': '已审核数据',
  '*:dataset': '数据集',
  'data_quality:data': '数据',
  'human_review:data': '待审核数据',
  'lerobot_export:data': '可导出数据',
  'hdf5_export:data': '可导出数据',
  'ai_annotation:data': 'RGB 视频',
  'annotation:data': 'RGB 视频',
};

export function portLabel(
  nodeType: string, key: string, fallback: string, lang: Lang,
): string {
  if (lang !== 'zh') return fallback;
  return PORT_LABELS_ZH[`${nodeType}:${key}`] || PORT_LABELS_ZH[`*:${key}`] || fallback;
}

/**
 * 订阅当前语言,返回一个取词函数。
 *
 * 语言变化时**所有用到它的组件都会重渲染** —— 这正是需要的:调色板、工具栏、
 * 节点卡片都要跟着换文字。
 */
export function useLang(): Lang {
  const [lang, setLang] = useState<Lang>(currentLang);

  useEffect(() => {
    const onChange = () => setLang(currentLang());
    window.addEventListener(LANG_EVENT, onChange);
    // 兜底:万一别处(如 devtools)直接改了 localStorage 而没派发事件
    window.addEventListener('storage', onChange);
    return () => {
      window.removeEventListener(LANG_EVENT, onChange);
      window.removeEventListener('storage', onChange);
    };
  }, []);

  return lang;
}

/** 取词函数。需要节点名/端口名的组件再单独调 useLang()。 */
export function useT(): (key: string) => string {
  const lang = useLang();
  return useMemo(() => (key: string) => translate(key, lang), [lang]);
}