fix các bug theo yêu cầu https://fptsoftware362-my.sharepoint.com/❌/g/personal/nampdt_fpt_com/IQAHBJ4A9xqDTLgvt2bhukJEAdRB5LRz2hbJpTivvIiBSYM?wdExp=TEAMS-TREATMENT&web=1&isSPOFile=1&ovuser=f01e930a-b52e-42b1-b70f-a8882b5d043b%2CAnhTNM1%40fpt.com&clickparams=eyJBcHBOYW1lIjoiVGVhbXMtRGVza3RvcCIsIkFwcFZlcnNpb24iOiI0OS8yNjA4MTMxOTMxNyIsIkhhc0ZlZGVyYXRlZFVzZXIiOmZhbHNlfQ%3D%3D --------- Co-authored-by: Duy Le Huu <duylh19@fpt.com> Reviewed-on: #10 Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
13 KiB
i18n — Quy tắc xử lý chuỗi hiển thị
Nguồn: docstring i18n/__init__.py
1. Ngôn ngữ được hỗ trợ
Cowork Local hỗ trợ 3 ngôn ngữ:
LANGUAGES = {
"en": "English",
"ja": "日本語",
"vi": "Tiếng Việt",
}
LANGUAGE_SHORT = {
"en": "EN",
"ja": "JP",
"vi": "VN",
}
DEFAULT_LANGUAGE = "vi"
Ngôn ngữ mặc định là Tiếng Việt (vi).
Hàm tr()
Sử dụng:
tr(key, **kwargs)
để lấy chuỗi hiển thị theo ngôn ngữ hiện tại.
Thứ tự fallback:
Ngôn ngữ hiện tại → English (en) → chính key
Ví dụ, nếu đang dùng tiếng Nhật nhưng key workspace.tab_folder chưa có bản dịch tiếng Nhật:
JA → EN → workspace.tab_folder
Ứng dụng không được crash chỉ vì thiếu bản dịch.
Nếu UI hiển thị một chuỗi dạng:
workspace.tab_folder
thì đây là dấu hiệu cho thấy đang thiếu translation key.
Placeholder
Nếu chuỗi có placeholder, truyền giá trị thông qua kwargs:
tr("composer.attachments", n=3)
Việc .format(**kwargs) được thực hiện sau khi lấy chuỗi dịch.
2. Widget nào phải cập nhật khi đổi ngôn ngữ?
Có 2 loại widget:
| Loại widget | Cách xử lý |
|---|---|
| Widget sống lâu | bind_* cho chuỗi tĩnh; on_language_changed(cb) cho phần còn lại |
| Widget tạm thời | Không cần đăng ký callback; gọi tr() khi tạo widget |
2.0. bind_* — cách mặc định cho chuỗi tĩnh
w.setToolTip(tr("k")) chỉ đúng ở đúng thời điểm chạy dòng đó. bind_* gộp "gán ngay"
và "gán lại sau mỗi lần đổi ngôn ngữ" vào một lời gọi, dùng weakref nên không giữ widget
sống thêm và tự dọn khi widget bị xoá:
from ...i18n import bind_dynamic, bind_items, bind_placeholder, bind_text, bind_tip
self.save_btn = bind_text(QPushButton(), "co4e.save") # thay QPushButton(tr(...))
bind_tip(self.save_btn, "co4e.tt_save") # thay .setToolTip(tr(...))
bind_placeholder(self.chat_input, "co4e.chat_placeholder")
bind_items(self.perm_combo, [f"co4e.perm.{p}" for p in PERMISSION_PRESETS])
form.addRow(bind_text(QLabel(), "co4e.f_label"), self.label_edit) # KHÔNG addRow(tr(...))
Ba luật:
- Chuỗi tĩnh →
bind_*. Đổi tại chỗ, không thêm dòng — quan trọng với file đã sát trần Gate S hoặc đang bị bánh cócLEGACY_ALLOWANCEchốt (quality_gates.md§4). - Chữ phụ thuộc trạng thái →
bind_dynamic(w, setter, fn), vớifnđọc trạng thái: nút Chạy ⇄ Dừng, tooltip Thu gọn ⇄ Mở rộng, nhãn có số đếm. Các nhánh xử lý trạng thái vẫn gọi setter trực tiếp như cũ để phản hồi ngay khi bấm;bind_dynamicchỉ lo lúc đổi ngôn ngữ. Bind cứng một nhãn động sẽ xoá trạng thái khi người dùng đổi ngôn ngữ giữa lúc đang chạy. - Chữ là DỮ LIỆU thì không bind. Tên agent, tên project, tên nhà cung cấp trong
config.PROVIDER_LABELS— dịch danh tính là sai.
QFormLayout.addRow(tr(...), w) và _add_section(outer, tr(...)) là hai bẫy hay gặp:
chúng tự dựng QLabel bên trong, không giữ tham chiếu nào để áp lại. Truyền
bind_text(QLabel(), key) hoặc truyền khoá thay vì chuỗi đã dịch.
2.0b. Nút do CHÍNH Qt vẽ chữ — ui/dialog_buttons.py
tr() không với tới được nhãn nút của mấy widget dựng sẵn: Qt lấy chữ từ bảng dịch của
riêng nó, mà ứng dụng không cài QTranslator nào (bản PySide6 đang dùng cũng không đóng
gói file qtbase_*.qm nào để cài). Kết quả: luôn là tiếng Anh ở cả ba ngôn ngữ.
| Không dùng | Dùng thay |
|---|---|
QDialogButtonBox(Save | Cancel) |
dialog_buttons(Save | Cancel) |
QMessageBox.question(...) == QMessageBox.Yes |
confirm(parent, title, body) |
QInputDialog.getText / getMultiLineText / getItem |
ask_text / ask_multiline / ask_item |
Muốn một nút mang chữ riêng thì truyền khoá vào dialog_buttons, không setText(tr(...))
sau khi dựng — lần đổi ngôn ngữ kế tiếp, ràng buộc sẽ áp lại khoá mặc định và xoá mất chữ đó:
self.buttons = dialog_buttons(QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
ok="schedtask.ai_confirm")
Ba cổng trong tests/ui/test_i18n_khong_hardcode_chu.py canh việc này.
2.1. Widget sống lâu
Ví dụ:
- Chrome của cửa sổ chính.
- Tab.
- Sidebar.
- Composer.
Các widget này vẫn tồn tại khi người dùng đổi ngôn ngữ.
Vì vậy phải:
-
Đăng ký
on_language_changed(cb). -
Trong callback, gọi lại
tr()cho các text của chính widget. -
Callback phải chạy:
- Một lần ngay khi đăng ký.
- Mỗi lần người dùng đổi ngôn ngữ.
Tên callback được sử dụng trong repo:
_retranslate()
_apply_i18n()
Có thể tham khảo implementation chuẩn từ:
ui/workspace_tab.py:484
2.2. Widget tạm thời
Ví dụ:
- Settings dialog.
- Skills dialog.
- Flow dialog.
- Permission dialog.
Các dialog này được tạo lại từ đầu mỗi lần mở.
Vì vậy chỉ cần gọi tr() khi construct widget.
Không cần đăng ký on_language_changed().
Bug thường gặp
Triệu chứng:
Đổi ngôn ngữ nhưng một label/nút vẫn giữ ngôn ngữ cũ.
Nguyên nhân thường là:
- Widget sống lâu nhưng chưa đăng ký
on_language_changed(). - Callback có đăng ký nhưng quên cập nhật label đó.
Cách sửa đúng:
bind_* tại chính dòng đang gán (mục 2.0), hoặc — nếu chữ phụ thuộc trạng thái/dữ liệu —
sửa trong _retranslate() / _apply_i18n() của chính widget.
Không giải quyết bằng cách gọi tr() ở một nơi khác chỉ để ép label thay đổi.
Cách TÌM ra hết các chỗ bị lỗi
Đừng grep chuỗi tiếng Việt trong source: lượt audit tháng 9/2026 grep ra 962 dòng mà
không dòng nào là lỗi thật (toàn docstring), trong khi 84 lỗi thật lại không xuất hiện
— vì chúng đi qua tr() đúng cách, chỉ thiếu người áp lại.
Phép đo đúng nằm ở tests/ui/test_i18n_khong_con_chu_cu.py: dựng MainWindow thật, thay
tr() bằng chuỗi mốc, gọi set_language(), rồi tìm chỗ không mang mốc. Hai chi
tiết mà bản kiểm ngây thơ sẽ sai:
from ...i18n import trcopy tham chiếu vào namespace từng module → phải thaytrở mọi module đã import, không chỉi18n.tr;- lưới vẽ lại bằng
deleteLater()để lại widget cũ còn sống → khôngsendPostedEvents(DeferredDelete)thì báo oan hàng chục widget bóng ma (lượt audit đầu báo 84 lỗi, trong đó 65 là bóng ma và widget bịid()cấp lại làm cắt vòng quét).
Chạy: QT_QPA_PLATFORM=offscreen pytest tests/ui/test_i18n_khong_con_chu_cu.py -q
3. Tổ chức file translation
Thư mục i18n/ được chia theo màn hình/chức năng, không gom tất cả translation vào một file lớn.
Ví dụ:
i18n/
├── login_dialog.py
├── sidebar.py
├── composer.py
├── cowork_tab.py
├── settings_dialog.py
├── skills_dialog.py
├── libreoffice_view.py
├── agents_admin_tab.py
├── monitoring_overview.py
└── hint.py
Mỗi file export một dictionary có dạng:
key → {
"en": "...",
"ja": "...",
"vi": "..."
}
i18n/__init__.py sẽ import và gộp các dictionary này.
Khi thêm key mới
Thực hiện theo 3 bước:
Bước 1 — Chọn đúng file
Đưa key vào file tương ứng với màn hình/chức năng.
Ví dụ:
workspace.* → file liên quan đến workspace
composer.* → composer.py
settings.* → settings_dialog.py
Không đưa key vào login_dialog.py chỉ vì file đó đang có nhiều key nhất.
Bước 2 — Điền đủ 3 ngôn ngữ
Mỗi key mới phải có:
en
ja
vi
Thiếu ja là lỗi đặc biệt cần chú ý vì có thể chỉ được phát hiện khi khách hàng Nhật sử dụng.
Bước 3 — Đặt tên key nhất quán
Format khuyến nghị:
<màn hình>.<thành phần>
Ví dụ:
workspace.tab_folder
app.nav.recents
Tên key phải mô tả rõ nó được dùng ở đâu và cho thành phần nào.
4. Các rủi ro thường gặp với tiếng Nhật và tiếng Việt
| Vấn đề | Triệu chứng | Cách xử lý |
|---|---|---|
| Độ dài chuỗi khác nhau | EN vừa nút nhưng VI bị tràn hoặc JA bị ... |
Không đặt width cố định dựa trên tiếng Anh. Dùng sizeHint(), minimumWidth hoặc cho phép wrap |
| Dấu tiếng Việt bị cắt | Các chữ như Ắ, ộ bị mất dấu |
Không dùng setFixedHeight() cho label. Để layout tự tính chiều cao |
| Thiếu font/glyph tiếng Nhật | Xuất hiện □□□ |
Kiểm tra _FONT trong theme/palettes.py và khai báo font fallback |
| Sắp xếp chuỗi | Project có dấu được sắp xếp không đúng | Dùng locale-aware sorting, không dùng sorted() một cách máy móc |
| Số ký tự không phản ánh chiều rộng | Text bị elide sai, đặc biệt với tiếng Nhật | Dùng QFontMetrics.horizontalAdvance(), không dùng len() để đo chiều rộng |
Đặc biệt lưu ý về độ dài text
Không được giả định:
số ký tự = chiều rộng hiển thị
Ví dụ hai chuỗi có cùng số ký tự nhưng có thể có chiều rộng hiển thị khác nhau.
Khi cần đo text trên UI, dùng:
QFontMetrics.horizontalAdvance(...)
5. Checklist khi sửa lỗi i18n
Trước khi hoàn thành bản vá i18n, phải kiểm tra:
-
Key mới có đủ
en/ja/vi? -
Đã chuyển qua cả 3 ngôn ngữ ngay trong lúc app đang chạy chưa?
Không chỉ restart app rồi kiểm tra. -
jacó khácenkhông? Bằng nhau nghĩa là chưa dịch — trừ tên thương hiệu / ký hiệu, và khi đó phải khai vàoKHOA_KHONG_CAN_DICHkèm lý do. -
Chuỗi tĩnh đã dùng
bind_text/bind_tip/bind_placeholder/bind_itemsthay chosetX(tr(...))một lần? -
Chữ phụ thuộc trạng thái đã dùng
bind_dynamic(không bind cứng, kẻo mất trạng thái)? -
Nếu widget sống lâu và còn phần không bind được, đã đăng ký:
`on_language_changed(...)` -
Callback
_retranslate()hoặc_apply_i18n()đã cập nhật tất cả text liên quan? -
Đã chạy
pytest tests/ui/test_i18n_khong_con_chu_cu.py -qvà nó xanh? -
Không còn chuỗi hardcode mới trong bản vá?
-
Nút hộp thoại đi qua
ui/dialog_buttons.py(mục 2.0b), không dựngQDialogButtonBox/QMessageBox.question/QInputDialog.get*trực tiếp? -
Layout vẫn đúng với chuỗi dài nhất trong 3 ngôn ngữ?
-
Không dùng
len()để tính chiều rộng text? -
Nếu có thay đổi UI, đã kiểm tra cả Dark Mode và Light Mode?
6. Nguyên tắc quan trọng
Khi sửa lỗi i18n, không sửa triệu chứng ở nơi khác.
Ví dụ:
Đổi ngôn ngữ
↓
Label X không thay đổi
↓
Kiểm tra widget X
↓
Widget sống lâu?
↓
Có on_language_changed()?
↓
_retranslate() có cập nhật Label X?
Nếu thiếu callback hoặc callback bỏ sót label, hãy sửa đúng callback của widget đó.
Không thêm các lệnh tr() rải rác ở nơi khác chỉ để làm cho UI thay đổi.
Mục tiêu là đảm bảo cơ chế i18n hoạt động đúng và nhất quán cho toàn bộ ứng dụng.