Skip to content
Clone

feat(i18n): integrate internationalization support with locale handling and message catalog

ZhenYi committed
parent c6d9bbe commit 8ab708f Browse files

232 files changed

+7384 −2728

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 232
Modified lib/agent/src/notifications.rs View lib/agent/src/notifications.rs
@@ -1,46 +1,15 @@
11//! 通知文案的多语言措辞。
22//!
3//! 只覆盖通知标题/正文这类固定短语;真正的内容语言由模型提示词控制。
4//! 未收录的语言回退到英语。
3//! 实现已迁到 `i18n::catalog`:通知文案要和邮件、HTML 结果页共用一份目录,
4//! 否则同一个事件在收件箱和邮件里会出现两种语言。这里保留原有入口签名,
5//! 让 `notify.rs` 的调用点不必关心目录的位置。
56
67/// 「AI 评审完成 · #12」
78pub fn review_title(language: &str, number: i32) -> String {
8 match tag(language).as_str() {
9 "zh" => format!("AI 评审完成 · #{number}"),
10 "ja" => format!("AI レビュー完了 · #{number}"),
11 "ko" => format!("AI 리뷰 완료 · #{number}"),
12 "fr" => format!("Revue IA terminée · #{number}"),
13 "de" => format!("KI-Review abgeschlossen · #{number}"),
14 "es" => format!("Revisión de IA completada · #{number}"),
15 "pt" => format!("Revisão de IA concluída · #{number}"),
16 "it" => format!("Revisione IA completata · #{number}"),
17 "ru" => format!("ИИ-ревью завершено · #{number}"),
18 _ => format!("AI review completed · #{number}"),
19 }
9 i18n::catalog::ai_review_title(language, number)
2010}
2111
2212/// 「3 条行级建议:<summary>」/「<summary>」
2313pub fn review_body(language: &str, findings: usize, summary: &str) -> String {
24 let summary = summary.trim();
25 if findings == 0 {
26 return summary.to_string();
27 }
28 match tag(language).as_str() {
29 "zh" => format!("{findings} 条行级建议:{summary}"),
30 "ja" => format!("行コメント {findings} 件:{summary}"),
31 "ko" => format!("인라인 제안 {findings}건: {summary}"),
32 "fr" => format!("{findings} suggestion(s) en ligne : {summary}"),
33 "de" => format!("{findings} Inline-Hinweis(e): {summary}"),
34 "es" => format!("{findings} sugerencia(s) en línea: {summary}"),
35 "pt" => format!("{findings} sugestão(ões) em linha: {summary}"),
36 "it" => format!("{findings} suggerimento/i in linea: {summary}"),
37 "ru" => format!("{findings} замечаний к строкам: {summary}"),
38 _ => format!("{findings} inline suggestion(s): {summary}"),
39 }
40}
41
42/// 把 BCP-47 代码折叠到语族标签。
43fn tag(language: &str) -> String {
44 let normalized = ci_protocol::ai_languages::normalize(language);
45 normalized.split('-').next().unwrap_or("en").to_string()
14 i18n::catalog::ai_review_body(language, findings, summary)
4615}
Modified lib/agent/Cargo.toml View lib/agent/Cargo.toml
@@ -9,6 +9,7 @@
99models = { workspace = true }
1010git = { workspace = true }
1111ci-protocol = { workspace = true }
12i18n = { workspace = true }
1213sea-orm = { workspace = true }
1314redis = { workspace = true }
1415reqwest = { workspace = true }
Modified lib/auth/src/handlers/agreement_page.rs View lib/auth/src/handlers/agreement_page.rs
@@ -1,5 +1,7 @@
11use axum::extract::{Path, State};
2use axum::http::HeaderMap;
23use axum::response::Html;
4use i18n::catalog;
35
46use crate::errors::ApiError;
57use crate::services::AuthService;
@@ -7,18 +9,28 @@
79
810pub async fn agreement_page(
911 State(service): State<AuthService>,
12 headers: HeaderMap,
1013 Path(key): Path<String>,
1114) -> Result<Html<String>, ApiError> {
15 let locale = service
16 .mail_locale(
17 None,
18 headers
19 .get(axum::http::header::ACCEPT_LANGUAGE)
20 .and_then(|value| value.to_str().ok()),
21 )
22 .await;
1223 let doc = service.list_agreements().into_iter().find(|d| d.key == key);
1324 match doc {
1425 Some(doc) => {
1526 let html_content = templates::render_markdown(&doc.content_md);
16 Ok(templates::page_agreement(&doc, &html_content))
27 Ok(templates::page_agreement(&doc, &html_content, locale))
1728 }
1829 None => Ok(templates::page_message(
1930 false,
20 "Not found",
21 "The requested agreement document does not exist.",
31 catalog::agreement_not_found_title(locale),
32 catalog::agreement_not_found_body(locale),
33 locale,
2234 )),
2335 }
2436}
Modified lib/auth/src/handlers/forgot_password.rs View lib/auth/src/handlers/forgot_password.rs
@@ -22,10 +22,17 @@
2222pub async fn forgot_password(
2323 State(service): State<AuthService>,
2424 session: Session,
25 headers: axum::http::HeaderMap,
2526 Json(cmd): Json<ForgotPasswordCmd>,
2627) -> Result<AppResponseData<ForgotPasswordResp>, ApiError> {
2728 let resp = service
28 .forgot_password(&session, cmd)
29 .forgot_password(
30 &session,
31 cmd,
32 headers
33 .get(axum::http::header::ACCEPT_LANGUAGE)
34 .and_then(|value| value.to_str().ok()),
35 )
2936 .await
3037 .map_err(ApiError::from)?;
3138 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/reclaim.rs View lib/auth/src/handlers/reclaim.rs
@@ -22,10 +22,17 @@
2222pub async fn reclaim(
2323 State(service): State<AuthService>,
2424 session: Session,
25 headers: axum::http::HeaderMap,
2526 Json(cmd): Json<ReclaimCmd>,
2627) -> Result<AppResponseData<ReclaimResp>, ApiError> {
2728 let resp = service
28 .reclaim(&session, cmd)
29 .reclaim(
30 &session,
31 cmd,
32 headers
33 .get(axum::http::header::ACCEPT_LANGUAGE)
34 .and_then(|value| value.to_str().ok()),
35 )
2936 .await
3037 .map_err(ApiError::from)?;
3138 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/register.rs View lib/auth/src/handlers/register.rs
@@ -22,10 +22,17 @@
2222pub async fn register(
2323 State(service): State<AuthService>,
2424 session: Session,
25 headers: axum::http::HeaderMap,
2526 Json(cmd): Json<RegisterCmd>,
2627) -> Result<AppResponseData<RegisterResp>, ApiError> {
2728 let resp = service
28 .register(&session, cmd)
29 .register(
30 &session,
31 cmd,
32 headers
33 .get(axum::http::header::ACCEPT_LANGUAGE)
34 .and_then(|value| value.to_str().ok()),
35 )
2936 .await
3037 .map_err(ApiError::from)?;
3138 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/resend_code.rs View lib/auth/src/handlers/resend_code.rs
@@ -22,10 +22,17 @@
2222pub async fn resend_code(
2323 State(service): State<AuthService>,
2424 session: Session,
25 headers: axum::http::HeaderMap,
2526 Json(cmd): Json<ResendCodeCmd>,
2627) -> Result<AppResponseData<ResendCodeResp>, ApiError> {
2728 let resp = service
28 .resend_code(&session, cmd)
29 .resend_code(
30 &session,
31 cmd,
32 headers
33 .get(axum::http::header::ACCEPT_LANGUAGE)
34 .and_then(|value| value.to_str().ok()),
35 )
2936 .await
3037 .map_err(ApiError::from)?;
3138 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/reset_password_page.rs View lib/auth/src/handlers/reset_password_page.rs
@@ -1,4 +1,5 @@
11use axum::extract::{Query, State};
2use axum::http::HeaderMap;
23use axum::response::Html;
34use maud::html;
45use serde::Deserialize;
@@ -16,19 +17,30 @@
1617}
1718
1819pub async fn reset_password_page(
19 State(_service): State<AuthService>,
20 State(service): State<AuthService>,
21 headers: HeaderMap,
2022 Query(q): Query<ResetPasswordPageQuery>,
2123) -> Result<Html<String>, ApiError> {
2224 let display_email = q.email.clone();
2325 let display_token = q.token.clone();
2426 let display_temp_id = q.temp_id.to_string();
2527
28 let locale = service
29 .mail_locale(
30 None,
31 headers
32 .get(axum::http::header::ACCEPT_LANGUAGE)
33 .and_then(|value| value.to_str().ok()),
34 )
35 .await;
36 let strings = page_strings(locale);
37
2638 let body = html! {
2739 div class="max-w-md mx-auto px-6 py-20" {
2840 div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8" {
29 h1 class="text-2xl font-bold text-gray-900 mb-2" { "Reset your password" }
41 h1 class="text-2xl font-bold text-gray-900 mb-2" { (strings.heading) }
3042 p class="text-sm text-gray-500 mb-6" {
31 "Account: "
43 (strings.account)
3244 (display_email)
3345 }
3446 form id="reset-form" class="space-y-4" {
@@ -37,7 +49,7 @@
3749 input type="hidden" name="temp_id" value=(display_temp_id);
3850 div {
3951 label for="new-password" class="block text-sm font-medium text-gray-700 mb-1" {
40 "New password"
52 (strings.new_password)
4153 }
4254 input type="password" id="new-password" name="new_password"
4355 required
@@ -45,54 +57,106 @@
4557 }
4658 button type="submit"
4759 class="w-full py-2.5 px-4 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition" {
48 "Reset password"
60 (strings.submit)
4961 }
5062 }
5163 div id="result-message" class="mt-4 text-sm hidden" {}
5264 p class="mt-4 text-sm text-gray-500" {
53 "After resetting, all other sessions will be signed out."
65 (strings.signout_note)
5466 }
5567 }
5668 }
5769 script {
58 (maud::PreEscaped(r#"
59document.getElementById('reset-form').addEventListener('submit', async function(e) {
70 (maud::PreEscaped(page_script(locale)))
71 }
72 };
73 Ok(templates::page_shell(strings.heading, body, locale))
74}
75
76/// Copy for the standalone reset page, resolved once per request.
77struct PageStrings {
78 heading: &'static str,
79 account: &'static str,
80 new_password: &'static str,
81 submit: &'static str,
82 signout_note: &'static str,
83}
84
85fn page_strings(locale: i18n::UiLocale) -> PageStrings {
86 match locale {
87 i18n::UiLocale::ZhCn => PageStrings {
88 heading: "重置密码",
89 account: "账号:",
90 new_password: "新密码",
91 submit: "重置密码",
92 signout_note: "重置后,其他所有会话都会被注销。",
93 },
94 i18n::UiLocale::En => PageStrings {
95 heading: "Reset your password",
96 account: "Account: ",
97 new_password: "New password",
98 submit: "Reset password",
99 signout_note: "After resetting, all other sessions will be signed out.",
100 },
101 }
102}
103
104/// Inline form script. The strings are baked into the response because there is
105/// no bundler on this standalone page; the locale was already resolved above.
106fn page_script(locale: i18n::UiLocale) -> String {
107 let (resetting, ok, failed, network, submit) = match locale {
108 i18n::UiLocale::ZhCn => (
109 "正在重置…",
110 "密码重置成功,现在可以登录了。",
111 "重置失败,链接可能已过期。",
112 "网络错误,请重试。",
113 "重置密码",
114 ),
115 i18n::UiLocale::En => (
116 "Resetting...",
117 "Password reset successful. You can now log in.",
118 "Reset failed. The link may have expired.",
119 "Network error. Please try again.",
120 "Reset password",
121 ),
122 };
123 format!(
124 r#"
125document.getElementById('reset-form').addEventListener('submit', async function(e) {{
60126 e.preventDefault();
61127 var btn = this.querySelector('button[type="submit"]');
62128 var msg = document.getElementById('result-message');
63129 btn.disabled = true;
64 btn.textContent = 'Resetting...';
130 btn.textContent = '{resetting}';
65131 msg.className = 'mt-4 text-sm hidden';
66 try {
67 var payload = {
132 try {{
133 var payload = {{
68134 email: this.email.value,
69135 token: this.token.value,
70136 temp_id: this.temp_id.value,
71137 new_password: this.new_password.value
72 };
73 var resp = await fetch('/auth/password/reset', {
138 }};
139 var resp = await fetch('/auth/password/reset', {{
74140 method: 'POST',
75 headers: {'Content-Type': 'application/json'},
76 body: JSON.stringify({data: payload})
77 });
141 headers: {{'Content-Type': 'application/json'}},
142 body: JSON.stringify({{data: payload}})
143 }});
78144 var body = await resp.json();
79 if (resp.ok) {
145 if (resp.ok) {{
80146 msg.className = 'mt-4 text-sm text-green-700';
81 msg.textContent = 'Password reset successful. You can now log in.';
147 msg.textContent = '{ok}';
82148 this.classList.add('hidden');
83 } else {
149 }} else {{
84150 msg.className = 'mt-4 text-sm text-red-600';
85 msg.textContent = body.message || 'Reset failed. The link may have expired.';
86 }
87 } catch (_) {
151 msg.textContent = body.message || '{failed}';
152 }}
153 }} catch (_) {{
88154 msg.className = 'mt-4 text-sm text-red-600';
89 msg.textContent = 'Network error. Please try again.';
90 }
155 msg.textContent = '{network}';
156 }}
91157 btn.disabled = false;
92 btn.textContent = 'Reset password';
93});
94"#))
95 }
96 };
97 Ok(templates::page_shell("Reset your password", body))
158 btn.textContent = '{submit}';
159}});
160"#
161 )
98162}
Modified lib/auth/src/handlers/verify_email_page.rs View lib/auth/src/handlers/verify_email_page.rs
@@ -1,4 +1,5 @@
11use axum::extract::State;
2use axum::http::HeaderMap;
23use axum::response::Html;
34use models::users::user_email_verification_codes::EmailVerificationPurpose;
45use serde::Deserialize;
@@ -19,12 +20,24 @@
1920
2021pub async fn verify_email_page(
2122 State(service): State<AuthService>,
23 headers: HeaderMap,
2224 axum::extract::Query(q): axum::extract::Query<VerifyEmailPageQuery>,
2325) -> Result<Html<String>, ApiError> {
2426 let purpose = match q.purpose.as_deref() {
2527 Some("password_reset") => EmailVerificationPurpose::PasswordReset,
2628 _ => EmailVerificationPurpose::Signup,
2729 };
30 // The page is opened straight from a mail client, so the browser's
31 // Accept-Language is the only signal available; the account lookup is not
32 // reachable before the code is verified.
33 let locale = service
34 .mail_locale(
35 None,
36 headers
37 .get(axum::http::header::ACCEPT_LANGUAGE)
38 .and_then(|value| value.to_str().ok()),
39 )
40 .await;
2841 let cmd = VerifyEmailCmd {
2942 email: q.email,
3043 purpose,
@@ -32,13 +45,7 @@
3245 temp_id: q.temp_id,
3346 };
3447 match service.verify_verification_code(cmd).await {
35 Ok(_) => Ok(templates::page_verify_result(
36 true,
37 "Your email has been verified. You can now continue.",
38 )),
39 Err(_) => Ok(templates::page_verify_result(
40 false,
41 "The verification code is invalid or has expired.",
42 )),
48 Ok(_) => Ok(templates::page_verify_result(true, locale)),
49 Err(_) => Ok(templates::page_verify_result(false, locale)),
4350 }
4451}
Modified lib/auth/src/services/email.rs View lib/auth/src/services/email.rs
@@ -255,6 +255,7 @@
255255 &self,
256256 session: &Session,
257257 cmd: ResendCodeCmd,
258 accept_language: Option<&str>,
258259 ) -> Result<ResendCodeResp, AuthError> {
259260 self.verify_pow(session, &cmd.pow_answer)?;
260261 let lower_email = cmd.email.trim().to_lowercase();
@@ -273,12 +274,11 @@
273274 let artifacts = self
274275 .issue_code(email_row.user_id, &lower_email, cmd.purpose)
275276 .await?;
276 let subject = match cmd.purpose {
277 EmailVerificationPurpose::Signup => "Verify your account",
278 EmailVerificationPurpose::PasswordReset => "Reset your password",
279 EmailVerificationPurpose::AddEmail => "Add your email",
280 EmailVerificationPurpose::Reclaim => "Recover your account",
281 };
277 let locale = self
278 .mail_locale(Some(email_row.user_id), accept_language)
279 .await;
280 let purpose = verify_purpose(cmd.purpose);
281 let subject = i18n::catalog::verify_subject(locale, purpose);
282282 let minutes = self.config().auth.verify_code_ttl_secs / 60;
283283 let link = build_verify_link(
284284 &self.config().web.web_base_url,
@@ -288,8 +288,8 @@
288288 cmd.purpose,
289289 );
290290 let html =
291 crate::templates::mail_verify_code(&artifacts.code, minutes, subject, link.as_deref());
292 let body = format!("Your verification code is: {}", artifacts.code);
291 crate::templates::mail_verify_code(&artifacts.code, minutes, subject, link.as_deref(), locale);
292 let body = i18n::catalog::verify_text(locale, &artifacts.code);
293293 let sent = self
294294 .send_email(email_row.email.as_str(), subject, &body, html.0.as_str())
295295 .await
@@ -309,6 +309,7 @@
309309 &self,
310310 session: &Session,
311311 cmd: ReclaimCmd,
312 accept_language: Option<&str>,
312313 ) -> Result<ReclaimResp, AuthError> {
313314 self.verify_pow(session, &cmd.pow_answer)?;
314315 let lower_email = cmd.email.trim().to_lowercase();
@@ -347,17 +348,22 @@
347348 &artifacts.code,
348349 EmailVerificationPurpose::Reclaim,
349350 );
351 let locale = self
352 .mail_locale(Some(user.uid), accept_language)
353 .await;
354 let subject = i18n::catalog::verify_subject(locale, i18n::VerifyPurpose::Reclaim);
350355 let html = crate::templates::mail_verify_code(
351356 &artifacts.code,
352357 minutes,
353 "Recover your account",
358 subject,
354359 link.as_deref(),
360 locale,
355361 );
356 let body = format!("Recover your account with code: {}", artifacts.code);
362 let body = i18n::catalog::verify_text(locale, &artifacts.code);
357363 let sent = self
358364 .send_email(
359365 email_row.email.as_str(),
360 "Recover your account",
366 subject,
361367 &body,
362368 html.0.as_str(),
363369 )
@@ -450,15 +456,22 @@
450456
451457 let expires_at = now + time::Duration::seconds(config.verify_code_ttl_secs as i64);
452458 let base_url = &self.config().web.web_base_url;
459 let locale = self.mail_locale(Some(uid), None).await;
453460 let html = crate::templates::mail_email_change_confirm(
454461 &new_email,
455462 &ticket_id.to_string(),
456463 &code,
457464 base_url,
465 locale,
458466 );
459 let body = format!("Your email change code is: {code}");
467 let body = i18n::catalog::email_change_text(locale, &code);
460468 if let Err(error) = self
461 .send_email(&new_email, "Confirm your new email", &body, html.0.as_str())
469 .send_email(
470 &new_email,
471 i18n::catalog::email_change_subject(locale),
472 &body,
473 html.0.as_str(),
474 )
462475 .await
463476 {
464477 tracing::warn!(%error, "failed to send email change code");
@@ -632,3 +645,16 @@
632645 }
633646 out
634647}
648
649/// Bridge the model enum to the catalogue's own purpose enum, keeping `i18n`
650/// free of a dependency on `models`.
651pub(crate) fn verify_purpose(
652 purpose: EmailVerificationPurpose,
653) -> i18n::VerifyPurpose {
654 match purpose {
655 EmailVerificationPurpose::Signup => i18n::VerifyPurpose::Signup,
656 EmailVerificationPurpose::PasswordReset => i18n::VerifyPurpose::PasswordReset,
657 EmailVerificationPurpose::AddEmail => i18n::VerifyPurpose::AddEmail,
658 EmailVerificationPurpose::Reclaim => i18n::VerifyPurpose::Reclaim,
659 }
660}
No newline at end of file
Modified lib/auth/src/services/mod.rs View lib/auth/src/services/mod.rs
@@ -51,6 +51,26 @@
5151 &self.state.redis
5252 }
5353
54 /// Pick the mail language for one recipient.
55 ///
56 /// Order: the recipient's own `users.language`/`users.locale`, then the
57 /// request's `Accept-Language`, then `[email].default_locale`. Account
58 /// preference wins because a single request can notify several recipients.
59 pub async fn mail_locale(
60 &self,
61 uid: Option<uuid::Uuid>,
62 accept_language: Option<&str>,
63 ) -> i18n::UiLocale {
64 let fallback = i18n::UiLocale::parse(self.config().email.default_locale())
65 .unwrap_or(i18n::UiLocale::En);
66 match uid {
67 Some(uid) => {
68 i18n::resolve_user_locale(self.db().read(), uid, fallback).await
69 }
70 None => i18n::negotiate_accept_language(accept_language, fallback),
71 }
72 }
73
5474 pub async fn send_email(
5575 &self,
5676 to: &str,
Modified lib/auth/src/services/password.rs View lib/auth/src/services/password.rs
@@ -50,6 +50,7 @@
5050 &self,
5151 session: &Session,
5252 cmd: ForgotPasswordCmd,
53 accept_language: Option<&str>,
5354 ) -> Result<ForgotPasswordResp, AuthError> {
5455 self.verify_pow(session, &cmd.pow_answer)?;
5556 let config = &self.config().auth;
@@ -87,16 +88,20 @@
8788 .await?;
8889
8990 let base_url = &self.config().web.web_base_url;
90 let html =
91 crate::templates::mail_reset_link(&lower_email, &token, &temp_id.to_string(), base_url);
92 let body = format!("Use this reset token within the next minutes: {token}");
91 let locale = self
92 .mail_locale(Some(email_row.user_id), accept_language)
93 .await;
94 let subject = i18n::catalog::reset_subject(locale);
95 let html = crate::templates::mail_reset_link(
96 &lower_email,
97 &token,
98 &temp_id.to_string(),
99 base_url,
100 locale,
101 );
102 let body = i18n::catalog::reset_text(locale, &token);
93103 let sent = self
94 .send_email(
95 email_row.email.as_str(),
96 "Reset your password",
97 &body,
98 html.0.as_str(),
99 )
104 .send_email(email_row.email.as_str(), subject, &body, html.0.as_str())
100105 .await
101106 .map(|_| true)
102107 .unwrap_or_else(|error| {
Modified lib/auth/src/services/register.rs View lib/auth/src/services/register.rs
@@ -33,10 +33,14 @@
3333}
3434
3535impl AuthService {
36 /// ccept_language is the raw request header; the recipient has no
37 /// account preference yet (the row is created here), so the mail follows
38 /// the browser and only falls back to [email].default_locale.
3639 pub async fn register(
3740 &self,
3841 session: &Session,
3942 cmd: RegisterCmd,
43 accept_language: Option<&str>,
4044 ) -> Result<RegisterResp, AuthError> {
4145 self.verify_pow(session, &cmd.pow_answer)?;
4246
@@ -160,15 +164,13 @@
160164 &artifacts.code,
161165 models::users::user_email_verification_codes::EmailVerificationPurpose::Signup,
162166 );
163 let html = crate::templates::mail_verify_code(
164 &artifacts.code,
165 minutes,
166 "Verify your account",
167 link.as_deref(),
168 );
169 let text = format!("Verify your account with code: {}", artifacts.code);
167 let locale = self.mail_locale(None, accept_language).await;
168 let purpose = i18n::catalog::verify_subject(locale, i18n::VerifyPurpose::Signup);
169 let html =
170 crate::templates::mail_verify_code(&artifacts.code, minutes, purpose, link.as_deref(), locale);
171 let text = i18n::catalog::verify_text(locale, &artifacts.code);
170172 if let Err(error) = self
171 .send_email(&email, "Verify your account", &text, html.0.as_str())
173 .send_email(&email, purpose, &text, html.0.as_str())
172174 .await
173175 {
174176 tracing::warn!(%error, "failed to send verification email");
Modified lib/auth/src/templates/common.rs View lib/auth/src/templates/common.rs
@@ -1,10 +1,11 @@
11use axum::response::Html;
2use i18n::UiLocale;
23use maud::{DOCTYPE, Markup, html};
34
4pub(crate) fn page_shell(title: &str, body: Markup) -> Html<String> {
5pub(crate) fn page_shell(title: &str, body: Markup, locale: UiLocale) -> Html<String> {
56 let v = html! {
67 (DOCTYPE)
7 html lang="en" {
8 html lang=(locale.tag()) {
89 head {
910 meta charset="utf-8";
1011 meta name="viewport" content="width=device-width, initial-scale=1";
Modified lib/auth/src/templates/mod.rs View lib/auth/src/templates/mod.rs
@@ -4,17 +4,26 @@
44use axum::response::Html;
55pub(crate) use common::page_shell;
66
7/// Thin wrappers over the `email` templates: the mail crate owns the markup and
8/// the catalogue, this crate owns the data the request happened to have.
79pub fn mail_verify_code(
810 code: &str,
911 minutes: u64,
1012 purpose: &str,
1113 link: Option<&str>,
14 locale: i18n::UiLocale,
1215) -> Html<String> {
13 Html(email::templates::mail_verify_code(code, minutes, purpose, link).into_string())
16 Html(email::templates::mail_verify_code(code, minutes, purpose, link, locale).into_string())
1417}
1518
16pub fn mail_reset_link(email: &str, token: &str, temp_id: &str, base_url: &str) -> Html<String> {
17 Html(email::templates::mail_reset_link(email, token, temp_id, base_url).into_string())
19pub fn mail_reset_link(
20 email: &str,
21 token: &str,
22 temp_id: &str,
23 base_url: &str,
24 locale: i18n::UiLocale,
25) -> Html<String> {
26 Html(email::templates::mail_reset_link(email, token, temp_id, base_url, locale).into_string())
1827}
1928
2029pub fn mail_email_change_confirm(
@@ -22,9 +31,10 @@
2231 ticket_id: &str,
2332 code: &str,
2433 base_url: &str,
34 locale: i18n::UiLocale,
2535) -> Html<String> {
2636 Html(
27 email::templates::mail_email_change_confirm(new_email, ticket_id, code, base_url)
37 email::templates::mail_email_change_confirm(new_email, ticket_id, code, base_url, locale)
2838 .into_string(),
2939 )
3040}
Modified lib/auth/src/templates/page.rs View lib/auth/src/templates/page.rs
@@ -1,36 +1,41 @@
11use axum::response::Html;
22use maud::html;
3use i18n::{UiLocale, catalog};
34
45use crate::share::types::AgreementDoc;
56
67use super::common::page_shell;
78
8pub fn page_agreement(doc: &AgreementDoc, html_content: &str) -> Html<String> {
9pub fn page_agreement(doc: &AgreementDoc, html_content: &str, locale: UiLocale) -> Html<String> {
910 let body = html! {
1011 div class="max-w-3xl mx-auto px-6 py-12" {
1112 div class="mb-8" {
1213 h1 class="text-3xl font-bold tracking-tight" { (doc.title) }
1314 p class="mt-2 text-sm text-gray-500" {
14 "Version "
15 (doc.version)
15 (catalog::agreement_version(locale, &doc.version))
1616 }
1717 }
1818 article class="prose prose-lg max-w-none" {
1919 (maud::PreEscaped(html_content))
2020 }
2121 footer class="mt-12 border-t pt-6 text-sm text-gray-500" {
22 "By continuing you agree to this document."
22 (catalog::agreement_footer(locale))
2323 }
2424 }
2525 };
26 page_shell(&doc.title, body)
26 page_shell(&doc.title, body, locale)
2727}
2828
29pub fn page_verify_result(ok: bool, message: &str) -> Html<String> {
29pub fn page_verify_result(ok: bool, locale: UiLocale) -> Html<String> {
3030 let title = if ok {
31 "Email verified"
31 catalog::email_verified_title(locale)
3232 } else {
33 "Verification failed"
33 catalog::verification_failed_title(locale)
34 };
35 let message = if ok {
36 catalog::email_verified_body(locale)
37 } else {
38 catalog::verification_failed_body(locale)
3439 };
3540 let (bg, title_color) = if ok {
3641 ("bg-green-50", "text-green-800")
@@ -47,10 +52,10 @@
4752 }
4853 }
4954 };
50 page_shell(title, body)
55 page_shell(title, body, locale)
5156}
5257
53pub fn page_message(ok: bool, title: &str, message: &str) -> Html<String> {
58pub fn page_message(ok: bool, title: &str, message: &str, locale: UiLocale) -> Html<String> {
5459 let bg = if ok { "bg-green-50" } else { "bg-blue-50" };
5560 let title_color = if ok {
5661 "text-green-800"
@@ -67,7 +72,7 @@
6772 }
6873 }
6974 };
70 page_shell(title, body)
75 page_shell(title, body, locale)
7176}
7277
7378pub fn render_markdown(input: &str) -> String {
Modified lib/auth/Cargo.toml View lib/auth/Cargo.toml
@@ -31,5 +31,6 @@
3131ssh-key = { workspace = true }
3232tracing = { workspace = true }
3333email = { workspace = true }
34i18n = { workspace = true }
3435maud = { workspace = true }
3536pulldown-cmark = { workspace = true }
No newline at end of file
Modified lib/chat/src/handlers/voice.rs View lib/chat/src/handlers/voice.rs
@@ -1,3 +1,4 @@
1use axum::Json;
12use axum::extract::{Path, Query, State};
23use axum::http::HeaderMap;
34use export::rest::{AppResponseData, NoData};
@@ -11,7 +12,7 @@
1112use crate::errors::ApiError;
1213use crate::services::service::ChatService;
1314use crate::services::voice::{
14 HandDto, VoiceConfigDto, VoiceHandsDto, VoiceModerationDto, VoiceParticipantDto,
15 HandDto, VoiceConfigDto, VoiceHandsDto, VoiceLayoutDto, VoiceModerationDto, VoiceParticipantDto,
1516 VoiceRecordingDto, VoiceSpaceStatesDto, VoiceTokenDto,
1617};
1718
@@ -122,6 +123,52 @@
122123}
123124
124125#[utoipa::path(
126 get,
127 path = "/chat/rooms/{room_id}/voice/layout",
128 tag = "chat",
129 description = "读取房间内共享的参与者窗口布局(坐标按画布宽高归一化)。",
130 params(("room_id" = Uuid, Path, description = "room id")),
131 responses(
132 (status = 200, description = "shared layout", body = AppResponseData<VoiceLayoutDto>),
133 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
134 (status = 403, description = "operation not allowed", body = AppResponseData<NoData>)
135 )
136)]
137pub async fn get_voice_layout(
138 State(service): State<ChatService>,
139 session: Session,
140 Path(room_id): Path<Uuid>,
141) -> Result<AppResponseData<VoiceLayoutDto>, ApiError> {
142 let uid = require_uid(&session)?;
143 Ok(AppResponseData::ok(service.voice_layout(uid, room_id).await?))
144}
145
146#[utoipa::path(
147 put,
148 path = "/chat/rooms/{room_id}/voice/layout",
149 tag = "chat",
150 description = "保存参与者窗口布局并广播给房间内所有人,越界坐标会被夹回画布内。",
151 params(("room_id" = Uuid, Path, description = "room id")),
152 request_body = VoiceLayoutDto,
153 responses(
154 (status = 200, description = "layout saved", body = AppResponseData<NoData>),
155 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
156 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
157 (status = 403, description = "operation not allowed", body = AppResponseData<NoData>)
158 )
159)]
160pub async fn save_voice_layout(
161 State(service): State<ChatService>,
162 session: Session,
163 Path(room_id): Path<Uuid>,
164 Json(layout): Json<VoiceLayoutDto>,
165) -> Result<AppResponseData<NoData>, ApiError> {
166 let uid = require_uid(&session)?;
167 service.save_voice_layout(uid, room_id, layout).await?;
168 Ok(AppResponseData::empty())
169}
170
171#[utoipa::path(
125172 post,
126173 path = "/chat/rooms/{room_id}/voice/hands",
127174 tag = "chat",
Modified lib/chat/src/services/messages.rs View lib/chat/src/services/messages.rs
@@ -346,6 +346,12 @@
346346
347347 let parsed = parse_mentions(self, &ctx, &cmd.content, caller).await?;
348348 let mention_uids = parsed.users.clone();
349 // Resolve every mentioned recipient's language up front: the
350 // notification rows are written inside the transaction and each one has
351 // to carry the recipient's own copy.
352 let fallback_locale = i18n::config_fallback(self.config().email.default_locale());
353 let mention_locales = i18n::resolve_user_locales(self.db().read(), &mention_uids, fallback_locale)
354 .await;
349355
350356 if !cmd.attachments.is_empty() {
351357 let owned = attachment_rows::Entity::find()
@@ -400,6 +406,7 @@
400406 let room = ctx.room.clone();
401407 let thread_clone = thread.clone();
402408 let mention_uids_for_txn = mention_uids.clone();
409 let mention_locales_for_txn = mention_locales.clone();
403410 let content_for_email = content.clone();
404411 let copy_ids_for_txn = copy_ids.clone();
405412 let svc = self.clone();
@@ -519,12 +526,16 @@
519526 {
520527 continue;
521528 }
529 let locale = mention_locales_for_txn
530 .get(&uid)
531 .copied()
532 .unwrap_or(fallback_locale);
522533 notify_user(
523534 txn,
524535 &pending_txn,
525536 uid,
526537 NotificationType::Mention,
527 "You were mentioned in a message".to_string(),
538 i18n::catalog::notification_mention_message_title(locale).to_string(),
528539 Some(body.clone()),
529540 Some(data.clone()),
530541 Some(caller),
@@ -631,11 +642,18 @@
631642 let Some(addr) = emails.remove(&uid) else {
632643 continue;
633644 };
634 let subject = format!("You were mentioned in #{room_name}");
645 // One recipient per iteration, so the mail follows that
646 // person's own language rather than the sender's.
647 let locale = email_svc.recipient_locale(uid).await;
648 let subject = i18n::catalog::mention_email_subject(locale, &room_name);
635649 let link = format!("{base_url}/chat/rooms/{room_id}");
636 let text = format!("{subject}\n\n{email_body}\n\nView channel: {link}");
650 let text = format!(
651 "{subject}\n\n{email_body}\n\n{}: {link}",
652 i18n::catalog::view_channel(locale)
653 );
637654 let html = format!(
638 r#"<p>{subject}</p><p>{email_body}</p><p><a href="{link}">View channel</a></p>"#
655 r#"<p>{subject}</p><p>{email_body}</p><p><a href="{link}">{}</a></p>"#,
656 i18n::catalog::view_channel(locale)
639657 );
640658 if let Err(err) = email_svc.send_email(&addr, &subject, &text, &html).await {
641659 tracing::warn!(error = %err, "failed to send mention email");
@@ -772,6 +790,9 @@
772790 let now = OffsetDateTime::now_utc();
773791
774792 let parsed = parse_mentions(self, &ctx, &content, caller).await?;
793 let fallback_locale = i18n::config_fallback(self.config().email.default_locale());
794 let new_mention_locales =
795 i18n::resolve_user_locales(self.db().read(), &parsed.users, fallback_locale).await;
775796 let mention_everyone = if parsed.everyone {
776797 match &ctx.space {
777798 Some(space) => can_manage_space(self, space, caller).await?,
@@ -850,7 +871,13 @@
850871 &pending_txn,
851872 uid,
852873 NotificationType::Mention,
853 "You were mentioned in a message".to_string(),
874 i18n::catalog::notification_mention_message_title(
875 new_mention_locales
876 .get(&uid)
877 .copied()
878 .unwrap_or(fallback_locale),
879 )
880 .to_string(),
854881 Some(body.clone()),
855882 Some(data.clone()),
856883 Some(caller),
Modified lib/chat/src/services/reminders.rs View lib/chat/src/services/reminders.rs
@@ -230,7 +230,7 @@
230230 &pending,
231231 reminder.user_id,
232232 NotificationType::System,
233 "Message reminder".to_string(),
233 i18n::catalog::notification_reminder_title(self.recipient_locale(reminder.user_id).await).to_string(),
234234 None,
235235 Some(data),
236236 None,
Modified lib/chat/src/services/rooms.rs View lib/chat/src/services/rooms.rs
@@ -805,7 +805,7 @@
805805 &pending,
806806 invitee,
807807 NotificationType::Invite,
808 "You were invited to a channel".to_string(),
808 i18n::catalog::notification_channel_invite_title(self.recipient_locale(invitee).await).to_string(),
809809 None,
810810 Some(data),
811811 Some(caller),
Modified lib/chat/src/services/service.rs View lib/chat/src/services/service.rs
@@ -61,6 +61,16 @@
6161 Ok(map)
6262 }
6363
64 /// Language for one recipient's notification/e-mail text.
65 ///
66 /// Account preference only: a fan-out event has no single ambient request
67 /// language, and writing one language for every recipient is the bug this
68 /// replaces.
69 pub(crate) async fn recipient_locale(&self, uid: Uuid) -> i18n::UiLocale {
70 let fallback = i18n::config_fallback(self.config().email.default_locale());
71 i18n::resolve_user_locale(self.db().read(), uid, fallback).await
72 }
73
6474 pub(crate) async fn primary_emails(
6575 &self,
6676 uids: &[Uuid],
Modified lib/chat/src/services/voice.rs View lib/chat/src/services/voice.rs
@@ -42,6 +42,25 @@
4242 pub requested_at: i64,
4343}
4444
45/// 一个参与者窗口在画布里的摆位,坐标按画布宽高归一化(0~1),
46/// 这样不同分辨率、不同窗口大小的成员看到的是同一套布局。
47#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
48pub struct VoiceTileDto {
49 pub x: f64,
50 pub y: f64,
51 pub w: f64,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
55pub struct VoiceLayoutDto {
56 /// key 为参与者用户 id。
57 #[serde(default)]
58 pub tiles: HashMap<String, VoiceTileDto>,
59}
60
61/// 房间成员共享的窗口数量上限,避免布局被当成存储滥用。
62const MAX_LAYOUT_TILES: usize = 64;
63
4564#[derive(Debug, Clone, Serialize, ToSchema)]
4665pub struct VoiceConfigDto {
4766 pub enabled: bool,
@@ -134,6 +153,10 @@
134153 format!("chat:voice:{room_id}:hands")
135154}
136155
156pub fn layout_key(room_id: Uuid) -> String {
157 format!("chat:voice:{room_id}:layout")
158}
159
137160fn now_secs() -> i64 {
138161 OffsetDateTime::now_utc().unix_timestamp()
139162}
@@ -657,6 +680,83 @@
657680 Ok(VoiceSpaceStatesDto { rooms: out })
658681 }
659682
683 /// 读取房间共享的窗口布局;没有存过就是空布局(前端回落到自动网格)。
684 pub async fn voice_layout(&self, caller: Uuid, room_id: Uuid) -> Result<VoiceLayoutDto, ChatError> {
685 require_voice_room(self, room_id, caller).await?;
686 let mut conn = self.state.redis.connection_clone();
687 use export::RedisConnection;
688 let key = layout_key(room_id);
689 let raw: Result<Option<String>, redis::RedisError> = match &mut conn {
690 RedisConnection::Standalone(c) => redis::cmd("GET").arg(&key).query_async(c).await,
691 RedisConnection::Cluster(c) => redis::cmd("GET").arg(&key).query_async(c).await,
692 };
693 let raw = raw.map_err(ChatError::from)?;
694 let Some(raw) = raw else {
695 return Ok(VoiceLayoutDto::default());
696 };
697 Ok(serde_json::from_str(&raw).unwrap_or_default())
698 }
699
700 /// 保存布局并广播给房间里的所有人。坐标一律夹到 0~1,越界写入不可能发生。
701 pub async fn save_voice_layout(
702 &self,
703 caller: Uuid,
704 room_id: Uuid,
705 layout: VoiceLayoutDto,
706 ) -> Result<(), ChatError> {
707 require_voice_room(self, room_id, caller).await?;
708 if layout.tiles.len() > MAX_LAYOUT_TILES {
709 return Err(ChatError::BadRequest(format!(
710 "at most {MAX_LAYOUT_TILES} windows can be placed"
711 )));
712 }
713 let tiles = layout
714 .tiles
715 .into_iter()
716 .map(|(id, tile)| {
717 (
718 id,
719 VoiceTileDto {
720 x: tile.x.clamp(0.0, 1.0),
721 y: tile.y.clamp(0.0, 1.0),
722 w: tile.w.clamp(0.05, 1.0),
723 },
724 )
725 })
726 .collect();
727 let value = serde_json::to_string(&VoiceLayoutDto { tiles })?;
728 let ttl = self.config().livekit.presence_ttl_secs.max(60);
729 let mut conn = self.state.redis.connection_clone();
730 use export::RedisConnection;
731 let key = layout_key(room_id);
732 let written: Result<(), redis::RedisError> = match &mut conn {
733 RedisConnection::Standalone(c) => {
734 let set: Result<(), redis::RedisError> = redis::cmd("SET")
735 .arg(&key)
736 .arg(&value)
737 .arg("EX")
738 .arg(ttl)
739 .query_async(c)
740 .await;
741 set
742 }
743 RedisConnection::Cluster(c) => {
744 redis::cmd("SET")
745 .arg(&key)
746 .arg(&value)
747 .arg("EX")
748 .arg(ttl)
749 .query_async(c)
750 .await
751 }
752 };
753 written.map_err(ChatError::from)?;
754 self.realtime
755 .publish_room(&RoomEvent::VoiceLayout { room_id })
756 .await;
757 Ok(())
758 }
759
660760 /// 举手。Stage 频道之外、已有发言权都不允许。
661761 pub async fn raise_hand(&self, caller: Uuid, room_id: Uuid) -> Result<HandDto, ChatError> {
662762 let room = require_voice_room(self, room_id, caller).await?;
Modified lib/chat/src/share/notify.rs View lib/chat/src/share/notify.rs
@@ -76,6 +76,8 @@
7676 data: Option<serde_json::Value>,
7777 actor_uid: Option<Uuid>,
7878) -> Result<(), ChatError> {
79 // The row stores already-rendered text: there is no schema column for the
80 // language, so callers resolve it per recipient and pass finished copy in.
7981 let row = user_notifications::ActiveModel {
8082 id: Set(Uuid::now_v7()),
8183 user_id: Set(recipient),
@@ -227,7 +229,8 @@
227229 &pending,
228230 uid,
229231 NotificationType::Mention,
230 "You were mentioned in a message".to_string(),
232 i18n::catalog::notification_mention_message_title(service.recipient_locale(uid).await)
233 .to_string(),
231234 Some(body.clone()),
232235 Some(data.clone()),
233236 Some(actor),
Modified lib/chat/src/share/realtime.rs View lib/chat/src/share/realtime.rs
@@ -79,6 +79,10 @@
7979 VoiceHands {
8080 room_id: Uuid,
8181 },
82 /// 参与者窗口布局变化(拖动/缩放,房间内所有人共享)。
83 VoiceLayout {
84 room_id: Uuid,
85 },
8286 /// 主持人动作(静音、解除静音、移出、授权)。
8387 VoiceModeration {
8488 room_id: Uuid,
@@ -103,6 +107,7 @@
103107 | Self::ThreadUpdated { room_id, .. }
104108 | Self::VoicePresence { room_id }
105109 | Self::VoiceHands { room_id }
110 | Self::VoiceLayout { room_id }
106111 | Self::VoiceModeration { room_id, .. } => *room_id,
107112 }
108113 }
Modified lib/chat/src/openapi.rs View lib/chat/src/openapi.rs
@@ -92,6 +92,8 @@
9292 crate::handlers::voice::voice_token,
9393 crate::handlers::voice::voice_participants,
9494 crate::handlers::voice::voice_space_states,
95 crate::handlers::voice::get_voice_layout,
96 crate::handlers::voice::save_voice_layout,
9597 crate::handlers::voice::list_hands,
9698 crate::handlers::voice::raise_hand,
9799 crate::handlers::voice::cancel_hand,
@@ -190,6 +192,8 @@
190192 crate::services::voice::VoiceHandsDto,
191193 crate::services::voice::VoiceModerationDto,
192194 crate::services::voice::VoiceRecordingDto,
195 crate::services::voice::VoiceLayoutDto,
196 crate::services::voice::VoiceTileDto,
193197 crate::services::voice::HandDto,
194198 crate::services::voice::HandState,
195199 crate::services::search::SearchHitDto,
Modified lib/chat/src/routes.rs View lib/chat/src/routes.rs
@@ -32,7 +32,8 @@
3232};
3333use crate::handlers::voice::{
3434 approve_hand, cancel_hand, deny_hand, kick_participant, list_hands, livekit_webhook,
35 moderate_mute, raise_hand, revoke_hand, start_recording, stop_recording, voice_config,
35 get_voice_layout, moderate_mute, raise_hand, revoke_hand, save_voice_layout, start_recording,
36 stop_recording, voice_config,
3637 voice_participants, voice_space_states, voice_token,
3738};
3839use crate::handlers::webhooks::{create_webhook, delete_webhook, list_webhooks, post_via_webhook};
@@ -262,6 +263,10 @@
262263 get(voice_space_states),
263264 )
264265 .route(
266 "/chat/rooms/{room_id}/voice/layout",
267 get(get_voice_layout).put(save_voice_layout),
268 )
269 .route(
265270 "/chat/rooms/{room_id}/voice/hands",
266271 get(list_hands).post(raise_hand),
267272 )
Modified lib/chat/Cargo.toml View lib/chat/Cargo.toml
@@ -7,6 +7,7 @@
77async-trait = { workspace = true }
88config = { workspace = true }
99export = { workspace = true }
10i18n = { workspace = true }
1011models = { workspace = true }
1112session = { workspace = true }
1213space = { workspace = true }
Modified lib/email/src/templates/email_change_confirm.rs View lib/email/src/templates/email_change_confirm.rs
@@ -1,5 +1,6 @@
11use maud::Markup;
22use maud::html;
3use i18n::{UiLocale, catalog};
34
45use super::{append_query, mail_shell, url_join};
56
@@ -8,6 +9,7 @@
89 ticket_id: &str,
910 code: &str,
1011 base_url: &str,
12 locale: UiLocale,
1113) -> Markup {
1214 let confirm_link = append_query(
1315 &url_join(base_url, "/email/change/confirm"),
@@ -18,30 +20,27 @@
1820 &[("ticket_id", ticket_id)],
1921 );
2022 let body = html! {
21 h1 style="font-size:24px;font-weight:700;margin:0 0 8px;" { "Confirm your new email" }
23 h1 style="font-size:24px;font-weight:700;margin:0 0 8px;" { (catalog::email_change_heading(locale)) }
2224 p style="color:#374151;margin:0 0 24px;" {
23 "You requested to change your email to "
24 strong { (new_email) }
25 ". Click the button below to confirm."
25 (catalog::email_change_body(locale, new_email))
2626 }
2727 table role="presentation" cellpadding="0" cellspacing="0" {
2828 tr {
2929 td align="center" {
30 a href=(confirm_link) class="btn" { "Confirm email change" }
30 a href=(confirm_link) class="btn" { (catalog::email_change_button(locale)) }
3131 }
3232 }
3333 }
3434 p class="muted" style="margin:16px 0 0;" {
35 "Or enter this code: "
35 (catalog::email_change_or_code(locale))
3636 strong style="color:#111827;" { (code) }
3737 }
3838 p class="muted" style="margin:16px 0 0;" {
39 "Didn't request this? "
40 a href=(cancel_link) style="color:#2563eb;" { "Cancel the change" }
41 "."
39 (catalog::email_change_not_requested(locale))
40 a href=(cancel_link) style="color:#2563eb;" { (catalog::email_change_cancel(locale)) }
4241 }
4342 };
44 mail_shell("Confirm your new email", body)
43 mail_shell(catalog::email_change_subject(locale), body, locale)
4544}
4645
4746pub fn render(vars: &serde_json::Value) -> Markup {
@@ -49,5 +48,10 @@
4948 let ticket_id = vars.get("ticket_id").and_then(|v| v.as_str()).unwrap_or("");
5049 let code = vars.get("code").and_then(|v| v.as_str()).unwrap_or("");
5150 let base_url = vars.get("base_url").and_then(|v| v.as_str()).unwrap_or("");
52 mail_email_change_confirm(new_email, ticket_id, code, base_url)
51 let locale = vars
52 .get("locale")
53 .and_then(|v| v.as_str())
54 .and_then(UiLocale::parse)
55 .unwrap_or(UiLocale::En);
56 mail_email_change_confirm(new_email, ticket_id, code, base_url, locale)
5357}
Modified lib/email/src/templates/mod.rs View lib/email/src/templates/mod.rs
@@ -8,10 +8,10 @@
88pub use reset_link::mail_reset_link;
99pub use verify_code::mail_verify_code;
1010
11pub(crate) fn mail_shell(title: &str, body: Markup) -> Markup {
11pub(crate) fn mail_shell(title: &str, body: Markup, locale: i18n::UiLocale) -> Markup {
1212 html! {
1313 (DOCTYPE)
14 html lang="en" xmlns="http://www.w3.org/1999/xhtml" {
14 html lang=(locale.tag()) xmlns="http://www.w3.org/1999/xhtml" {
1515 head {
1616 meta charset="utf-8";
1717 meta name="viewport" content="width=device-width, initial-scale=1";
Modified lib/email/src/templates/reset_link.rs View lib/email/src/templates/reset_link.rs
@@ -1,34 +1,35 @@
11use maud::Markup;
22use maud::html;
3use i18n::{UiLocale, catalog};
34
45use super::{append_query, mail_shell, url_join};
56
6pub fn mail_reset_link(email: &str, token: &str, temp_id: &str, base_url: &str) -> Markup {
7pub fn mail_reset_link(
8 email: &str,
9 token: &str,
10 temp_id: &str,
11 base_url: &str,
12 locale: UiLocale,
13) -> Markup {
714 let link = append_query(
815 &url_join(base_url, "/auth/reset-password"),
916 &[("email", email), ("token", token), ("temp_id", temp_id)],
1017 );
1118 let body = html! {
12 h1 style="font-size:24px;font-weight:700;margin:0 0 8px;" { "Reset your password" }
13 p style="color:#374151;margin:0 0 24px;" {
14 "Click the button below to reset your password. This link expires in 30 minutes."
15 }
19 h1 style="font-size:24px;font-weight:700;margin:0 0 8px;" { (catalog::reset_heading(locale)) }
20 p style="color:#374151;margin:0 0 24px;" { (catalog::reset_body(locale)) }
1621 table role="presentation" cellpadding="0" cellspacing="0" {
1722 tr {
1823 td align="center" {
19 a href=(link) class="btn" { "Reset password" }
24 a href=(link) class="btn" { (catalog::reset_button(locale)) }
2025 }
2126 }
22 }
23 p class="muted" style="margin:24px 0 0;" {
24 "If the button doesn't work, copy this link into your browser:"
2527 }
28 p class="muted" style="margin:24px 0 0;" { (catalog::link_fallback(locale)) }
2629 p style="word-break:break-all;font-size:13px;color:#2563eb;margin:4px 0 0;" { (link) }
27 p class="muted" style="margin:24px 0 0;" {
28 "If you didn't request a password reset, ignore this email."
29 }
30 p class="muted" style="margin:24px 0 0;" { (catalog::reset_ignore(locale)) }
3031 };
31 mail_shell("Reset your password", body)
32 mail_shell(catalog::reset_subject(locale), body, locale)
3233}
3334
3435pub fn render(vars: &serde_json::Value) -> Markup {
@@ -36,5 +37,10 @@
3637 let token = vars.get("token").and_then(|v| v.as_str()).unwrap_or("");
3738 let temp_id = vars.get("temp_id").and_then(|v| v.as_str()).unwrap_or("");
3839 let base_url = vars.get("base_url").and_then(|v| v.as_str()).unwrap_or("");
39 mail_reset_link(email, token, temp_id, base_url)
40 let locale = vars
41 .get("locale")
42 .and_then(|v| v.as_str())
43 .and_then(UiLocale::parse)
44 .unwrap_or(UiLocale::En);
45 mail_reset_link(email, token, temp_id, base_url, locale)
4046}
Modified lib/email/src/templates/verify_code.rs View lib/email/src/templates/verify_code.rs
@@ -1,31 +1,34 @@
11use maud::Markup;
22use maud::html;
3use i18n::{UiLocale, catalog};
34
45use super::{append_query, mail_shell, url_join};
56
6pub fn mail_verify_code(code: &str, minutes: u64, purpose: &str, link: Option<&str>) -> Markup {
7pub fn mail_verify_code(
8 code: &str,
9 minutes: u64,
10 purpose: &str,
11 link: Option<&str>,
12 locale: UiLocale,
13) -> Markup {
714 let body = html! {
815 h1 style="font-size:24px;font-weight:700;margin:0 0 8px;" { (purpose) }
9 p style="color:#374151;margin:0 0 8px;" {
10 "Enter the following code to continue. It expires in "
11 (minutes)
12 " minutes."
13 }
16 p style="color:#374151;margin:0 0 8px;" { (catalog::verify_body(locale, minutes)) }
1417 div class="code" { (code) }
1518 @if let Some(url) = link {
1619 p style="margin:24px 0 0;" {
17 a href=(url) class="btn" { "Verify email" }
20 a href=(url) class="btn" { (catalog::verify_verify_button(locale)) }
1821 }
1922 p class="muted" style="margin:16px 0 0;" {
20 "If the button doesn't work, copy this link into your browser:"
23 (catalog::link_fallback(locale))
2124 }
2225 p style="word-break:break-all;font-size:13px;color:#2563eb;margin:4px 0 0;" { (url) }
2326 }
2427 p class="muted" style="margin:16px 0 0;" {
25 "If you didn't request this, you can safely ignore this email."
28 (catalog::ignore_if_unrequested(locale))
2629 }
2730 };
28 mail_shell(purpose, body)
31 mail_shell(purpose, body, locale)
2932}
3033
3134pub fn render(vars: &serde_json::Value) -> Markup {
@@ -36,7 +39,14 @@
3639 .and_then(|v| v.as_str())
3740 .unwrap_or("Verification");
3841 let link = build_link(vars);
39 mail_verify_code(code, minutes, purpose, link.as_deref())
42 // Templates registered through `render_and_send` receive the already
43 // localized `purpose`; the locale only drives the surrounding chrome.
44 let locale = vars
45 .get("locale")
46 .and_then(|v| v.as_str())
47 .and_then(UiLocale::parse)
48 .unwrap_or(UiLocale::En);
49 mail_verify_code(code, minutes, purpose, link.as_deref(), locale)
4050}
4151
4252fn build_link(vars: &serde_json::Value) -> Option<String> {
Modified lib/email/src/service.rs View lib/email/src/service.rs
@@ -214,11 +214,22 @@
214214 recipients = recipient_count,
215215 "service::render_and_send dispatch"
216216 );
217 let html = self
218 .inner
219 .templates
220 .render(&req.template, &req.vars)
221 .await?;
217 // The locale travels inside the template vars: registered templates are
218 // `Fn(&Value) -> Markup`, and every one of them reads `locale` to pick
219 // its catalogue. Injecting here keeps the `render_and_send` contract
220 // (and the queue payload) unchanged for callers.
221 let mut vars = req.vars.clone();
222 if let Some(object) = vars.as_object_mut()
223 && !object.contains_key("locale")
224 {
225 let tag = req
226 .locale
227 .as_deref()
228 .and_then(i18n::UiLocale::parse)
229 .unwrap_or(i18n::UiLocale::En);
230 object.insert("locale".to_string(), serde_json::Value::String(tag.tag().to_string()));
231 }
232 let html = self.inner.templates.render(&req.template, &vars).await?;
222233 debug!(
223234 request_id = %req.request_id,
224235 template = %req.template,
@@ -238,7 +249,6 @@
238249 metadata: req.metadata,
239250 queued_at_ms: now_ms(),
240251 };
241 let _ = req.locale;
242252 self.send(send_req).await
243253 }
244254
Modified lib/email/Cargo.toml View lib/email/Cargo.toml
@@ -5,6 +5,7 @@
55
66[dependencies]
77config = { workspace = true }
8i18n = { workspace = true }
89lettre = { workspace = true }
910maud = { workspace = true }
1011redis = { workspace = true }
Added lib/i18n/src/catalog.rs View lib/i18n/src/catalog.rs
@@ -0,0 +1,447 @@
1//! Server-side message catalogue.
2//!
3//! Kept as plain `match` arms rather than a macro: the set is small, the
4//! compiler then guarantees every locale is handled, and there is no build step
5//! for translators to run. Every function takes the resolved [`UiLocale`], so
6//! callers never consult a process-global — one request can send mail to two
7//! recipients in two languages.
8
9use crate::locale::UiLocale;
10
11/// Look up a mail subject/heading pair. `purpose` selects the verification-mail
12/// wording, matching `EmailVerificationPurpose`'s variants.
13pub fn verify_subject(locale: UiLocale, purpose: VerifyPurpose) -> &'static str {
14 match (locale, purpose) {
15 (UiLocale::ZhCn, VerifyPurpose::Signup) => "验证你的账号",
16 (UiLocale::ZhCn, VerifyPurpose::PasswordReset) => "重置你的密码",
17 (UiLocale::ZhCn, VerifyPurpose::AddEmail) => "添加你的邮箱",
18 (UiLocale::ZhCn, VerifyPurpose::Reclaim) => "找回你的账号",
19 (UiLocale::En, VerifyPurpose::Signup) => "Verify your account",
20 (UiLocale::En, VerifyPurpose::PasswordReset) => "Reset your password",
21 (UiLocale::En, VerifyPurpose::AddEmail) => "Add your email",
22 (UiLocale::En, VerifyPurpose::Reclaim) => "Recover your account",
23 }
24}
25
26/// The plain-text body of a verification mail.
27pub fn verify_text(locale: UiLocale, code: &str) -> String {
28 match locale {
29 UiLocale::ZhCn => format!("你的验证码是:{code}"),
30 UiLocale::En => format!("Your verification code is: {code}"),
31 }
32}
33
34/// Verification mail: body copy around the code.
35pub fn verify_body(locale: UiLocale, minutes: u64) -> String {
36 match locale {
37 UiLocale::ZhCn => format!("输入下列验证码以继续,{minutes} 分钟内有效。"),
38 UiLocale::En => format!("Enter the following code to continue. It expires in {minutes} minutes."),
39 }
40}
41
42pub fn verify_verify_button(locale: UiLocale) -> &'static str {
43 match locale {
44 UiLocale::ZhCn => "验证邮箱",
45 UiLocale::En => "Verify email",
46 }
47}
48
49pub fn link_fallback(locale: UiLocale) -> &'static str {
50 match locale {
51 UiLocale::ZhCn => "如果按钮无法点击,请把下面的链接复制到浏览器打开:",
52 UiLocale::En => "If the button doesn't work, copy this link into your browser:",
53 }
54}
55
56pub fn ignore_if_unrequested(locale: UiLocale) -> &'static str {
57 match locale {
58 UiLocale::ZhCn => "如果这不是你本人的操作,可以忽略这封邮件。",
59 UiLocale::En => "If you didn't request this, you can safely ignore this email.",
60 }
61}
62
63/// Password-reset mail.
64pub fn reset_subject(locale: UiLocale) -> &'static str {
65 match locale {
66 UiLocale::ZhCn => "重置你的密码",
67 UiLocale::En => "Reset your password",
68 }
69}
70
71pub fn reset_heading(locale: UiLocale) -> &'static str {
72 match locale {
73 UiLocale::ZhCn => "重置密码",
74 UiLocale::En => "Reset your password",
75 }
76}
77
78pub fn reset_body(locale: UiLocale) -> &'static str {
79 match locale {
80 UiLocale::ZhCn => "点击下方按钮重置密码,链接 30 分钟内有效。",
81 UiLocale::En => "Click the button below to reset your password. This link expires in 30 minutes.",
82 }
83}
84
85pub fn reset_button(locale: UiLocale) -> &'static str {
86 match locale {
87 UiLocale::ZhCn => "重置密码",
88 UiLocale::En => "Reset password",
89 }
90}
91
92pub fn reset_text(locale: UiLocale, token: &str) -> String {
93 match locale {
94 UiLocale::ZhCn => format!("请在有效期内使用以下重置令牌:{token}"),
95 UiLocale::En => format!("Use this reset token before it expires: {token}"),
96 }
97}
98
99pub fn reset_ignore(locale: UiLocale) -> &'static str {
100 match locale {
101 UiLocale::ZhCn => "如果你没有申请重置密码,请忽略这封邮件。",
102 UiLocale::En => "If you didn't request a password reset, ignore this email.",
103 }
104}
105
106/// Email-change confirmation mail.
107pub fn email_change_subject(locale: UiLocale) -> &'static str {
108 match locale {
109 UiLocale::ZhCn => "确认你的新邮箱",
110 UiLocale::En => "Confirm your new email",
111 }
112}
113
114pub fn email_change_heading(locale: UiLocale) -> &'static str {
115 match locale {
116 UiLocale::ZhCn => "确认新邮箱",
117 UiLocale::En => "Confirm your new email",
118 }
119}
120
121pub fn email_change_body(locale: UiLocale, new_email: &str) -> String {
122 match locale {
123 UiLocale::ZhCn => format!("你申请把邮箱改为 {new_email},点击下方按钮完成确认。"),
124 UiLocale::En => format!("You requested to change your email to {new_email}. Click the button below to confirm."),
125 }
126}
127
128pub fn email_change_button(locale: UiLocale) -> &'static str {
129 match locale {
130 UiLocale::ZhCn => "确认更换邮箱",
131 UiLocale::En => "Confirm email change",
132 }
133}
134
135pub fn email_change_or_code(locale: UiLocale) -> &'static str {
136 match locale {
137 UiLocale::ZhCn => "或输入以下验证码:",
138 UiLocale::En => "Or enter this code:",
139 }
140}
141
142pub fn email_change_not_requested(locale: UiLocale) -> &'static str {
143 match locale {
144 UiLocale::ZhCn => "不是你本人操作?",
145 UiLocale::En => "Didn't request this?",
146 }
147}
148
149pub fn email_change_cancel(locale: UiLocale) -> &'static str {
150 match locale {
151 UiLocale::ZhCn => "撤销本次修改",
152 UiLocale::En => "Cancel the change",
153 }
154}
155
156pub fn email_change_text(locale: UiLocale, code: &str) -> String {
157 match locale {
158 UiLocale::ZhCn => format!("你的邮箱更换验证码是:{code}"),
159 UiLocale::En => format!("Your email change confirmation code is: {code}"),
160 }
161}
162
163/// Signup / add-email text mail.
164pub fn add_email_subject(locale: UiLocale) -> &'static str {
165 match locale {
166 UiLocale::ZhCn => "验证你的邮箱",
167 UiLocale::En => "Verify your email",
168 }
169}
170
171pub fn add_email_text(locale: UiLocale, code: &str) -> String {
172 match locale {
173 UiLocale::ZhCn => format!("你的邮箱验证码是:{code}"),
174 UiLocale::En => format!("Your email verification code is: {code}"),
175 }
176}
177
178/// Notification text.
179pub fn notification_mention_title(locale: UiLocale, room: &str) -> String {
180 match locale {
181 UiLocale::ZhCn => format!("你在 #{room} 中被提及"),
182 UiLocale::En => format!("You were mentioned in #{room}"),
183 }
184}
185
186pub fn notification_reply_title(locale: UiLocale) -> &'static str {
187 match locale {
188 UiLocale::ZhCn => "有人回复了你的消息",
189 UiLocale::En => "Someone replied to your message",
190 }
191}
192
193pub fn notification_invite_title(locale: UiLocale, space: &str) -> String {
194 match locale {
195 UiLocale::ZhCn => format!("你被邀请加入 {space}"),
196 UiLocale::En => format!("You were invited to join {space}"),
197 }
198}
199
200pub fn notification_issue_assigned_title(locale: UiLocale, number: i32) -> String {
201 match locale {
202 UiLocale::ZhCn => format!("工单 #{number} 已指派给你"),
203 UiLocale::En => format!("Issue #{number} was assigned to you"),
204 }
205}
206
207pub fn notification_issue_comment_title(locale: UiLocale, number: i32) -> String {
208 match locale {
209 UiLocale::ZhCn => format!("工单 #{number} 有新评论"),
210 UiLocale::En => format!("New comment on issue #{number}"),
211 }
212}
213
214/// Mail/notification subjects that mirror the issue and mention notices.
215pub fn mention_email_subject(locale: UiLocale, room: &str) -> String {
216 match locale {
217 UiLocale::ZhCn => format!("你在 #{room} 中被提及"),
218 UiLocale::En => format!("You were mentioned in #{room}"),
219 }
220}
221
222pub fn view_channel(locale: UiLocale) -> &'static str {
223 match locale {
224 UiLocale::ZhCn => "查看频道",
225 UiLocale::En => "View channel",
226 }
227}
228
229pub fn view_issue(locale: UiLocale) -> &'static str {
230 match locale {
231 UiLocale::ZhCn => "查看工单",
232 UiLocale::En => "View issue",
233 }
234}
235
236pub fn space_invite_subject(locale: UiLocale, space: &str) -> String {
237 match locale {
238 UiLocale::ZhCn => format!("邀请你加入 {space}"),
239 UiLocale::En => format!("You're invited to join {space}"),
240 }
241}
242
243/// HTML result pages served directly by the backend.
244pub fn email_verified_title(locale: UiLocale) -> &'static str {
245 match locale {
246 UiLocale::ZhCn => "邮箱已验证",
247 UiLocale::En => "Email verified",
248 }
249}
250
251pub fn email_verified_body(locale: UiLocale) -> &'static str {
252 match locale {
253 UiLocale::ZhCn => "你的邮箱已验证,可以继续操作了。",
254 UiLocale::En => "Your email has been verified. You can now continue.",
255 }
256}
257
258pub fn verification_failed_title(locale: UiLocale) -> &'static str {
259 match locale {
260 UiLocale::ZhCn => "验证失败",
261 UiLocale::En => "Verification failed",
262 }
263}
264
265pub fn verification_failed_body(locale: UiLocale) -> &'static str {
266 match locale {
267 UiLocale::ZhCn => "验证链接无效或已过期,请重新发起验证。",
268 UiLocale::En => "The verification link is invalid or expired. Please start again.",
269 }
270}
271
272pub fn agreement_version(locale: UiLocale, version: &str) -> String {
273 match locale {
274 UiLocale::ZhCn => format!("版本 {version}"),
275 UiLocale::En => format!("Version {version}"),
276 }
277}
278
279pub fn agreement_footer(locale: UiLocale) -> &'static str {
280 match locale {
281 UiLocale::ZhCn => "继续使用即表示你同意本文档。",
282 UiLocale::En => "By continuing you agree to this document.",
283 }
284}
285
286pub fn agreement_not_found_title(locale: UiLocale) -> &'static str {
287 match locale {
288 UiLocale::ZhCn => "未找到",
289 UiLocale::En => "Not found",
290 }
291}
292
293pub fn agreement_not_found_body(locale: UiLocale) -> &'static str {
294 match locale {
295 UiLocale::ZhCn => "请求的协议文档不存在。",
296 UiLocale::En => "The requested agreement document does not exist.",
297 }
298}
299
300/// Language of a verification-code mail, derived from the purpose enum so the
301/// mail crate stays independent of the auth crate's types.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum VerifyPurpose {
304 Signup,
305 PasswordReset,
306 AddEmail,
307 Reclaim,
308}
309
310/// AI review notification phrasing.
311///
312/// Unlike the rest of this crate these keep the full nine-language set that
313/// `agent::notifications` already shipped, because the AI output language is a
314/// per-task setting and the notification should follow it. Callers pass the
315/// raw `users.language`/task language; unknown values fall back to English.
316pub fn ai_review_title(language: &str, number: i32) -> String {
317 match language_tag(language).as_str() {
318 "zh" => format!("AI 评审完成 · #{number}"),
319 "ja" => format!("AI レビュー完了 · #{number}"),
320 "ko" => format!("AI 리뷰 완료 · #{number}"),
321 "fr" => format!("Revue IA terminée · #{number}"),
322 "de" => format!("KI-Review abgeschlossen · #{number}"),
323 "es" => format!("Revisión de IA completada · #{number}"),
324 "pt" => format!("Revisão de IA concluída · #{number}"),
325 "it" => format!("Revisione IA completata · #{number}"),
326 "ru" => format!("ИИ-ревью завершено · #{number}"),
327 _ => format!("AI review completed · #{number}"),
328 }
329}
330
331pub fn ai_review_body(language: &str, findings: usize, summary: &str) -> String {
332 let summary = summary.trim();
333 if findings == 0 {
334 return summary.to_string();
335 }
336 match language_tag(language).as_str() {
337 "zh" => format!("{findings} 条行级建议:{summary}"),
338 "ja" => format!("行コメント {findings} 件:{summary}"),
339 "ko" => format!("인라인 제안 {findings}건: {summary}"),
340 "fr" => format!("{findings} suggestion(s) en ligne : {summary}"),
341 "de" => format!("{findings} Inline-Hinweis(e): {summary}"),
342 "es" => format!("{findings} sugerencia(s) en línea: {summary}"),
343 "pt" => format!("{findings} sugestão(ões) em linha: {summary}"),
344 "it" => format!("{findings} suggerimento/i in linea: {summary}"),
345 "ru" => format!("{findings} замечаний к строкам: {summary}"),
346 _ => format!("{findings} inline suggestion(s): {summary}"),
347 }
348}
349
350/// Fold a BCP-47 tag onto its primary language subtag.
351fn language_tag(language: &str) -> String {
352 let normalized = ci_protocol::ai_languages::normalize(language);
353 normalized.split('-').next().unwrap_or("en").to_string()
354}
355
356pub fn notification_reminder_title(locale: UiLocale) -> &'static str {
357 match locale {
358 UiLocale::ZhCn => "消息提醒",
359 UiLocale::En => "Message reminder",
360 }
361}
362
363pub fn notification_channel_invite_title(locale: UiLocale) -> &'static str {
364 match locale {
365 UiLocale::ZhCn => "你被邀请加入一个频道",
366 UiLocale::En => "You were invited to a channel",
367 }
368}
369
370pub fn notification_mention_message_title(locale: UiLocale) -> &'static str {
371 match locale {
372 UiLocale::ZhCn => "消息中提到了你",
373 UiLocale::En => "You were mentioned in a message",
374 }
375}
376
377/// Resolve the fallback locale from configuration in one place.
378pub fn config_fallback(default_locale: &str) -> UiLocale {
379 UiLocale::parse(default_locale).unwrap_or(UiLocale::En)
380}
381
382/// Issue-related mail subjects. The issue number and title are caller data, so
383/// these compose rather than look up a static string.
384pub fn issue_mention_subject(locale: UiLocale, number: i32, title: &str) -> String {
385 match locale {
386 UiLocale::ZhCn => format!("你在工单 #{number} 中被提及:{title}"),
387 UiLocale::En => format!("You were mentioned in issue #{number}: {title}"),
388 }
389}
390
391pub fn issue_reply_subject(locale: UiLocale, number: i32, title: &str) -> String {
392 match locale {
393 UiLocale::ZhCn => format!("工单 #{number} 有新回复:{title}"),
394 UiLocale::En => format!("New reply on issue #{number}: {title}"),
395 }
396}
397
398pub fn issue_assigned_subject(locale: UiLocale, number: i32, title: &str) -> String {
399 match locale {
400 UiLocale::ZhCn => format!("工单 #{number} 指派给你:{title}"),
401 UiLocale::En => format!("You were assigned to issue #{number}: {title}"),
402 }
403}
404
405/// Notification wording for the same three issue events, without the title
406/// (the inbox already renders it from `data`).
407pub fn notification_issue_mention_title(locale: UiLocale, number: i32) -> String {
408 match locale {
409 UiLocale::ZhCn => format!("你在工单 #{number} 中被提及"),
410 UiLocale::En => format!("You were mentioned in issue #{number}"),
411 }
412}
413
414/// Space invite mail.
415pub fn space_invite_body(locale: UiLocale, space: &str) -> String {
416 match locale {
417 UiLocale::ZhCn => format!("你被邀请加入 {space}。"),
418 UiLocale::En => format!("You have been invited to join {space}."),
419 }
420}
421
422pub fn space_invite_accept(locale: UiLocale) -> &'static str {
423 match locale {
424 UiLocale::ZhCn => "接受邀请",
425 UiLocale::En => "Accept invite",
426 }
427}
428
429pub fn space_invite_text(locale: UiLocale, space: &str, url: &str) -> String {
430 match locale {
431 UiLocale::ZhCn => format!(
432 "你被邀请加入 {space}\n\n{}:{url}\n",
433 space_invite_accept(locale)
434 ),
435 UiLocale::En => format!(
436 "You have been invited to join {space}\n\n{}: {url}\n",
437 space_invite_accept(locale)
438 ),
439 }
440}
441
442pub fn space_invite_or_copy(locale: UiLocale, url: &str) -> String {
443 match locale {
444 UiLocale::ZhCn => format!("或复制此链接:{url}"),
445 UiLocale::En => format!("Or copy this link: {url}"),
446 }
447}
No newline at end of file
Added lib/i18n/src/lib.rs View lib/i18n/src/lib.rs
@@ -0,0 +1,29 @@
1//! Server-side message catalogue for everything the backend renders itself:
2//! transactional e-mail, in-app notification text and the HTML result pages.
3//!
4//! The interface language is a per-recipient property, so this crate never
5//! holds "the" locale — it resolves one per message from the recipient's
6//! `users.language` and falls back to the request's `Accept-Language`, then to
7//! `[email].default_locale`.
8//!
9//! Scope is deliberately narrow: two locales (`zh-CN`, `en`) covering the
10//! strings the platform actually emits, plus the nine-locale AI review phrasing
11//! preserved from `agent::notifications`. Anything written by a user or a model
12//! is never translated here.
13
14pub mod catalog;
15pub mod locale;
16
17pub use catalog::*;
18pub use locale::{
19 UiLocale, negotiate_accept_language, resolve_user_locale, resolve_user_locales,
20};
21
22/// Normalize a stored user preference to a locale this build can render.
23///
24/// `users.language` doubles as the AI output-language preference, so values
25/// such as `ja` or `auto` are normal: they are not errors, they simply do not
26/// select a UI catalogue and therefore lose their turn in the chain.
27pub fn from_user_preference(value: Option<&str>) -> Option<UiLocale> {
28 UiLocale::parse(value?)
29}
Added lib/i18n/src/locale.rs View lib/i18n/src/locale.rs
@@ -0,0 +1,154 @@
1use std::str::FromStr;
2
3use sea_orm::{ConnectionTrait, EntityTrait};
4use uuid::Uuid;
5
6/// A locale with a shipped server-side catalogue.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum UiLocale {
9 ZhCn,
10 En,
11}
12
13impl UiLocale {
14 /// BCP-47 tag, as written into `lang=""` and into the mail headers.
15 pub fn tag(self) -> &'static str {
16 match self {
17 Self::ZhCn => "zh-CN",
18 Self::En => "en",
19 }
20 }
21
22 /// Parse one tag, tolerating POSIX spellings (`zh_CN`) and script/region
23 /// variants (`zh-Hans-CN`, `en-US`).
24 ///
25 /// `zh-Hant`/`zh-TW`/`zh-HK`/`zh-MO` deliberately return `None`: serving
26 /// Simplified copy to a Traditional reader is worse than falling back.
27 pub fn parse(raw: &str) -> Option<Self> {
28 let tag = raw.trim().to_ascii_lowercase().replace('_', "-");
29 if tag.is_empty() {
30 return None;
31 }
32 if tag == "zh" || tag.starts_with("zh-") {
33 let rest = &tag[2..];
34 if rest.contains("hant") || ["-tw", "-hk", "-mo"].iter().any(|s| rest.contains(s)) {
35 return None;
36 }
37 return Some(Self::ZhCn);
38 }
39 if tag == "en" || tag.starts_with("en-") {
40 return Some(Self::En);
41 }
42 None
43 }
44
45 /// Locale tag for HTTP headers that want an underscore-free form.
46 pub fn header(self) -> &'static str {
47 self.tag()
48 }
49}
50
51impl FromStr for UiLocale {
52 type Err = ();
53 fn from_str(s: &str) -> Result<Self, Self::Err> {
54 Self::parse(s).ok_or(())
55 }
56}
57
58/// Pick a locale from an `Accept-Language` header value.
59///
60/// Honours q-values (including `q=0` as an explicit refusal) and walks the
61/// client's preference order, so `zh-CN,zh;q=0.9,en;q=0.8` resolves to zh-CN
62/// while `en-GB,en;q=0.9` resolves to en.
63pub fn negotiate_accept_language(header: Option<&str>, fallback: UiLocale) -> UiLocale {
64 let Some(header) = header else {
65 return fallback;
66 };
67 let mut candidates: Vec<(f32, usize, UiLocale)> = Vec::new();
68 for (index, entry) in header.split(',').enumerate() {
69 let entry = entry.trim();
70 if entry.is_empty() {
71 continue;
72 }
73 let (tag, q) = match entry.split_once(';') {
74 Some((tag, params)) => {
75 let q = params
76 .trim()
77 .strip_prefix("q=")
78 .and_then(|value| value.trim().parse::<f32>().ok())
79 .unwrap_or(1.0);
80 (tag.trim(), q)
81 }
82 None => (entry, 1.0),
83 };
84 if q <= 0.0 {
85 continue;
86 }
87 if let Some(locale) = UiLocale::parse(tag) {
88 candidates.push((q, index, locale));
89 }
90 }
91 // Stable by (descending q, ascending header position); header order wins ties.
92 candidates.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
93 candidates.first().map(|(_, _, locale)| *locale).unwrap_or(fallback)
94}
95
96/// Resolve the language for a specific message recipient.
97///
98/// Preference order is fixed and intentional: an account-level choice always
99/// beats the ambient request, because one request can produce several mails to
100/// recipients who read different languages.
101pub fn resolve_locale(
102 user_language: Option<&str>,
103 user_locale: Option<&str>,
104 accept_language: Option<&str>,
105 fallback: UiLocale,
106) -> UiLocale {
107 UiLocale::parse(user_language.filter(|v| !v.trim().is_empty()).unwrap_or(""))
108 .or_else(|| UiLocale::parse(user_locale.filter(|v| !v.trim().is_empty()).unwrap_or("")))
109 .unwrap_or_else(|| negotiate_accept_language(accept_language, fallback))
110}
111
112/// Batch-read a single user's rendered-locale preference straight from the DB.
113///
114/// Returns `fallback` when the user has no usable preference, so callers can
115/// pass the result directly to a template.
116pub async fn resolve_user_locale<C: ConnectionTrait>(
117 db: &C,
118 uid: Uuid,
119 fallback: UiLocale,
120) -> UiLocale {
121 use models::users::user;
122 let row = user::Entity::find_by_id(uid).one(db).await.ok().flatten();
123 match row {
124 Some(row) => resolve_locale(row.language.as_deref(), row.locale.as_deref(), None, fallback),
125 None => fallback,
126 }
127}
128
129/// Batch variant: one query for many recipients, keyed by user id.
130///
131/// Callers that fan a single event out to a whole room or issue watcher list
132/// must not issue one query per recipient.
133pub async fn resolve_user_locales<C: ConnectionTrait>(
134 db: &C,
135 uids: &[Uuid],
136 fallback: UiLocale,
137) -> std::collections::HashMap<Uuid, UiLocale> {
138 use models::users::user;
139 use sea_orm::{ColumnTrait, QueryFilter};
140 let mut out = std::collections::HashMap::new();
141 if uids.is_empty() {
142 return out;
143 }
144 let rows = user::Entity::find()
145 .filter(user::Column::Uid.is_in(uids.to_vec()))
146 .all(db)
147 .await
148 .unwrap_or_default();
149 for row in rows {
150 let locale = resolve_locale(row.language.as_deref(), row.locale.as_deref(), None, fallback);
151 out.insert(row.uid, locale);
152 }
153 out
154}
Added lib/i18n/Cargo.toml View lib/i18n/Cargo.toml
@@ -0,0 +1,10 @@
1[package]
2name = "i18n"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7ci-protocol = { workspace = true }
8models = { workspace = true }
9sea-orm = { workspace = true }
10uuid = { workspace = true }
Modified lib/issues/src/services/comments.rs View lib/issues/src/services/comments.rs
@@ -249,16 +249,19 @@
249249 if !email_recipients.is_empty() {
250250 let emails = self.primary_emails(&email_recipients).await?;
251251 for (uid, addr) in emails {
252 let locale = self.recipient_locale(uid).await;
252253 let is_reply = Some(uid) == parent_poster_id;
253254 let subject = if is_reply {
254 format!(
255 "New reply on issue #{}: {}",
256 item.autoincrement_id, item.title
255 i18n::catalog::issue_reply_subject(
256 locale,
257 item.autoincrement_id,
258 &item.title,
257259 )
258260 } else {
259 format!(
260 "You were mentioned in issue #{}: {}",
261 item.autoincrement_id, item.title
261 i18n::catalog::issue_mention_subject(
262 locale,
263 item.autoincrement_id,
264 &item.title,
262265 )
263266 };
264267 let link = format!(
@@ -268,10 +271,10 @@
268271 board.id,
269272 item.id
270273 );
271 let text = format!("{}\n\nView issue: {}", subject, link);
274 let text = format!("{subject}\n\n{}: {link}", i18n::catalog::view_issue(locale));
272275 let html = format!(
273 r#"<p>{}</p><p><a href="{}">View Issue</a></p>"#,
274 subject, link
276 r#"<p>{subject}</p><p><a href="{link}">{}</a></p>"#,
277 i18n::catalog::view_issue(locale)
275278 );
276279 if let Err(err) = self.send_email(&addr, &subject, &text, &html).await {
277280 tracing::warn!(error = %err, "failed to send comment notification email");
Modified lib/issues/src/services/issues.rs View lib/issues/src/services/issues.rs
@@ -518,11 +518,10 @@
518518
519519 if !mentioned_uids.is_empty() {
520520 let emails = self.primary_emails(&mentioned_uids).await?;
521 for (_uid, addr) in emails {
522 let subject = format!(
523 "You were mentioned in issue #{}: {}",
524 inserted.autoincrement_id, inserted.title
525 );
521 for (uid, addr) in emails {
522 let locale = self.recipient_locale(uid).await;
523 let subject =
524 i18n::catalog::issue_mention_subject(locale, inserted.autoincrement_id, &inserted.title);
526525 let link = format!(
527526 "{}/issues/{}/{}/{}",
528527 self.config().web.web_base_url,
@@ -530,10 +529,10 @@
530529 board.id,
531530 inserted.id
532531 );
533 let text = format!("{}\n\nView issue: {}", subject, link);
532 let text = format!("{subject}\n\n{}: {link}", i18n::catalog::view_issue(locale));
534533 let html = format!(
535 r#"<p>{}</p><p><a href="{}">View Issue</a></p>"#,
536 subject, link
534 r#"<p>{subject}</p><p><a href="{link}">{}</a></p>"#,
535 i18n::catalog::view_issue(locale)
537536 );
538537 if let Err(err) = self.send_email(&addr, &subject, &text, &html).await {
539538 tracing::warn!(error = %err, "failed to send issue mention email");
Modified lib/issues/src/services/members.rs View lib/issues/src/services/members.rs
@@ -158,10 +158,9 @@
158158 if kind_enum == IssueMemberKind::Assignee && target_uid != caller {
159159 let emails = self.primary_emails(&[target_uid]).await?;
160160 if let Some(addr) = emails.get(&target_uid) {
161 let subject = format!(
162 "You were assigned to issue #{}: {}",
163 item.autoincrement_id, item.title
164 );
161 let locale = self.recipient_locale(target_uid).await;
162 let subject =
163 i18n::catalog::issue_assigned_subject(locale, item.autoincrement_id, &item.title);
165164 let link = format!(
166165 "{}/issues/{}/{}/{}",
167166 self.config().web.web_base_url,
@@ -169,10 +168,10 @@
169168 board.id,
170169 item.id
171170 );
172 let text = format!("{}\n\nView issue: {}", subject, link);
171 let text = format!("{subject}\n\n{}: {link}", i18n::catalog::view_issue(locale));
173172 let html = format!(
174 r#"<p>{}</p><p><a href="{}">View Issue</a></p>"#,
175 subject, link
173 r#"<p>{subject}</p><p><a href="{link}">{}</a></p>"#,
174 i18n::catalog::view_issue(locale)
176175 );
177176 if let Err(err) = self.send_email(addr, &subject, &text, &html).await {
178177 tracing::warn!(error = %err, "failed to send issue assignment email");
Modified lib/issues/src/services/service.rs View lib/issues/src/services/service.rs
@@ -5,6 +5,7 @@
55use config::AppConfig;
66use email::EmailService;
77use export::AppState;
8use i18n;
89use export::database::AppDatabase;
910use models::issue::issue_event_line::{self, IssueEventType};
1011use models::space::space_members::{self, MemberStatus};
@@ -218,6 +219,15 @@
218219 Ok(uids)
219220 }
220221
222 /// Language for one recipient's issue mail.
223 ///
224 /// Account preference only: an issue event fans out to watchers, the
225 /// assignee and mentioned users, each of whom may read a different language.
226 pub(crate) async fn recipient_locale(&self, uid: Uuid) -> i18n::UiLocale {
227 let fallback = i18n::config_fallback(self.config().email.default_locale());
228 i18n::resolve_user_locale(self.db().read(), uid, fallback).await
229 }
230
221231 pub(crate) async fn primary_emails(
222232 &self,
223233 uids: &[Uuid],
Modified lib/issues/Cargo.toml View lib/issues/Cargo.toml
@@ -7,6 +7,7 @@
77async-trait = { workspace = true }
88config = { workspace = true }
99export = { workspace = true }
10i18n = { workspace = true }
1011email = { workspace = true }
1112models = { workspace = true }
1213session = { workspace = true }
Modified lib/space/src/services/invites.rs View lib/space/src/services/invites.rs
@@ -258,7 +258,10 @@
258258 id: Set(Uuid::now_v7()),
259259 user_id: Set(invitee),
260260 notification_type: Set(NotificationType::Invite),
261 title: Set(format!("You were invited to join {}", space.name)),
261 title: Set(i18n::catalog::notification_invite_title(
262 self.recipient_locale(Some(invitee)).await,
263 &space.name,
264 )),
262265 body: Set(None),
263266 data_json: Set(Some(data)),
264267 actor_uid: Set(Some(caller)),
@@ -288,18 +291,21 @@
288291 }
289292
290293 if let Some(recipient) = recipient_email {
291 let subject = format!("You're invited to join {}", space.name);
294 // `recipient_email` may be a plain address with no account yet; the
295 // invitee uid is the account signal when it exists.
296 let locale = self.recipient_locale(target_uid).await;
297 let subject = i18n::catalog::space_invite_subject(locale, &space.name);
292298 let accept_url = format!(
293299 "{}/spaces/invites/accept?code={accept_code}",
294300 self.config().web.web_base_url
295301 );
296 let text = format!(
297 "You have been invited to join {}\n\nAccept invite: {}\n",
298 space.name, accept_url
299 );
302 let text = i18n::catalog::space_invite_text(locale, &space.name, &accept_url);
300303 let html = format!(
301 r#"<p>You have been invited to join <strong>{}</strong>.</p><p><a href="{}" style="display:inline-block;padding:8px 16px;background:#0066cc;color:#fff;text-decoration:none;border-radius:4px;">Accept Invite</a></p><p>Or copy this link: {}</p>"#,
302 space.name, accept_url, accept_url
304 r#"<p>{}</p><p><a href="{}" style="display:inline-block;padding:8px 16px;background:#0066cc;color:#fff;text-decoration:none;border-radius:4px;">{}</a></p><p>{}</p>"#,
305 i18n::catalog::space_invite_body(locale, &space.name),
306 accept_url,
307 i18n::catalog::space_invite_accept(locale),
308 i18n::catalog::space_invite_or_copy(locale, &accept_url)
303309 );
304310 if let Err(err) = self.send_email(&recipient, &subject, &text, &html).await {
305311 tracing::warn!(error = %err, "failed to send space invite email");
Modified lib/space/src/services/service.rs View lib/space/src/services/service.rs
@@ -7,6 +7,8 @@
77use session::SessionError;
88use session::share::guard::{SessionGuard, SessionWithUser};
99
10use uuid::Uuid;
11
1012use crate::errors::SpaceError;
1113
1214#[derive(Clone)]
@@ -35,6 +37,15 @@
3537 &self.state.email
3638 }
3739
40 /// Language for one recipient's space mail.
41 pub(crate) async fn recipient_locale(&self, uid: Option<Uuid>) -> i18n::UiLocale {
42 let fallback = i18n::config_fallback(self.config().email.default_locale());
43 match uid {
44 Some(uid) => i18n::resolve_user_locale(self.db().read(), uid, fallback).await,
45 None => fallback,
46 }
47 }
48
3849 pub(crate) async fn send_email(
3950 &self,
4051 to: &str,
Modified lib/space/Cargo.toml View lib/space/Cargo.toml
@@ -9,6 +9,7 @@
99config = { workspace = true }
1010export = { workspace = true }
1111email = { workspace = true }
12i18n = { workspace = true }
1213models = { workspace = true }
1314session = { workspace = true }
1415storage = { workspace = true }
Modified lib/user/src/services/emails.rs View lib/user/src/services/emails.rs
@@ -136,12 +136,19 @@
136136 };
137137 row.insert(self.db().write()).await?;
138138
139 let text = format!("Your email verification code is: {code}");
140 let html = format!("<p>Your email verification code is <strong>{code}</strong>.</p>");
139 // The user is signed in here, so the account preference is available.
140 let locale = self.recipient_locale(uid).await;
141 let text = i18n::catalog::add_email_text(locale, &code);
142 let html = format!("<p>{text}</p>");
141143 if let Err(error) = self
142144 .state
143145 .email
144 .send_mail(&email, "Verify your email", &text, &html)
146 .send_mail(
147 &email,
148 i18n::catalog::add_email_subject(locale),
149 &text,
150 &html,
151 )
145152 .await
146153 {
147154 tracing::warn!(%error, "failed to send add email verification code");
Modified lib/user/src/services/service.rs View lib/user/src/services/service.rs
@@ -5,6 +5,7 @@
55use export::{AppDatabase, AppState, AppStorage};
66use session::SessionError;
77use session::share::guard::{SessionGuard, SessionWithUser};
8use uuid::Uuid;
89
910#[derive(Clone)]
1011pub struct UserService {
@@ -20,6 +21,12 @@
2021 &self.state.config
2122 }
2223
24 /// Language for one recipient's account mail.
25 pub(crate) async fn recipient_locale(&self, uid: Uuid) -> i18n::UiLocale {
26 let fallback = i18n::config_fallback(self.config().email.default_locale());
27 i18n::resolve_user_locale(self.db().read(), uid, fallback).await
28 }
29
2330 pub fn db(&self) -> &Arc<AppDatabase> {
2431 &self.state.database
2532 }
Modified lib/user/Cargo.toml View lib/user/Cargo.toml
@@ -8,6 +8,8 @@
88config = { workspace = true }
99export = { workspace = true }
1010models = { workspace = true }
11i18n = { workspace = true }
12email = { workspace = true }
1113session = { workspace = true }
1214storage = { workspace = true }
1315axum = { workspace = true }
Added web/scripts/lint-i18n.mjs View web/scripts/lint-i18n.mjs
@@ -0,0 +1,148 @@
1#!/usr/bin/env bun
2/**
3 * i18n guard for the user-facing app.
4 *
5 * Checks, in order of how badly they break the product:
6 *
7 * 1. Dictionary parity — zh-CN is the reference key set; en must contain every
8 * key with an identical `{placeholder}` set, and neither may be empty.
9 * These are hard failures: a missing translation is a shipped bug.
10 * 2. Untranslated copy — a CJK literal in `src` means a user-visible string was
11 * missed. Comments and the generated API client are exempt.
12 *
13 * The second check is a **ratchet**. The whole app is being migrated in waves,
14 * so the remaining count is compared against `CJK_BASELINE`: the build fails if
15 * it grows (new hardcoded copy cannot land) and reports the number otherwise.
16 * When a wave finishes, lower the baseline; when the last wave lands, set it to
17 * 0 and the check becomes absolute.
18 *
19 * Usage: bun scripts/lint-i18n.mjs [--strict]
20 * --strict ignores the baseline and fails on any untranslated copy.
21 */
22import { readdirSync, readFileSync, statSync } from 'node:fs'
23import { dirname, join, relative, resolve } from 'node:path'
24import { fileURLToPath } from 'node:url'
25
26const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
27const srcRoot = join(repoRoot, 'src')
28const messagesRoot = join(srcRoot, 'shared', 'i18n', 'messages')
29const strict = process.argv.includes('--strict')
30
31/** Maximum tolerated untranslated lines; lowered wave by wave, 0 at the end. */
32const CJK_BASELINE = Number(process.env.I18N_CJK_BASELINE ?? 0)
33
34/** Files whose CJK text is authored content, not translatable copy. */
35const CJK_EXEMPT = [
36 'src/shared/api/', // generated client; descriptions come from the OpenAPI doc
37 'src/shared/i18n/messages/', // the dictionaries themselves
38 'src/shared/i18n/locale.ts', // `localeLabel` is an endonym table: 简体中文 is
39 // the correct label for Chinese even while the UI is in English
40]
41
42const CJK = /[\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]/
43
44const failures = []
45const warnings = []
46
47function walk(dir) {
48 const out = []
49 for (const entry of readdirSync(dir)) {
50 const path = join(dir, entry)
51 if (statSync(path).isDirectory()) out.push(...walk(path))
52 else out.push(path)
53 }
54 return out
55}
56
57/** Collect `'key': 'value'` pairs from a dictionary module. */
58function readDictionary(directory) {
59 const entries = new Map()
60 for (const file of walk(directory)) {
61 if (!file.endsWith('.ts') || file.endsWith('index.ts')) continue
62 const source = readFileSync(file, 'utf8')
63 const pair = /^\s*'([^']+)':\s*(?:\r?\n\s*)?((?:'(?:[^'\\]|\\.)*')|(?:"[^"]*"))/gm
64 for (const match of source.matchAll(pair)) {
65 const [, key, rawValue] = match
66 const value = rawValue.slice(1, -1)
67 if (entries.has(key)) failures.push(`${relative(repoRoot, file)}: duplicate key ${key}`)
68 entries.set(key, value)
69 }
70 }
71 return entries
72}
73
74function placeholders(value) {
75 return [...value.matchAll(/\{(\w+)\}/g)].map(m => m[1]).sort()
76}
77
78const zh = readDictionary(join(messagesRoot, 'zh-CN'))
79const en = readDictionary(join(messagesRoot, 'en'))
80
81for (const [key, value] of zh) {
82 const other = en.get(key)
83 if (other === undefined) {
84 failures.push(`en is missing key: ${key}`)
85 continue
86 }
87 if (!other.trim()) failures.push(`en has an empty value: ${key}`)
88 if (jsonish(value) === jsonish(other) && value !== other) {
89 failures.push(`en value is not a real translation for ${key}`)
90 }
91 const a = placeholders(value).join(',')
92 const b = placeholders(other).join(',')
93 if (a !== b) failures.push(`placeholder mismatch for ${key}: zh-CN has [${a}] but en has [${b}]`)
94}
95for (const key of en.keys()) {
96 if (!zh.has(key)) failures.push(`zh-CN is missing key: ${key}`)
97}
98for (const [key, value] of zh) if (!value.trim()) failures.push(`empty zh-CN value: ${key}`)
99
100function jsonish(value) {
101 return value.replace(/\s+/g, ' ').trim()
102}
103
104/**
105 * Strip comments only. CJK inside a comment documents Rust-side behaviour and
106 * never reaches a user; CJK inside a string or JSX always does.
107 */
108function stripComments(source) {
109 return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
110}
111
112const scanned = walk(srcRoot).filter(file => /\.(ts|tsx)$/.test(file))
113let cjkLines = 0
114for (const file of scanned) {
115 const rel = relative(repoRoot, file).replace(/\\/g, '/')
116 if (CJK_EXEMPT.some(exempt => rel.startsWith(exempt))) continue
117 const source = stripComments(readFileSync(file, 'utf8'))
118 for (const [index, line] of source.split('\n').entries()) {
119 if (!CJK.test(line)) continue
120 cjkLines += 1
121 if (strict) {
122 failures.push(`untranslated CJK copy at ${rel}:${index + 1}: ${line.trim().slice(0, 120)}`)
123 } else if (cjkLines <= 12) {
124 warnings.push(`untranslated CJK copy at ${rel}:${index + 1}: ${line.trim().slice(0, 100)}`)
125 }
126 }
127}
128
129if (!strict && cjkLines > CJK_BASELINE) {
130 failures.push(
131 `untranslated CJK lines grew to ${cjkLines}, above the baseline of ${CJK_BASELINE} — ` +
132 'add dictionary entries instead of new hardcoded copy',
133 )
134}
135
136if (failures.length) {
137 console.error(`i18n lint failed with ${failures.length} problem(s):`)
138 for (const failure of failures.slice(0, 60)) console.error(` - ${failure}`)
139 if (failures.length > 60) console.error(` … and ${failures.length - 60} more`)
140 process.exit(1)
141}
142if (warnings.length) {
143 console.warn(`i18n: ${cjkLines} line(s) still unmigrated (baseline ${CJK_BASELINE}); first few:`)
144 for (const warning of warnings) console.warn(` - ${warning}`)
145}
146console.log(
147 `i18n lint passed: ${zh.size} keys, ${cjkLines}/${CJK_BASELINE} CJK lines remaining across ${scanned.length} files`,
148)
Modified web/src/app/styles/workspace.css View web/src/app/styles/workspace.css
@@ -84,7 +84,7 @@
8484.workspace-dialog h2 { font-size: 20px; line-height: 1.4; }
8585.workspace-dialog :is(input, select, textarea) { border-width: 1px; border-radius: 10px; font-size: 14px; }
8686.workspace-dialog :is(input:not([type="checkbox"]), select) { min-height: 42px; }
87.workspace-dialog button[aria-label="关闭"] { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: var(--shell-field); }
87.workspace-dialog .dialog-close { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: var(--shell-field); }
8888.settings-dialog { display: flex; position: relative; width: min(1060px, 94vw); height: min(820px, 90dvh); overflow: hidden; border: 1px solid var(--shell-hairline); border-radius: 22px; background: var(--shell-surface); box-shadow: 0 24px 80px rgb(15 17 19 / 22%); }
8989.settings-nav { display: flex; width: 220px; flex-shrink: 0; flex-direction: column; gap: 5px; overflow-y: auto; padding: 24px 16px; background: var(--shell-sidebar); border-right: 1px solid var(--shell-hairline); }
9090.settings-nav > p { padding: 0 12px 18px; font-size: 13px; font-weight: 600; color: var(--shell-ink-muted); }
Modified web/src/app/api-error-gate.tsx View web/src/app/api-error-gate.tsx
@@ -1,6 +1,7 @@
11import type { ReactNode } from 'react'
22import { Loader2 } from 'lucide-react'
33import { API_PROBE_INTERVAL_MS, retryApiHealth, useApiHealth, useApiUnavailable } from '@/shared/api'
4import { useT } from '@/shared/i18n'
45import { Button } from '@/shared/ui'
56
67/**
@@ -21,15 +22,8 @@
2122 return <>{children}</>
2223}
2324
24/** Failure reason in words: status 0 / transport error means "no answer". */
25function failureLabel(status: number | null): string {
26 if (status === null) return '无响应'
27 if (status === 0) return '网络错误(无响应)'
28 if (status >= 500) return `HTTP ${status} · 服务端错误`
29 return `HTTP ${status}`
30}
31
3225function ServiceUnavailableScreen() {
26 const { t } = useT()
3327 const {
3428 failures,
3529 samples,
@@ -43,6 +37,16 @@
4337 const percent = Math.round(errorRate * 100)
4438 const path = lastFailureUrl ? lastFailureUrl.replace(/^https?:\/\/[^/]+/, '') : null
4539
40 /** Failure reason in words: status 0 / transport error means "no answer". */
41 const failureLabel =
42 lastFailureStatus === null
43 ? t('error.gate.noResponse')
44 : lastFailureStatus === 0
45 ? t('error.gate.networkError')
46 : lastFailureStatus >= 500
47 ? t('error.gate.serverError', { status: lastFailureStatus })
48 : `HTTP ${lastFailureStatus}`
49
4650 return (
4751 <main
4852 role="alert"
@@ -51,29 +55,36 @@
5155 >
5256 <img
5357 src="/wronged.png"
54 alt="服务暂不可用"
58 alt={t('error.gate.imageAlt')}
5559 draggable={false}
5660 className="max-h-[38vh] w-auto max-w-[min(420px,72vw)] shrink-0 select-none object-contain"
5761 />
5862 <div className="flex max-w-md flex-col gap-1.5">
59 <h1 className="text-[17px] font-semibold tracking-tight">服务暂不可用</h1>
63 <h1 className="text-[17px] font-semibold tracking-tight">{t('error.gate.title')}</h1>
6064 <p className="text-[13px] leading-relaxed text-shell-ink-muted">
61 接口调用持续失败(连续 {burstFailures} 次请求无成功响应),页面已暂停渲染。
62 请确认后端服务是否已启动。
65 {t('error.gate.body', { count: burstFailures })}
6366 </p>
6467 </div>
6568 <p className="max-w-[min(560px,92vw)] truncate font-mono text-[11px] text-shell-ink-muted">
66 {`最近一次失败:${failureLabel(lastFailureStatus)}${path ? ` · ${path}` : ''} | 窗口失败率 ${percent}%(${failures}/${samples})`}
69 {t('error.gate.lastFailure', {
70 reason: failureLabel,
71 path: path ? ` · ${path}` : '',
72 percent,
73 failures,
74 samples,
75 })}
6776 </p>
6877 <div className="justify-self-center">
6978 <Button onClick={retryApiHealth} disabled={probing} className="min-w-32">
7079 {probing ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
71 {probing ? '连接中…' : '重试连接'}
80 {probing ? t('error.gate.connecting') : t('error.gate.retry')}
7281 </Button>
7382 </div>
7483 <p className="text-[11px] text-shell-ink-muted">
75 每 {Math.round(API_PROBE_INTERVAL_MS / 1000)}s 自动探测一次
76 {lastProbeAt ? ` · 上次 ${new Date(lastProbeAt).toLocaleTimeString([], { hour12: false })}` : ''}
84 {t('error.gate.probeInterval', { seconds: Math.round(API_PROBE_INTERVAL_MS / 1000) })}
85 {lastProbeAt
86 ? ` · ${t('error.gate.lastProbe', { time: new Date(lastProbeAt).toLocaleTimeString([], { hour12: false }) })}`
87 : ''}
7788 </p>
7889 </main>
7990 )
Modified web/src/app/index.tsx View web/src/app/index.tsx
@@ -1,6 +1,7 @@
11import { StrictMode } from 'react'
22import { createRoot } from 'react-dom/client'
33import { RouterProvider } from 'react-router-dom'
4import { bootLocale } from '@/shared/i18n'
45import { initPalette, initTheme } from '@/shared/lib'
56import { prefetchWorkspaceBootstrap } from '@/pages/workspace'
67import { AppProviders } from './bootstrap'
@@ -9,6 +10,9 @@
910
1011initTheme()
1112initPalette()
13// Resolve the interface language before anything paints: the store is a
14// module-level singleton, so this also sets `<html lang>` for a11y and fonts.
15bootLocale()
1216// Warm identity and the global lists before the router even mounts, so the
1317// shell's first paint reads them from the cache instead of waiting on them.
1418prefetchWorkspaceBootstrap()
Modified web/src/entities/chat/model/api.ts View web/src/entities/chat/model/api.ts
@@ -14,6 +14,7 @@
1414 * rejects an empty `thread_id=` with a 400.
1515 */
1616import Apis from '@/shared/api'
17import { getLocale, translate } from '@/shared/i18n'
1718import { unwrapApi, type ApiEnvelope } from '@/shared/lib'
1819import type {
1920 Attachment,
@@ -446,7 +447,7 @@
446447 body: file,
447448 })
448449 if (!response.ok) {
449 throw new Error(`上传失败(${response.status})`)
450 throw new Error(translate(getLocale(), 'chat.upload.failedStatus', { status: response.status }))
450451 }
451452}
452453
Modified web/src/entities/chat/model/meta.ts View web/src/entities/chat/model/meta.ts
@@ -7,6 +7,7 @@
77 * is guaranteed to be rejected.
88 */
99import { isSystemMessage } from './chat'
10import { getLocale, t } from '@/shared/i18n'
1011import type { Attachment, ChatMessage, ReactionAgg } from './chat'
1112
1213/** `chat.max_message_len` — counted in code points, like Rust's `chars().count()`. */
@@ -87,7 +88,7 @@
8788 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
8889}
8990
90/** Divider label: 今天 / 昨天 / 周X / 完整日期 for anything older. */
91/** Divider label: today / yesterday / weekday / full date for anything older. */
9192export function dayLabel(value: string): string {
9293 const stamp = parseStamp(value)
9394 if (!Number.isFinite(stamp)) return value
@@ -96,11 +97,18 @@
9697 const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
9798 const startOfThat = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
9899 const days = Math.round((startOfToday - startOfThat) / 86_400_000)
99 if (days === 0) return '今天'
100 if (days === 1) return '昨天'
101 if (days > 1 && days < 7) return `周${'日一二三四五六'[date.getDay()]}`
102 const year = date.getFullYear() === now.getFullYear() ? '' : `${date.getFullYear()}年`
103 return `${year}${date.getMonth() + 1}月${date.getDate()}日`
100 if (days === 0) return t('chat.day.today')
101 if (days === 1) return t('chat.day.yesterday')
102 // Within the last week the weekday alone is unambiguous; `weekday: 'short'`
103 // yields 周一 in Chinese and Mon in English without a hand-written table.
104 if (days > 1 && days < 7) {
105 return new Intl.DateTimeFormat(getLocale(), { weekday: 'short' }).format(date)
106 }
107 return new Intl.DateTimeFormat(getLocale(), {
108 year: date.getFullYear() === now.getFullYear() ? undefined : 'numeric',
109 month: 'long',
110 day: 'numeric',
111 }).format(date)
104112}
105113
106114/** Full stamp for tooltips and system rows. */
@@ -270,17 +278,7 @@
270278 return `/api/v1/chat/spaces/${encodeURIComponent(spaceName)}/emojis/${encodeURIComponent(emojiId)}/image`
271279}
272280
273export function formatSize(bytes: number | null | undefined): string {
274 if (bytes === null || bytes === undefined || !Number.isFinite(bytes)) return '—'
275 const units = ['B', 'KB', 'MB', 'GB']
276 let size = bytes
277 let unit = 0
278 while (size >= 1024 && unit < units.length - 1) {
279 size /= 1024
280 unit += 1
281 }
282 return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`
283}
281export { formatBytes as formatSize } from '@/shared/i18n'
284282
285283/** First glyph of a person's name, for the coloured avatar tile. */
286284export function initialsOf(user?: { display_name?: string; username?: string } | null): string {
@@ -298,13 +296,15 @@
298296 */
299297export function describeFailure(error: unknown): string {
300298 const code = failureCode(error)
301 if (code === 401) return '登录状态已失效,请重新登录'
302 if (code === 403) return '没有权限在该频道发言'
303 if (code === 404) return '频道或消息已不存在'
304 if (code === 429) return '发送太快了,请稍后再试'
305 if (code === 0 || code === 502 || code === 503 || code === 504) return '服务未响应,消息未发送'
299 if (code === 401) return t('chat.composer.unauthorized')
300 if (code === 403) return t('chat.composer.forbidden')
301 if (code === 404) return t('chat.composer.notFound')
302 if (code === 429) return t('chat.composer.rateLimited')
303 if (code === 0 || code === 502 || code === 503 || code === 504) {
304 return t('chat.composer.unavailable')
305 }
306306 if (error instanceof Error && error.message) return error.message
307 return '发送失败,请稍后重试'
307 return t('chat.composer.failed')
308308}
309309
310310/** HTTP status mirrored by the envelope, or undefined when it is not one. */
Modified web/src/entities/issue/model/meta.ts View web/src/entities/issue/model/meta.ts
@@ -4,8 +4,12 @@
44 * component) because the kanban columns, the filter chips, the list table and
55 * the detail sidebar all have to agree on them.
66 */
7import { getLocale, t, type MessageKey } from '@/shared/i18n'
8import { zhCN } from '@/shared/i18n/messages/zh-CN'
79import type { IssuePriority, IssueState, IssueSort, WatchMode } from './issue'
810
11const MESSAGE_KEYS: ReadonlySet<string> = new Set(Object.keys(zhCN))
12
913export interface PriorityMeta {
1014 id: IssuePriority
1115 label: string
@@ -19,11 +23,11 @@
1923
2024/** Highest priority first — matches `?sort=priority` (backend sorts desc). */
2125export const PRIORITIES: readonly PriorityMeta[] = [
22 { id: 'urgent', label: '紧急', color: '#e5484d', text: '#ffffff', rank: 4 },
23 { id: 'high', label: '高', color: '#f76808', text: '#ffffff', rank: 3 },
24 { id: 'medium', label: '中', color: '#ffc53d', text: '#3d2a00', rank: 2 },
25 { id: 'low', label: '低', color: '#8fb8ff', text: '#12315e', rank: 1 },
26 { id: 'none', label: '无优先级', color: '#d6d9e0', text: '#3f434b', rank: 0 },
26 { id: 'urgent', get label() { return t('label.priority.urgent') }, color: '#e5484d', text: '#ffffff', rank: 4 },
27 { id: 'high', get label() { return t('label.priority.high') }, color: '#f76808', text: '#ffffff', rank: 3 },
28 { id: 'medium', get label() { return t('label.priority.medium') }, color: '#ffc53d', text: '#3d2a00', rank: 2 },
29 { id: 'low', get label() { return t('label.priority.low') }, color: '#8fb8ff', text: '#12315e', rank: 1 },
30 { id: 'none', get label() { return t('label.priority.none') }, color: '#d6d9e0', text: '#3f434b', rank: 0 },
2731] as const
2832
2933export const PRIORITY_META: Record<IssuePriority, PriorityMeta> = PRIORITIES.reduce(
@@ -36,30 +40,35 @@
3640}
3741
3842export const STATES: Readonly<Record<IssueState, { label: string; color: string }>> = {
39 open: { label: '未关闭', color: '#3ba55d' },
40 closed: { label: '已关闭', color: '#66717d' },
43 open: { get label() { return t('label.issueState.open') }, color: '#3ba55d' },
44 closed: { get label() { return t('label.issueState.closed') }, color: '#66717d' },
4145}
4246
4347export const STATE_FILTERS: ReadonlyArray<{ id: IssueState | 'all'; label: string }> = [
44 { id: 'open', label: '未关闭' },
45 { id: 'closed', label: '已关闭' },
46 { id: 'all', label: '全部' },
48 { id: 'open', get label() { return t('label.issueState.open') } },
49 { id: 'closed', get label() { return t('label.issueState.closed') } },
50 { id: 'all', get label() { return t('label.issueState.all') } },
4751]
4852
4953export const SORTS: ReadonlyArray<{ id: IssueSort; label: string }> = [
50 { id: 'created', label: '最新创建' },
51 { id: 'updated', label: '最近更新' },
52 { id: 'priority', label: '优先级' },
53 { id: 'due', label: '截止日期' },
54 { id: 'number', label: '编号' },
54 { id: 'created', get label() { return t('label.issueSort.created') } },
55 { id: 'updated', get label() { return t('label.issueSort.updated') } },
56 { id: 'priority', get label() { return t('label.issueSort.priority') } },
57 { id: 'due', get label() { return t('label.issueSort.due') } },
58 { id: 'number', get label() { return t('label.issueSort.number') } },
5559]
5660
5761export const WATCH_LABELS: Readonly<Record<WatchMode, string>> = {
58 watching: '关注',
59 unwatched: '默认',
60 ignore: '忽略',
62 get watching() {
63 return t('issue.watch.watching')
64 },
65 get unwatched() {
66 return t('issue.watch.unwatched')
67 },
68 get ignore() {
69 return t('issue.watch.ignore')
70 },
6171}
62
6372/** Sort values the backend rejects are dropped so the query stays valid. */
6473export const SORT_PARAMS: Record<IssueSort, string> = {
6574 created: 'created_at',
@@ -89,16 +98,19 @@
8998export function describeDue(value: string | null | undefined, state: IssueState): DueMeta | null {
9099 const date = parseIssueDate(value)
91100 if (!date) return null
92 const day = `${date.getMonth() + 1}/${date.getDate()}`
101 const day = new Intl.DateTimeFormat(getLocale(), { month: 'numeric', day: 'numeric' }).format(date)
93102 if (state === 'closed') return { text: day, tone: 'done' }
94103 const today = new Date()
95104 today.setHours(0, 0, 0, 0)
96105 const start = new Date(date)
97106 start.setHours(0, 0, 0, 0)
98107 const days = Math.round((start.getTime() - today.getTime()) / 86_400_000)
99 if (days < 0) return { text: `${day} 已逾期`, tone: 'over' }
100 if (days === 0) return { text: `${day} 今天`, tone: 'due' }
101 if (days <= 7) return { text: `${day} ${days} 天后`, tone: 'due' }
108 // Absolute date plus a relative phrase; Intl supplies the wording so the
109 // table stays out of the translation catalogue.
110 const relative = new Intl.RelativeTimeFormat(getLocale(), { numeric: 'auto' })
111 if (days < 0) return { text: `${day} · ${relative.format(days, 'day')}`, tone: 'over' }
112 if (days === 0) return { text: `${day} · ${relative.format(0, 'day')}`, tone: 'due' }
113 if (days <= 7) return { text: `${day} · ${relative.format(days, 'day')}`, tone: 'due' }
102114 return { text: day, tone: 'muted' }
103115}
104116
@@ -153,32 +165,22 @@
153165 * values. Unknown types fall back to the raw value, so a new server-side event
154166 * still renders (unreadable rather than missing).
155167 */
156export const EVENT_LABELS: Readonly<Record<string, string>> = {
157 created: '创建了工单',
158 title_updated: '修改了标题',
159 description_updated: '修改了摘要',
160 content_updated: '修改了正文',
161 milestone_updated: '调整了里程碑',
162 priority_updated: '调整了优先级',
163 due_updated: '调整了截止日期',
164 closed: '关闭了工单',
165 reopened: '重新打开了工单',
166 comment_created: '添加了评论',
167 comment_updated: '编辑了评论',
168 comment_deleted: '删除了评论',
169 member_added: '添加了成员',
170 member_removed: '移除了成员',
171 label_added: '添加了标签',
172 label_removed: '移除了标签',
173 dependency_added: '添加了依赖',
174 dependency_removed: '移除了依赖',
175 pinned: '置顶了工单',
176 unpinned: '取消置顶',
177 repo_relation_added: '关联了仓库',
178 repo_relation_removed: '取消了仓库关联',
179}
168/**
169 * Activity line labels, keyed by the backend's `IssueEventType` snake_case
170 * values. Resolved lazily so the table follows the active locale; unknown types
171 * fall back to the raw value, so a new server-side event still renders.
172 */
173export const EVENT_LABELS: Readonly<Record<string, string>> = new Proxy(
174 {},
175 {
176 get: (_target, key: string) => {
177 const entry = `issue.event.${key}` as MessageKey
178 return entry in MESSAGE_KEYS ? t(entry) : undefined
179 },
180 },
181)
180182
181183export function eventLabel(type: string | null | undefined): string {
182 if (!type) return '更新了工单'
183 return EVENT_LABELS[type] ?? `更新了工单(${type})`
184 if (!type) return t('issue.event.unknown')
185 return EVENT_LABELS[type] ?? t('issue.event.fallback', { type })
184186}
Modified web/src/entities/pull-request/model/ai.ts View web/src/entities/pull-request/model/ai.ts
@@ -5,6 +5,7 @@
55 * 合并请求与其提交的评审表面。
66 */
77import Apis from '@/shared/api'
8import { getLocale, translate } from '@/shared/i18n'
89import { unwrapApi } from '@/shared/lib'
910import type {
1011 AppResponseData_AiReviewDto,
@@ -78,7 +79,9 @@
7879 )
7980
8081export function aiReviewVerdictLabel(verdict: PullAiReview['verdict']): string {
81 return verdict === 'changes_requested' ? '请求修改' : '仅评论'
82 return verdict === 'changes_requested'
83 ? translate(getLocale(), 'pull.ai.verdict.changesRequested')
84 : translate(getLocale(), 'pull.ai.verdict.commented')
8285}
8386
8487export function severityTone(severity?: string | null): string {
@@ -100,5 +103,8 @@
100103 unwrapApi<LanguageOption[]>(Apis.ai.list_ai_languages({}))
101104
102105/** 语言显示名:本语名优先,`auto` 用调用方给的文案。 */
103export const languageLabel = (option: LanguageOption, autoLabel = '沿用内容语言') =>
106export const languageLabel = (
107 option: LanguageOption,
108 autoLabel = translate(getLocale(), 'pull.ai.autoLanguage'),
109) =>
104110 option.auto ? autoLabel : option.native_name
Modified web/src/entities/pull-request/ui/language-picker.tsx View web/src/entities/pull-request/ui/language-picker.tsx
@@ -1,4 +1,5 @@
11import { Languages } from 'lucide-react'
2import { useT } from '@/shared/i18n'
23import { useAsync } from '@/shared/lib'
34import { languageLabel, listAiLanguages, type LanguageOption } from '../model/ai'
45
@@ -6,32 +7,35 @@
67 value: string
78 onChange: (code: string) => void
89 disabled?: boolean
9 /** `auto` 选项在业务语境里的文案(如「沿用内容语言」)。 */
10 /** Overrides the `auto` wording for the calling screen. */
1011 autoLabel?: string
11 /** 渲染在 select 前的说明文字。 */
12 /** Accessible label in front of the select. */
1213 label?: string
1314}
1415
1516/**
16 * AI 输出语言选择器。
17 * AI output-language picker.
1718 *
18 * 选项来自 `GET /ai/languages`:覆盖中、英、日、韩、法、德、西、葡、俄、阿等
19 * 主流语言,外加 `auto`(沿用代码注释的语言)。空字符串表示「跟随我的偏好」——
20 * 由服务端按 `users.language` 解析。
19 * Options come from `GET /ai/languages` — the mainstream languages plus `auto`
20 * (follow the language of the code comments). An empty value means "follow my
21 * preference", which the server resolves from `users.language`.
2122 */
2223export function LanguagePicker({
2324 value,
2425 onChange,
2526 disabled,
26 autoLabel = '沿用内容语言',
27 label = '输出语言',
27 autoLabel,
28 label,
2829}: LanguagePickerProps) {
30 const { t: tr } = useT()
31 const auto = autoLabel ?? tr('pull.ai.autoLanguage')
32 const title = label ?? tr('pull.ai.outputLanguage')
2933 const options = useAsync(() => listAiLanguages(), [], { cacheKey: 'ai:languages' })
3034 const rows: LanguageOption[] = options.data ?? []
31 // 「跟随偏好」在没有已知选项时也要能表达,因此始终保留一个空值项。
35 // The first option is synthesized, not fetched, so it must always be present.
3236 const follow: LanguageOption = {
3337 code: '',
34 native_name: '跟随我的偏好',
38 native_name: tr('pull.ai.followPreference'),
3539 english_name: 'Follow my preference',
3640 auto: false,
3741 default: false,
@@ -39,25 +43,22 @@
3943 return (
4044 <label className="flex items-center gap-1.5 text-[12.5px] text-shell-ink-muted">
4145 <Languages className="h-3.5 w-3.5 shrink-0" aria-hidden />
42 <span className="sr-only">{label}</span>
46 <span className="sr-only">{title}</span>
4347 <select
4448 value={value}
4549 disabled={disabled || options.loading}
4650 onChange={event => onChange(event.target.value)}
47 title={label}
51 title={title}
4852 className="rounded-md border border-shell-hairline bg-shell-field px-2 py-1 text-[12.5px] text-shell-ink disabled:opacity-60"
4953 >
50 <option value="">跟随我的偏好</option>
54 <option value="">{follow.native_name}</option>
5155 {rows.map(option => (
5256 <option key={option.code} value={option.code}>
53 {languageLabel(
54 option,
55 option.code === 'auto' ? autoLabel : option.native_name,
56 )}
57 {option.default ? '(默认)' : ''}
57 {languageLabel(option, option.code === 'auto' ? auto : option.native_name)}
58 {option.default ? tr('pull.ai.defaultOption') : ''}
5859 </option>
5960 ))}
60 {/* 极端情况下清单为空:至少让 follow 选项可读。 */}
61 {/* Degenerate case: the list is empty, so at least the follow option reads. */}
6162 {rows.length === 0 && !options.loading ? (
6263 <option value={follow.code} disabled hidden>
6364 {follow.native_name}
@@ -66,4 +67,4 @@
6667 </select>
6768 </label>
6869 )
69}
No newline at end of file
70}
Modified web/src/entities/repo/model/diff.ts View web/src/entities/repo/model/diff.ts
@@ -9,6 +9,8 @@
99 * freeze the page.
1010 */
1111
12import { getLocale, translate } from '@/shared/i18n'
13
1214export type DiffFileStatus = 'added' | 'deleted' | 'renamed' | 'copied' | 'modified' | 'binary'
1315
1416export interface DiffFile {
@@ -71,7 +73,14 @@
7173 if (line.startsWith('+')) additions += 1
7274 else if (line.startsWith('-')) deletions += 1
7375 }
74 return { path: path || '(未知路径)', oldPath, status, raw: chunk, additions, deletions }
76 return {
77 path: path || translate(getLocale(), 'repo.unknownPath'),
78 oldPath,
79 status,
80 raw: chunk,
81 additions,
82 deletions,
83 }
7584 })
7685}
7786
Modified web/src/entities/repo/model/imports.ts View web/src/entities/repo/model/imports.ts
@@ -8,6 +8,7 @@
88 * import wizard. Types mirror the backend DTOs in lib/import/src/service.rs.
99 */
1010import { trackFetch } from '@/shared/api'
11import { getLocale, translate } from '@/shared/i18n'
1112import { ApiFailure, type ApiEnvelope } from '@/shared/lib'
1213
1314export type ImportProvider = 'github' | 'gitlab' | 'gitea'
@@ -83,7 +84,7 @@
8384 const body = (await response.json()) as ApiEnvelope<T>
8485 if (!response.ok || body.code !== 200 || body.data === null || body.data === undefined) {
8586 throw new ApiFailure(
86 body.error || body.message || '导入请求失败',
87 body.error || body.message || translate(getLocale(), 'repo.import.failed'),
8788 body.code || response.status,
8889 body.error_code,
8990 )
Modified web/src/entities/search/model/api.ts View web/src/entities/search/model/api.ts
@@ -1,4 +1,5 @@
11import { trackFetch } from '@/shared/api'
2import { getLocale, translate } from '@/shared/i18n'
23import { ApiFailure, type ApiEnvelope } from '@/shared/lib'
34import type { SearchInput, SearchPage } from './search'
45
@@ -14,7 +15,7 @@
1415 const response = await trackFetch(`/api/v1/search?${params.toString()}`)
1516 const body = await response.json() as ApiEnvelope<SearchPage>
1617 if (!response.ok || body.code !== 200 || !body.data) {
17 throw new ApiFailure(body.error || body.message || '搜索失败', body.code || response.status, body.error_code)
18 throw new ApiFailure(body.error || body.message || translate(getLocale(), 'search.requestFailed'), body.code || response.status, body.error_code)
1819 }
1920 return body.data
2021}
Modified web/src/features/auth/model/api.ts View web/src/features/auth/model/api.ts
@@ -1,4 +1,5 @@
11import Apis from '@/shared/api'
2import { getLocale, translate, type MessageKey } from '@/shared/i18n'
23import type {
34 AppResponseData_ForgotPasswordResp,
45 AppResponseData_LoginResp,
@@ -38,12 +39,58 @@
3839 }
3940}
4041
42/**
43 * Localized user-facing error text. The server's own wording is the last
44 * resort: `error_code` and status are stable machine-readable handles that the
45 * dictionary can translate, but a validation message built from user input is
46 * not translatable and must pass through unchanged.
47 */
4148export function toUserError(err: unknown): string {
42 if (err instanceof ApiError) return err.message
43 if (err instanceof TypeError) return 'Network error — check your connection and retry.'
44 return 'Something went wrong. Please try again.'
49 if (err instanceof ApiError) {
50 const key = err.errorCode ? (`error.code.${err.errorCode}` as MessageKey) : null
51 if (key && key in ERROR_KEYS) {
52 return translate(getLocale(), key)
53 }
54 const byStatus = `error.status.${err.code}` as MessageKey
55 if (byStatus in ERROR_KEYS) return translate(getLocale(), byStatus)
56 return err.message
57 }
58 if (err instanceof TypeError) return translate(getLocale(), 'auth.error.network')
59 return translate(getLocale(), 'auth.error.unknown')
4560}
4661
62/**
63 * Key membership probe: the dictionaries are `Record<string, string>` at the
64 * boundary, so an unknown server code must be detected rather than assumed.
65 */
66const ERROR_KEYS = {
67 'error.code.invalid_credentials': true,
68 'error.code.pow_failed': true,
69 'error.code.unauthorized': true,
70 'error.code.forbidden': true,
71 'error.code.not_found': true,
72 'error.code.conflict': true,
73 'error.code.rate_limited': true,
74 'error.code.bad_request': true,
75 'error.code.invalid_request_body': true,
76 'error.code.merge_blocked': true,
77 'error.code.import_disabled': true,
78 'error.code.import_upstream_failed': true,
79 'error.code.password_change_required': true,
80 'error.status.400': true,
81 'error.status.401': true,
82 'error.status.403': true,
83 'error.status.404': true,
84 'error.status.409': true,
85 'error.status.429': true,
86 'error.status.500': true,
87 'error.status.502': true,
88 'error.status.503': true,
89 'error.status.504': true,
90 'error.status.0': true,
91 'error.status.unknown': true,
92} as const satisfies Partial<Record<MessageKey, true>>
93
4794async function unwrap<T>(method: PromiseLike<Envelope<T>>): Promise<T | null> {
4895 const res = await method
4996 if (res.code !== 200) {
Modified web/src/features/auth/ui/auth-shell.tsx View web/src/features/auth/ui/auth-shell.tsx
@@ -1,6 +1,7 @@
1import type { ReactNode } from 'react'
1import { useEffect, type ReactNode } from 'react'
22import { Link } from 'react-router-dom'
33import { Moon, Sun } from 'lucide-react'
4import { useT } from '@/shared/i18n'
45import { getRegion, toggleTheme, useTheme } from '@/shared/lib'
56import { KernelMark } from './kernel-mark'
67
@@ -23,8 +24,15 @@
2324
2425/** Full-viewport chrome shared by all auth screens. */
2526export function AuthShell({ current, hint, children }: AuthShellProps) {
27 const { t } = useT()
2628 const theme = useTheme()
2729 const region = getRegion()
30
31 // Each auth screen is a full document: keep the tab title in step with the
32 // active step and the interface language (current is already localized).
33 useEffect(() => {
34 document.title = `${current} · ${t('common.app.name')}`
35 }, [current, t])
2836 return (
2937 <main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 sm:px-8 lg:p-6">
3038 <div
@@ -49,7 +57,7 @@
4957 </div>
5058
5159 <nav
52 aria-label="Breadcrumb"
60 aria-label={t('auth.shell.breadcrumb')}
5361 className="absolute left-8 top-9 hidden items-center gap-2 font-mono text-[10px] tracking-[1.2px] sm:flex lg:left-[70px]"
5462 >
5563 <KernelMark size={14} radius={4} />
@@ -65,7 +73,7 @@
6573 <button
6674 type="button"
6775 onClick={toggleTheme}
68 aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
76 aria-label={theme === 'dark' ? t('auth.shell.switchToLight') : t('auth.shell.switchToDark')}
6977 className="absolute right-[40px] top-[16px] z-20 flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring sm:right-[52px] sm:top-[20px] lg:right-[60px] lg:top-[30px]"
7078 >
7179 {theme === 'dark' ? <Sun size={15} aria-hidden /> : <Moon size={15} aria-hidden />}
@@ -79,9 +87,11 @@
7987 </p>
8088 <p className="flex items-center gap-2.5 font-mono text-[11px] tracking-[0.5px] text-muted-foreground">
8189 <span className="size-[7px] shrink-0 rounded-full bg-success-foreground" aria-hidden />
82 <span>ALL SYSTEMS OPERATIONAL</span>
90 <span>{t('auth.shell.operational').toUpperCase()}</span>
8391 <span className="hidden text-border sm:inline">·</span>
84 <span className="hidden sm:inline">REGION {region}</span>
92 <span className="hidden sm:inline">
93 {t('auth.shell.region', { region }).toUpperCase()}
94 </span>
8595 <span className="text-border">·</span>
8696 <span>© 2026 GitDataAI</span>
8797 </p>
Modified web/src/features/chat/model/use-composer.ts View web/src/features/chat/model/use-composer.ts
@@ -29,6 +29,7 @@
2929 type Attachment,
3030 type UploadItem,
3131} from '@/entities/chat'
32import { t } from '@/shared/i18n'
3233
3334const DRAFT_SAVE_DEBOUNCE_MS = 1_200
3435/** Rows the composer's own draft load must not clobber. */
@@ -164,7 +165,7 @@
164165 // An empty write deletes the draft server-side, which is what "the user
165166 // erased the box" means; skipping it would resurrect the text next visit.
166167 saveDraft(roomId, value, threadId ?? undefined).catch(() => {
167 setDraftNotice('草稿保存失败(消息本身不受影响)')
168 setDraftNotice(t('chat.composer.draftSaveFailed'))
168169 })
169170 },
170171 [enabled, editingId, roomId, threadId],
@@ -276,7 +277,7 @@
276277 file,
277278 progress: 0,
278279 state: 'too_big',
279 error: `超过 ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB 上限`,
280 error: t('chat.composer.overLimit', { mb: Math.round(MAX_UPLOAD_BYTES / 1024 / 1024) }),
280281 })
281282 continue
282283 }
@@ -290,7 +291,7 @@
290291 state: 'checking',
291292 })
292293 }
293 if (overflow) setDraftNotice(`一条消息最多 ${MAX_ATTACHMENTS} 个附件,多出的没有添加`)
294 if (overflow) setDraftNotice(t('chat.composer.attachmentLimit', { max: MAX_ATTACHMENTS }))
294295 if (accepted.length === 0) return
295296 commitUploads(current => [...current, ...accepted])
296297 for (const item of accepted) void runUpload(item)
@@ -342,9 +343,9 @@
342343 )
343344 const failedUploads = uploads.filter(item => item.state === 'failed' || item.state === 'too_big')
344345 const blocked = uploading
345 ? '附件上传中,完成后会自动可发送'
346 ? t('chat.composer.blockedUploading')
346347 : failedUploads.length > 0
347 ? '有附件上传失败,请重试或移除后再发送'
348 ? t('chat.composer.blockedFailed')
348349 : null
349350 const length = textLength(text)
350351 const overLength = length > DRAFT_MAX_LEN
Modified web/src/features/chat/model/use-room-data.ts View web/src/features/chat/model/use-room-data.ts
@@ -24,6 +24,7 @@
2424 type RoomMember,
2525 type ThreadBrief,
2626} from '@/entities/chat'
27import { t } from '@/shared/i18n'
2728
2829export interface RoomData {
2930 /** `GET /chat/rooms/{id}` — the room as the server sees it right now. */
@@ -118,8 +119,8 @@
118119
119120 const nameOf = useCallback(
120121 (userId: string | null | undefined) => {
121 if (!userId) return '未知用户'
122 return users.get(userId) ?? `用户 ${userId.slice(0, 8)}`
122 if (!userId) return t('common.state.unknownUser')
123 return users.get(userId) ?? t('shell.chat.userFallback', { id: userId.slice(0, 8) })
123124 },
124125 [users],
125126 )
Modified web/src/features/chat/model/use-room-stream.ts View web/src/features/chat/model/use-room-stream.ts
@@ -19,6 +19,7 @@
1919 */
2020import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
2121import { chatSeedKey } from '@/shared/lib'
22import { t } from '@/shared/i18n'
2223import { readCache, writeCache } from '@/shared/storage'
2324import {
2425 clientNonce,
@@ -296,7 +297,7 @@
296297 if ('room_id' in issue && issue.room_id !== undefined && issue.room_id !== roomId) return
297298 if (issue.kind === 'server_error' && issue.error === 'forbidden') {
298299 dispatch({ type: 'accessRevoked' })
299 setNotice('你没有权限查看这个频道')
300 setNotice(t('chat.room.accessDenied'))
300301 return
301302 }
302303 if (
@@ -595,7 +596,7 @@
595596 atBottomRef.current = true
596597 setBottom({ scope: scopeKey, value: true, baseline: tipRef.current })
597598 },
598 () => dispatch({ type: 'seedError', message: '无法加载最新消息' }),
599 () => dispatch({ type: 'seedError', message: t('chat.transcript.seedFailed') }),
599600 )
600601 }, [enabled, roomId, scopeKey, threadId])
601602
Modified web/src/features/chat/ui/voice/voice-call-bar.tsx View web/src/features/chat/ui/voice/voice-call-bar.tsx
@@ -21,6 +21,7 @@
2121 Volume2,
2222} from 'lucide-react'
2323import { useVoiceSession, voiceSession } from '@/shared/voice'
24import { useT } from '@/shared/i18n'
2425
2526const POSITION_KEY = 'gitdata.voice.callbar.position'
2627const VIEWPORT_MARGIN = 12
@@ -58,6 +59,7 @@
5859}
5960
6061export function VoiceCallBar() {
62 const { t } = useT()
6163 const session = useVoiceSession()
6264 const barRef = useRef<HTMLDivElement | null>(null)
6365 const dragRef = useRef<{ offsetX: number; offsetY: number } | null>(null)
@@ -126,7 +128,7 @@
126128
127129 // The label and the route are captured at join time; a call keeps working
128130 // from any page, so the bar never depends on the current route params.
129 const roomLabel = session.roomLabel ?? '语音频道'
131 const roomLabel = session.roomLabel ?? t('chat.voice.channelFallback')
130132 const href =
131133 session.roomId && session.spaceName
132134 ? '/app/' + encodeURIComponent(session.spaceName) + '/chat/' + encodeURIComponent(session.roomId)
@@ -146,8 +148,8 @@
146148 .join(' ')}
147149 style={position ? { left: position.x, top: position.y } : undefined}
148150 role="region"
149 aria-label="通话"
150 title="按住空白处可拖动,双击复位"
151 aria-label={t('chat.voice.callAria')}
152 title={t('chat.voice.barDragHint')}
151153 onPointerDown={onPointerDown}
152154 onPointerMove={onPointerMove}
153155 onPointerUp={endDrag}
@@ -173,7 +175,7 @@
173175 onClick={() => void voiceSession.resumeAudio()}
174176 >
175177 <AlertCircle className="h-3.5 w-3.5" />
176 恢复声音
178 {t('chat.voice.restore')}
177179 </button>
178180 ) : null}
179181
@@ -188,7 +190,7 @@
188190 type="button"
189191 className={session.micEnabled ? 'is-on' : 'is-off'}
190192 disabled={!session.canPublish}
191 title={session.canPublish ? '麦克风' : '当前没有发言权'}
193 title={session.canPublish ? t('chat.voice.mic') : t('chat.voice.noPublish')}
192194 onClick={() => void voiceSession.setMicrophone(!session.micEnabled)}
193195 >
194196 {session.micEnabled ? <Mic className="h-4 w-4" /> : <MicOff className="h-4 w-4" />}
@@ -197,7 +199,7 @@
197199 type="button"
198200 className={session.cameraEnabled ? 'is-on' : 'is-off'}
199201 disabled={!session.canPublish}
200 title="摄像头"
202 title={t('chat.voice.camera')}
201203 onClick={() => void voiceSession.setCamera(!session.cameraEnabled)}
202204 >
203205 {session.cameraEnabled ? <Video className="h-4 w-4" /> : <VideoOff className="h-4 w-4" />}
@@ -206,20 +208,20 @@
206208 type="button"
207209 className={session.screenShareEnabled ? 'is-on' : 'is-off'}
208210 disabled={!session.canPublish}
209 title="共享屏幕"
211 title={t('chat.voice.screenShare')}
210212 onClick={() => void voiceSession.setScreenShare(!session.screenShareEnabled)}
211213 >
212214 <MonitorUp className="h-4 w-4" />
213215 </button>
214216 {href ? (
215 <Link className="voice-call-return" to={href} title="回到语音频道">
217 <Link className="voice-call-return" to={href} title={t('chat.voice.return')}>
216218 <Volume2 className="h-4 w-4" />
217219 </Link>
218220 ) : null}
219221 <button
220222 type="button"
221223 className="is-hangup"
222 title="离开通话"
224 title={t('chat.voice.hangUp')}
223225 onClick={() => void voiceSession.leave()}
224226 >
225227 <PhoneOff className="h-4 w-4" />
Modified web/src/features/chat/ui/voice/voice-danmaku-history.tsx View web/src/features/chat/ui/voice/voice-danmaku-history.tsx
@@ -6,6 +6,7 @@
66import { useEffect, useRef } from 'react'
77import { X } from 'lucide-react'
88import type { ChatMessage } from '@/entities/chat'
9import { useT } from '@/shared/i18n'
910import { fmtRelative } from '@/shared/lib/format'
1011
1112export interface VoiceDanmakuHistoryProps {
@@ -33,6 +34,7 @@
3334 onFlyingChange,
3435 onClose,
3536}: VoiceDanmakuHistoryProps) {
37 const { t } = useT()
3638 const ref = useRef<HTMLDivElement | null>(null)
3739
3840 // Clicking anywhere else, or Escape, dismisses it. The toggle button is
@@ -64,15 +66,15 @@
6466 ref={ref}
6567 className="voice-danmaku-history"
6668 role="dialog"
67 aria-label="弹幕记录"
69 aria-label={t('chat.danmaku.title')}
6870 >
6971 <div className="voice-danmaku-history-head">
70 <span className="voice-danmaku-history-title">弹幕记录</span>
71 <span className="voice-badge">{items.length} 条</span>
72 <span className="voice-danmaku-history-title">{t('chat.danmaku.title')}</span>
73 <span className="voice-badge">{t('chat.danmaku.count', { count: items.length })}</span>
7274 <button
7375 type="button"
7476 className="voice-danmaku-history-close"
75 title="关闭"
77 title={t('common.action.close')}
7678 onClick={onClose}
7779 >
7880 <X className="h-3.5 w-3.5" />
@@ -84,11 +86,13 @@
8486 className="voice-danmaku-history-more"
8587 onClick={onLoadOlder}
8688 >
87 加载更早
89 {t('chat.danmaku.loadOlder')}
8890 </button>
8991 ) : null}
9092 <div className="voice-danmaku-history-list">
91 {items.length === 0 ? <p className="voice-hands-empty">还没有弹幕</p> : null}
93 {items.length === 0 ? (
94 <p className="voice-hands-empty">{t('chat.danmaku.empty')}</p>
95 ) : null}
9296 {items.map(message => (
9397 <div key={String(message.id)} className="voice-danmaku-history-row">
9498 <span className="voice-danmaku-history-author">
@@ -105,7 +109,7 @@
105109 checked={flying}
106110 onChange={() => onFlyingChange(!flying)}
107111 />
108 实时飘屏
112 {t('chat.danmaku.flying')}
109113 </label>
110114 </div>
111115 )
Modified web/src/features/chat/ui/voice/voice-danmaku-input.tsx View web/src/features/chat/ui/voice/voice-danmaku-input.tsx
@@ -7,6 +7,7 @@
77import { useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
88import { MessageSquare, Send } from 'lucide-react'
99import { MAX_MESSAGE_LEN } from '@/entities/chat'
10import { useT } from '@/shared/i18n'
1011
1112export interface VoiceDanmakuInputProps {
1213 value: string
@@ -24,6 +25,7 @@
2425 disabled = false,
2526 notice = null,
2627}: VoiceDanmakuInputProps) {
28 const { t } = useT()
2729 const [focused, setFocused] = useState(false)
2830 const open = focused || value.length > 0
2931 const sendable = value.trim().length > 0 && !disabled
@@ -44,8 +46,8 @@
4446 value={value}
4547 disabled={disabled}
4648 maxLength={MAX_MESSAGE_LEN}
47 placeholder="发条弹幕…"
48 aria-label="弹幕"
49 placeholder={t('chat.danmaku.placeholder')}
50 aria-label={t('chat.danmaku.label')}
4951 onChange={event => onChange(event.target.value)}
5052 onFocus={() => setFocused(true)}
5153 onBlur={() => setFocused(false)}
@@ -56,7 +58,7 @@
5658 type="button"
5759 className="voice-danmaku-input-send"
5860 disabled={!sendable}
59 title="发送"
61 title={t('chat.composer.send')}
6062 onClick={() => {
6163 if (sendable) onSend()
6264 }}
Modified web/src/features/chat/ui/voice/voice-room-panel.tsx View web/src/features/chat/ui/voice/voice-room-panel.tsx
@@ -41,6 +41,7 @@
4141 type VoiceParticipant,
4242} from '@/entities/chat'
4343import { useVoiceSession, voiceSession, participantTracks } from '@/shared/voice'
44import { useT } from '@/shared/i18n'
4445import { useWsFrames } from '@/shared/ws'
4546import { RoomEvent, type Track } from 'livekit-client'
4647import type { ChatMessage } from '@/entities/chat'
@@ -175,6 +176,7 @@
175176 viewerId,
176177 canModerate,
177178}: VoiceRoomPanelProps) {
179 const { t } = useT()
178180 const session = useVoiceSession()
179181 const joined = session.roomId === roomId && session.status !== 'idle'
180182 const [joining, setJoining] = useState(false)
@@ -366,7 +368,7 @@
366368 setError(null)
367369 try {
368370 const config = await getVoiceConfig()
369 if (!config.enabled) throw new Error('语音功能未启用')
371 if (!config.enabled) throw new Error(t('chat.voice.disabled'))
370372 const token = await requestVoiceToken(roomId)
371373 await voiceSession.join({
372374 roomId,
@@ -382,11 +384,11 @@
382384 await voiceSession.resumeAudio()
383385 await refreshHands()
384386 } catch (cause) {
385 setError(cause instanceof Error ? cause.message : '加入语音失败')
387 setError(cause instanceof Error ? cause.message : t('chat.voice.joinFailed'))
386388 } finally {
387389 setJoining(false)
388390 }
389 }, [roomId, roomName, spaceName, refreshHands])
391 }, [roomId, roomName, spaceName, refreshHands, t])
390392
391393 const onRaiseHand = useCallback(async () => {
392394 setError(null)
@@ -395,9 +397,9 @@
395397 voiceSession.markHandRaised(true)
396398 await refreshHands()
397399 } catch (cause) {
398 setError(cause instanceof Error ? cause.message : '举手失败')
400 setError(cause instanceof Error ? cause.message : t('chat.voice.handFailed'))
399401 }
400 }, [roomId, refreshHands])
402 }, [roomId, refreshHands, t])
401403
402404 const onCancelHand = useCallback(async () => {
403405 setError(null)
@@ -406,9 +408,9 @@
406408 voiceSession.markHandRaised(false)
407409 await refreshHands()
408410 } catch (cause) {
409 setError(cause instanceof Error ? cause.message : '撤回失败')
411 setError(cause instanceof Error ? cause.message : t('chat.voice.handCancelFailed'))
410412 }
411 }, [roomId, refreshHands])
413 }, [roomId, refreshHands, t])
412414
413415 const decide = useCallback(
414416 async (userId: string, action: 'approve' | 'deny' | 'revoke') => {
@@ -419,10 +421,10 @@
419421 else await revokeVoiceHand(roomId, userId)
420422 await refreshHands()
421423 } catch (cause) {
422 setError(cause instanceof Error ? cause.message : '审批失败')
424 setError(cause instanceof Error ? cause.message : t('chat.voice.handDecideFailed'))
423425 }
424426 },
425 [roomId, refreshHands],
427 [roomId, refreshHands, t],
426428 )
427429
428430 const tiles = useMemo(
@@ -452,7 +454,7 @@
452454 <AlertCircle className="h-3.5 w-3.5 shrink-0" />
453455 <span className="min-w-0 truncate">{session.notice}</span>
454456 <button type="button" onClick={() => voiceSession.clearNotice()}>
455 忽略
457 {t('common.action.ignore')}
456458 </button>
457459 </div>
458460 ) : null}
@@ -461,7 +463,7 @@
461463 <AlertCircle className="h-3.5 w-3.5 shrink-0" />
462464 <span className="min-w-0 truncate">{error}</span>
463465 <button type="button" onClick={() => setError(null)}>
464 忽略
466 {t('common.action.ignore')}
465467 </button>
466468 </div>
467469 ) : null}
@@ -477,7 +479,7 @@
477479 <TrackVideo track={screenSharer.track} muted={screenSharer.identity === viewerId} />
478480 <div className="voice-stage-caption">
479481 <MonitorUp className="h-3.5 w-3.5" />
480 {voiceSession.nameOf(screenSharer.identity)} 正在共享屏幕
482 {t('chat.voice.sharing', { name: voiceSession.nameOf(screenSharer.identity) })}
481483 </div>
482484 </div>
483485 ) : null}
@@ -489,17 +491,15 @@
489491 </div>
490492 <p className="voice-join-title">{roomName}</p>
491493 <p className="voice-join-hint">
492 {isStage
493 ? '这是舞台频道:加入后可收听;想要发言请举手,主持人通过后自动开麦。'
494 : '加入语音后会开启麦克风,可随时静音或关闭摄像头。'}
494 {isStage ? t('chat.voice.stageHint') : t('chat.voice.joinHint')}
495495 </p>
496496 {isStage && myHand === 'pending' ? (
497497 <div className="flex flex-col items-center gap-2">
498498 <span className="voice-hand-pending">
499 <Hand className="h-3.5 w-3.5" /> 已举手,等待主持人通过
499 <Hand className="h-3.5 w-3.5" /> {t('chat.voice.handPending')}
500500 </span>
501501 <button type="button" className="voice-ghost-button" onClick={onCancelHand}>
502 撤回举手
502 {t('chat.voice.cancelHand')}
503503 </button>
504504 </div>
505505 ) : null}
@@ -511,10 +511,10 @@
511511 >
512512 {joining || session.status === 'connecting' ? (
513513 <>
514 <Loader2 className="h-4 w-4 animate-spin" /> 正在加入…
514 <Loader2 className="h-4 w-4 animate-spin" /> {t('chat.voice.joining')}
515515 </>
516516 ) : (
517 '加入语音'
517 t('chat.voice.join')
518518 )}
519519 </button>
520520 </div>
@@ -538,7 +538,7 @@
538538 .filter(Boolean)
539539 .join(' ')}
540540 style={placed ? { left: placed.x, top: placed.y, width: placed.w } : undefined}
541 title={placed ? '拖动可摆位,右下角缩放,双击放回网格' : '按住可拖到任意空位'}
541 title={placed ? t('chat.voice.tilePlaced') : t('chat.voice.tileHint')}
542542 onPointerDown={event => beginTileGesture(event, tile.user_id, 'move')}
543543 onPointerMove={event => moveTileGesture(event, tile.user_id)}
544544 onPointerUp={() => endTileGesture(tile.user_id)}
@@ -568,7 +568,7 @@
568568 <div className="voice-tile-actions">
569569 <button
570570 type="button"
571 title={tile.moderator_muted ? '解除静音' : '强制静音'}
571 title={tile.moderator_muted ? t('chat.voice.forceUnmute') : t('chat.voice.forceMute')}
572572 onClick={() =>
573573 void moderateVoiceMute(roomId, tile.user_id, !tile.moderator_muted)
574574 }
@@ -581,7 +581,7 @@
581581 </button>
582582 <button
583583 type="button"
584 title="移出通话"
584 title={t('chat.voice.kick')}
585585 onClick={() => void kickVoiceParticipant(roomId, tile.user_id)}
586586 >
587587 <LogOut className="h-3.5 w-3.5" />
@@ -601,7 +601,7 @@
601601 )
602602 })}
603603 {tiles.length === 0 ? (
604 <p className="voice-empty">还没有其他人和你一起,邀请成员加入吧。</p>
604 <p className="voice-empty">{t('chat.voice.solo')}</p>
605605 ) : null}
606606 </div>
607607
@@ -609,28 +609,30 @@
609609 <div className="voice-hands">
610610 <div className="voice-hands-head">
611611 <Hand className="h-4 w-4" />
612 <span>发言申请</span>
612 <span>{t('chat.voice.hands')}</span>
613613 {!canModerate ? (
614614 myHand === 'approved' ? (
615615 <span className="voice-badge ok">
616 <ShieldCheck className="h-3 w-3" /> 已获得发言权
616 <ShieldCheck className="h-3 w-3" /> {t('chat.voice.canSpeak')}
617617 </span>
618618 ) : session.hasRaisedHand || myHand === 'pending' ? (
619619 <button type="button" className="voice-ghost-button" onClick={onCancelHand}>
620 撤回举手
620 {t('chat.voice.cancelHand')}
621621 </button>
622622 ) : (
623623 <button type="button" className="voice-ghost-button" onClick={onRaiseHand}>
624 举手申请发言
624 {t('chat.voice.raiseHand')}
625625 </button>
626626 )
627627 ) : (
628 <span className="voice-badge">{pendingHands.length} 待处理</span>
628 <span className="voice-badge">
629 {t('chat.voice.pendingCount', { count: pendingHands.length })}
630 </span>
629631 )}
630632 </div>
631633 {canModerate ? (
632634 pendingHands.length === 0 ? (
633 <p className="voice-hands-empty">暂无待处理申请</p>
635 <p className="voice-hands-empty">{t('chat.voice.noHands')}</p>
634636 ) : (
635637 <ul className="voice-hands-list">
636638 {pendingHands.map(hand => (
@@ -642,14 +644,14 @@
642644 className="voice-approve"
643645 onClick={() => void decide(hand.user_id, 'approve')}
644646 >
645 通过
647 {t('common.action.approve')}
646648 </button>
647649 <button
648650 type="button"
649651 className="voice-deny"
650652 onClick={() => void decide(hand.user_id, 'deny')}
651653 >
652 拒绝
654 {t('common.action.reject')}
653655 </button>
654656 </span>
655657 </li>
@@ -661,14 +663,16 @@
661663 <ul className="voice-hands-list">
662664 {approvedHands.map(hand => (
663665 <li key={hand.user_id}>
664 <span className="truncate">{hand.display_name} · 发言中</span>
666 <span className="truncate">
667 {t('chat.voice.speaking', { name: hand.display_name })}
668 </span>
665669 <span className="voice-hands-actions">
666670 <button
667671 type="button"
668672 className="voice-deny"
669673 onClick={() => void decide(hand.user_id, 'revoke')}
670674 >
671 收回
675 {t('chat.voice.revoke')}
672676 </button>
673677 </span>
674678 </li>
@@ -698,7 +702,7 @@
698702 type="button"
699703 className={['voice-control', session.micEnabled ? '' : 'is-off'].join(' ')}
700704 disabled={!session.canPublish}
701 title={session.canPublish ? '麦克风' : '当前没有发言权'}
705 title={session.canPublish ? t('chat.voice.mic') : t('chat.voice.noPublish')}
702706 onClick={() => void voiceSession.setMicrophone(!session.micEnabled)}
703707 >
704708 {session.micEnabled ? <Mic className="h-4 w-4" /> : <MicOff className="h-4 w-4" />}
@@ -707,7 +711,7 @@
707711 type="button"
708712 className={['voice-control', session.cameraEnabled ? '' : 'is-off'].join(' ')}
709713 disabled={!session.canPublish}
710 title="摄像头"
714 title={t('chat.voice.camera')}
711715 onClick={() => void voiceSession.setCamera(!session.cameraEnabled)}
712716 >
713717 {session.cameraEnabled ? <Video className="h-4 w-4" /> : <VideoOff className="h-4 w-4" />}
@@ -716,7 +720,7 @@
716720 type="button"
717721 className={['voice-control', session.screenShareEnabled ? '' : 'is-off'].join(' ')}
718722 disabled={!session.canPublish}
719 title="共享屏幕"
723 title={t('chat.voice.screenShare')}
720724 onClick={() => void voiceSession.setScreenShare(!session.screenShareEnabled)}
721725 >
722726 <MonitorUp className="h-4 w-4" />
@@ -725,7 +729,7 @@
725729 type="button"
726730 data-danmaku-toggle="true"
727731 className={['voice-control', historyOpen ? 'is-active' : ''].join(' ')}
728 title="弹幕记录"
732 title={t('chat.danmaku.title')}
729733 aria-expanded={historyOpen}
730734 onClick={() => setHistoryOpen(value => !value)}
731735 >
@@ -735,7 +739,7 @@
735739 <button
736740 type="button"
737741 className="voice-control danger"
738 title="离开通话"
742 title={t('chat.voice.hangUp')}
739743 onClick={() => void voiceSession.leave()}
740744 >
741745 <LogOut className="h-4 w-4" />
Modified web/src/features/chat/ui/chat-composer.tsx View web/src/features/chat/ui/chat-composer.tsx
@@ -34,6 +34,7 @@
3434 type CustomEmoji,
3535 type RoomMember,
3636} from '@/entities/chat'
37import { useT } from '@/shared/i18n'
3738import { Popover } from './parts'
3839import { EmojiPicker } from './emoji-picker'
3940import type { ComposerState } from '../model/use-composer'
@@ -81,6 +82,7 @@
8182 onTyping,
8283 onSubmit,
8384}: ComposerProps) {
85 const { t } = useT()
8486 const boxRef = useRef<HTMLTextAreaElement | null>(null)
8587 const helpId = useId()
8688 const [emojiOpen, setEmojiOpen] = useState(false)
@@ -243,17 +245,17 @@
243245 {editing ? (
244246 <ContextBar
245247 icon={<span className="text-[11px]">✎</span>}
246 label="编辑消息"
248 label={t('chat.composer.editLabel')}
247249 preview={editing.content}
248 hint="Esc 取消"
250 hint={t('chat.composer.escCancel')}
249251 onCancel={onCancelEdit}
250252 />
251253 ) : replyTo ? (
252254 <ContextBar
253255 icon={<AtSign className="h-3.5 w-3.5" />}
254 label={`回复 ${replyTo.author?.display_name || replyTo.author?.username || '某条消息'}`}
256 label={t('chat.composer.replyLabel', { author: replyTo.author?.display_name || replyTo.author?.username || t('chat.composer.someMessage') })}
255257 preview={replyTo.content}
256 hint="Esc 取消"
258 hint={t('chat.composer.escCancel')}
257259 onCancel={onCancelReply}
258260 />
259261 ) : null}
@@ -276,14 +278,14 @@
276278 {item.state === 'ready'
277279 ? formatSize(item.file.size)
278280 : item.state === 'failed' || item.state === 'too_big'
279 ? (item.error ?? '失败')
280 : '上传中…'}
281 ? (item.error ?? t('chat.composer.uploadFailedShort'))
282 : t('chat.composer.uploading')}
281283 </p>
282284 </div>
283285 {item.state === 'failed' ? (
284286 <button
285287 type="button"
286 aria-label="重试上传"
288 aria-label={t('chat.composer.retryUpload')}
287289 onClick={() => composer.retryUpload(item.localId)}
288290 className="grid h-5 w-5 place-content-center rounded bg-[#e5484d]/12 text-[#e5484d]"
289291 >
@@ -298,7 +300,7 @@
298300 )}
299301 <button
300302 type="button"
301 aria-label={`移除 ${item.file.name}`}
303 aria-label={t('chat.composer.removeFile', { name: item.file.name })}
302304 onClick={() => composer.removeUpload(item.localId)}
303305 className="absolute right-0.5 top-0.5 grid h-4 w-4 place-content-center rounded bg-shell-surface/90 text-shell-ink-muted hover:text-shell-ink"
304306 >
@@ -315,8 +317,8 @@
315317 ref={boxRef}
316318 value={text}
317319 rows={1}
318 placeholder={placeholder ?? `发消息给 ${roomLabel},分享你的想法…`}
319 aria-label="消息输入框"
320 placeholder={placeholder ?? t('chat.composer.entryHint', { room: roomLabel })}
321 aria-label={t('chat.composer.inputAria')}
320322 aria-describedby={helpId}
321323 spellCheck
322324 onChange={event => {
@@ -332,7 +334,7 @@
332334 />
333335 <Popover open={Boolean(mention) && candidates.length > 0} onClose={() => setMention(null)} className="w-64 p-1">
334336 <p className="px-2 py-1 text-[10.5px] font-bold uppercase tracking-wide text-shell-ink-muted">
335 提及成员
337 {t('chat.composer.mentionMembers')}
336338 </p>
337339 {candidates.map((member, index) => (
338340 <button
@@ -362,11 +364,11 @@
362364 </div>
363365 </div>
364366 <div className="chat-composer-toolbar">
365 <label className="chat-composer-tool" title="添加附件(也可以拖入或粘贴文件)">
367 <label className="chat-composer-tool" title={t('chat.composer.addAttachmentHint')}>
366368 <input
367369 type="file"
368370 multiple
369 aria-label="添加附件"
371 aria-label={t('chat.composer.addAttachment')}
370372 className="sr-only"
371373 onChange={event => {
372374 const files = [...(event.target.files ?? [])]
@@ -379,8 +381,8 @@
379381 <div className="relative shrink-0">
380382 <button
381383 type="button"
382 aria-label="插入表情"
383 title="插入表情"
384 aria-label={t('chat.composer.insertEmoji')}
385 title={t('chat.composer.insertEmoji')}
384386 aria-expanded={emojiOpen}
385387 onClick={() => setEmojiOpen(value => !value)}
386388 className="chat-composer-tool"
@@ -392,20 +394,20 @@
392394 </Popover>
393395 </div>
394396 <p id={helpId} className="chat-composer-help">
395 <span><kbd>Enter</kbd> 发送</span>
396 <span><kbd>Shift + Enter</kbd> 换行</span>
397 <span><kbd>Enter</kbd> {t('chat.composer.enterSends')}</span>
398 <span><kbd>Shift + Enter</kbd> {t('chat.composer.shiftEnterNewline')}</span>
397399 </p>
398400 <button
399401 type="button"
400 aria-label="发送"
402 aria-label={t('chat.composer.sendAria')}
401403 title={
402404 slowModeRemaining > 0
403 ? `慢速模式,${slowModeRemaining}s 后可发送`
405 ? t('chat.composer.slowModeWait', { seconds: slowModeRemaining })
404406 : blocked
405407 ? blocked
406408 : over
407 ? `超出 ${length - MAX_MESSAGE_LEN} 字`
408 : '发送(Enter)'
409 ? t('chat.composer.tooLongBy', { count: length - MAX_MESSAGE_LEN })
410 : t('chat.composer.sendWithEnter')
409411 }
410412 disabled={!canSend}
411413 onClick={submit}
@@ -416,7 +418,7 @@
416418 ) : (
417419 <Send className="h-4 w-4" />
418420 )}
419 <span>{editing ? '保存' : '发送'}</span>
421 <span>{editing ? t('chat.composer.save') : t('chat.composer.sendAria')}</span>
420422 </button>
421423 </div>
422424
@@ -425,13 +427,13 @@
425427 {over ? (
426428 <span className="flex items-center gap-1 text-[#e5484d]">
427429 <TriangleAlert className="h-3 w-3" />
428 超出 {length - MAX_MESSAGE_LEN} 字(上限 {MAX_MESSAGE_LEN})
430 {t('chat.composer.tooLongDetail', { over: length - MAX_MESSAGE_LEN, max: MAX_MESSAGE_LEN })}
429431 </span>
430432 ) : null}
431433 {blocked ? <span className="text-shell-ink-muted">{blocked}</span> : null}
432434 {composer.draftNotice ? <span className="text-shell-ink-muted">{composer.draftNotice}</span> : null}
433435 {slowModeSeconds > 0 && length === 0 ? (
434 <span className="text-shell-ink-muted">此频道开启慢速模式:每 {slowModeSeconds}s 可发一条</span>
436 <span className="text-shell-ink-muted">{t('chat.composer.slowModeDetail', { seconds: slowModeSeconds })}</span>
435437 ) : null}
436438 </div>
437439 )}
@@ -452,18 +454,19 @@
452454 hint: string
453455 onCancel?: () => void
454456}) {
457 const { t } = useT()
455458 return (
456459 <div className="flex items-center gap-2 rounded-t-lg border-b border-shell-hairline bg-shell-surface px-3 py-1.5">
457460 {icon}
458461 <span className="shrink-0 text-[12px] font-semibold">{label}</span>
459462 <span className="min-w-0 flex-1 truncate text-[12px] text-shell-ink-muted">
460 {preview || '[附件]'}
463 {preview || t('chat.composer.attachmentPlaceholder')}
461464 </span>
462465 <span className="hidden shrink-0 text-[11px] text-shell-ink-muted lg:inline">{hint}</span>
463466 {onCancel ? (
464467 <button
465468 type="button"
466 aria-label="取消"
469 aria-label={t('common.action.cancel')}
467470 onClick={onCancel}
468471 className="shrink-0 text-shell-ink-muted hover:text-shell-ink"
469472 >
Modified web/src/features/chat/ui/chat-room.tsx View web/src/features/chat/ui/chat-room.tsx
@@ -56,6 +56,7 @@
5656 type UserInfo,
5757} from '@/entities/chat'
5858import { FormModal } from '@/shared/ui'
59import { useT } from '@/shared/i18n'
5960import { getRealtimeHub, sendTypingSignal } from '@/shared/ws'
6061import { useAsync } from '@/shared/lib'
6162import { Transcript, type MessageInteractions } from './transcript'
@@ -118,6 +119,7 @@
118119 onDmOpened,
119120 onOpenRemoteMessage,
120121}: ChatRoomProps) {
122 const { t } = useT()
121123 const [version, setVersion] = useState(0)
122124 const [flash, setFlash] = useState<string | null>(null)
123125 useEffect(() => {
@@ -227,14 +229,18 @@
227229
228230 const nameOf = useCallback(
229231 (userId: string | null | undefined) => {
230 if (!userId) return '未知用户'
231 if (userId === viewerId) return '我'
232 if (!userId) return t('common.state.unknownUser')
233 if (userId === viewerId) return t('shell.chat.me')
232234 const member = (data.members.data ?? []).find(row => row.user.user_id === userId)
233235 if (member) return member.nick || member.user.display_name || member.user.username
234236 const author = stream.state.rows.find(row => row.author_id === userId)?.author
235 return author?.display_name || author?.username || `用户 ${userId.slice(0, 8)}`
237 return (
238 author?.display_name ||
239 author?.username ||
240 t('shell.chat.userFallback', { id: userId.slice(0, 8) })
241 )
236242 },
237 [data.members.data, stream.state.rows, viewerId],
243 [data.members.data, stream.state.rows, t, viewerId],
238244 )
239245
240246 const openThread = useCallback(
@@ -290,8 +296,8 @@
290296 /* --------------------------------------------------------------- header */
291297
292298 const title = dm
293 ? hint?.peer?.display_name || hint?.peer?.username || room?.name || '私信'
294 : room?.name || hint?.name || '频道'
299 ? hint?.peer?.display_name || hint?.peer?.username || room?.name || t('shell.chat.dm')
300 : room?.name || hint?.name || t('shell.chat.channel')
295301 const topic = room?.topic ?? hint?.topic ?? undefined
296302 // The room itself is unreachable: anything below it would be an empty
297303 // transcript pretending to be a quiet channel, so replace the column instead.
@@ -354,7 +360,7 @@
354360 {room?.is_private ? <Lock className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" /> : null}
355361 {slowModeSeconds > 0 ? (
356362 <span
357 title={`慢速模式:每 ${slowModeSeconds} 秒可发一条`}
363 title={t('chat.room.slowModeTitle', { seconds: slowModeSeconds })}
358364 className="shrink-0 rounded bg-shell-field px-1.5 py-px text-[10.5px] text-shell-ink-muted"
359365 >
360366 {slowModeSeconds}s
@@ -362,13 +368,13 @@
362368 ) : null}
363369 </div>
364370 <p className="chat-topic" title={topic || undefined}>
365 {topic || (dm ? '每一段交流,都从一句问候开始' : '在这里分享想法,一起把事情做好')}
371 {topic || (dm ? t('chat.room.dmTopicPlaceholder') : t('chat.room.topicPlaceholder'))}
366372 </p>
367373 </div>
368374 <div className="chat-header-actions">
369375 {voice ? (
370376 <IconAction
371 label={voicePane === 'call' ? '文字记录' : '通话视图'}
377 label={voicePane === 'call' ? t('chat.room.transcriptPane') : t('chat.room.callPane')}
372378 active={voicePane === 'text'}
373379 onClick={() => setVoicePane(value => (value === 'call' ? 'text' : 'call'))}
374380 >
@@ -378,62 +384,62 @@
378384 <Phone className="h-[17px] w-[17px]" />
379385 )}
380386 <span className="chat-action-label">
381 {voicePane === 'call' ? '文字记录' : '通话视图'}
387 {voicePane === 'call' ? t('chat.room.transcriptPane') : t('chat.room.callPane')}
382388 </span>
383389 </IconAction>
384390 ) : null}
385391 <IconAction
386 label="搜索消息"
392 label={t('chat.search.placeholder')}
387393 className="chat-search-action"
388394 active={state.panel === 'search'}
389395 onClick={() => onStateChange({ panel: state.panel === 'search' ? null : 'search' })}
390396 >
391397 <Search className="h-[17px] w-[17px]" />
392 <span className="chat-action-label">搜索消息</span>
398 <span className="chat-action-label">{t('chat.search.placeholder')}</span>
393399 </IconAction>
394400 <IconAction
395 label="置顶消息"
401 label={t('chat.room.pins')}
396402 active={state.panel === 'pins'}
397403 onClick={() => onStateChange({ panel: state.panel === 'pins' ? null : 'pins' })}
398404 >
399405 <Pin className="h-[17px] w-[17px]" />
400406 </IconAction>
401407 <IconAction
402 label="话题列表"
408 label={t('chat.room.threads')}
403409 active={state.panel === 'threads'}
404410 onClick={() => onStateChange({ panel: state.panel === 'threads' ? null : 'threads' })}
405411 >
406412 <MessageSquare className="h-[17px] w-[17px]" />
407413 </IconAction>
408414 <IconAction
409 label={memberCount > 0 ? `频道成员 · ${memberCount} 人` : '频道成员'}
415 label={memberCount > 0 ? t('chat.room.membersWithCount', { count: memberCount }) : t('chat.room.members')}
410416 active={state.panel === 'members'}
411417 onClick={() => onStateChange({ panel: state.panel === 'members' ? null : 'members' })}
412418 >
413419 <Users className="h-[17px] w-[17px]" />
414420 </IconAction>
415421 <div className="relative">
416 <IconAction label="频道选项" active={menu} onClick={() => setMenu(value => !value)}>
422 <IconAction label={t('chat.room.options')} active={menu} onClick={() => setMenu(value => !value)}>
417423 <Settings2 className="h-[17px] w-[17px]" />
418424 </IconAction>
419425 <Popover open={menu} onClose={() => setMenu(false)} align="end" side="bottom" className="w-52 py-1">
420426 <MenuRow
421427 icon={room?.muted ? <BellOff className="h-3.5 w-3.5" /> : <Bell className="h-3.5 w-3.5" />}
422 label={room?.muted ? '取消静音' : '静音此频道'}
428 label={room?.muted ? t('chat.room.unmute') : t('chat.room.mute')}
423429 onClick={() =>
424430 write(() => updateRoomSetting(roomId, { muted: !room?.muted }))
425431 }
426432 />
427433 <MenuRow
428434 icon={<Star className="h-3.5 w-3.5" />}
429 label={room?.favorite ? '取消收藏' : '收藏此频道'}
435 label={room?.favorite ? t('chat.room.unfavorite') : t('chat.room.favorite')}
430436 onClick={() =>
431437 write(() => updateRoomSetting(roomId, { favorite: !room?.favorite }))
432438 }
433439 />
434440 <MenuRow
435441 icon={<CalendarX className="h-3.5 w-3.5" />}
436 label="清空草稿"
442 label={t('chat.room.clearDraft')}
437443 onClick={() => {
438444 setMenu(false)
439445 // The composer owns the draft write: resetting it also deletes
@@ -445,7 +451,7 @@
445451 {dm ? (
446452 <MenuRow
447453 icon={<LogOut className="h-3.5 w-3.5" />}
448 label="关闭此会话"
454 label={t('chat.room.closeDm')}
449455 danger
450456 onClick={() => write(() => closeDmApi(roomId).then(() => onRoomGone('dm_closed')))}
451457 />
@@ -453,7 +459,7 @@
453459 <>
454460 <MenuRow
455461 icon={<Settings2 className="h-3.5 w-3.5" />}
456 label={canModerateRoom ? '频道设置' : '频道信息'}
462 label={canModerateRoom ? t('chat.room.settings') : t('chat.room.info')}
457463 onClick={() => {
458464 setMenu(false)
459465 setSettingsOpen(true)
@@ -461,7 +467,7 @@
461467 />
462468 <MenuRow
463469 icon={<LogOut className="h-3.5 w-3.5" />}
464 label={isOwner ? '频道由你拥有' : '离开频道'}
470 label={isOwner ? t('chat.room.ownedByViewer') : t('chat.room.leave')}
465471 disabled={isOwner}
466472 danger
467473 onClick={() => write(() => leaveRoomApi(roomId).then(() => onRoomGone('left')))}
@@ -493,7 +499,7 @@
493499 }}
494500 className="shrink-0 underline"
495501 >
496 忽略
502 {t('common.action.ignore')}
497503 </button>
498504 </div>
499505 ) : null}
@@ -520,7 +526,9 @@
520526 disabled={stream.state.accessRevoked}
521527 notice={
522528 composer.blocked ??
523 (stream.slowModeRemaining > 0 ? '慢速模式 ' + stream.slowModeRemaining + 's' : null)
529 (stream.slowModeRemaining > 0
530 ? t('chat.room.slowModeShort', { seconds: stream.slowModeRemaining })
531 : null)
524532 }
525533 onSend={() => {
526534 void composer.submit().then(ok => {
@@ -621,7 +629,7 @@
621629 )}
622630 </section>
623631 {state.panel && !roomGone ? (
624 <aside className="chat-side-panel flex w-[320px] shrink-0 flex-col border-l border-shell-hairline bg-shell-sidebar xl:w-[380px]" aria-label="频道详情">
632 <aside className="chat-side-panel flex w-[320px] shrink-0 flex-col border-l border-shell-hairline bg-shell-sidebar xl:w-[380px]" aria-label={t('chat.room.details')}>
625633 {state.panel === 'thread' ? (
626634 thread ? (
627635 <ThreadPanel
@@ -735,15 +743,16 @@
735743}
736744
737745function ConnectionBanner({ status, onRetry }: { status: string; onRetry: () => void }) {
746 const { t } = useT()
738747 if (status === 'open') return null
739748 const text =
740749 status === 'auth_required'
741 ? '实时连接需要重新登录'
750 ? t('chat.connection.authRequired')
742751 : status === 'failed'
743 ? '实时连接已停止,消息不会自动更新'
752 ? t('chat.connection.stopped')
744753 : status === 'connecting' || status === 'reconnecting'
745 ? '正在连接实时通道…'
746 : '实时通道已断开,消息不会自动更新'
754 ? t('chat.connection.connecting')
755 : t('chat.connection.closed')
747756 return (
748757 <div className="flex h-7 shrink-0 items-center gap-2 border-b border-shell-hairline bg-[#f0b232]/18 px-4 text-[11.5px] text-[#804200]">
749758 <WifiOff className="h-3.5 w-3.5 shrink-0" />
@@ -754,32 +763,34 @@
754763 className="flex shrink-0 items-center gap-1 rounded border border-[#804200]/30 px-1.5 py-px hover:bg-[#f0b232]/25"
755764 >
756765 <RefreshCw className="h-3 w-3" />
757 重连
766 {t('chat.connection.retry')}
758767 </button>
759768 </div>
760769 )
761770}
762771
763772function RoomGone({ code }: { code?: number }) {
773 const { t } = useT()
764774 return (
765775 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
766776 <Lock className="h-6 w-6 text-shell-ink-muted" />
767777 <p className="text-[14px] font-semibold">
768 {code === 403 ? '你没有权限查看这个频道' : '这个频道不存在或已被删除'}
778 {code === 403 ? t('chat.room.accessDenied') : t('chat.room.gone')}
769779 </p>
770780 <p className="max-w-sm text-[12.5px] leading-[1.6] text-shell-ink-muted">
771 链接可能已经过期,或者频道的所有者把它删掉了。从左侧列表选择其他频道。
781 {t('chat.room.goneHint')}
772782 </p>
773783 </div>
774784 )
775785}
776786
777787function ThreadMissing({ onClose }: { onClose: () => void }) {
788 const { t } = useT()
778789 return (
779790 <>
780 <PanelHeader title="话题" subtitle="未找到" onClose={onClose} />
791 <PanelHeader title={t('chat.thread.title')} subtitle={t('chat.thread.missing')} onClose={onClose} />
781792 <div className="p-4 text-[12.5px] leading-[1.6] text-shell-ink-muted">
782 这个话题可能已被删除,或仍在加载中。关闭后可以从「话题列表」重新进入。
793 {t('chat.thread.missingHint')}
783794 </div>
784795 </>
785796 )
@@ -835,6 +846,7 @@
835846 onChanged: () => void
836847 onDeleted: () => void
837848}) {
849 const { t } = useT()
838850 const [armed, setArmed] = useState(false)
839851 // Gated on `open` rather than reset by an effect: closing the dialog must be
840852 // enough to disarm the destructive step.
@@ -842,49 +854,49 @@
842854 return (
843855 <FormModal
844856 open={open}
845 title={dm ? '会话设置' : '频道设置'}
857 title={dm ? t('chat.room.dmSettings') : t('chat.room.settings')}
846858 description={
847859 dm
848 ? '私信会话只能改名以外的少量设置;关闭会话会从列表中移除。'
860 ? t('chat.room.settingsNoteDm')
849861 : canManage
850 ? '这些字段写入房间本身,所有成员都会看到变化。'
851 : '你只有读取权限,以下设置为只读。'
862 ? t('chat.room.settingsNoteManage')
863 : t('chat.room.settingsNoteView')
852864 }
853865 wide
854 confirmLabel={canManage ? '保存' : '关闭'}
866 confirmLabel={canManage ? t('common.action.save') : t('common.action.close')}
855867 fields={[
856868 {
857869 key: 'name',
858 label: '频道名称',
870 label: t('chat.rooms.name'),
859871 kind: 'text',
860872 optional: false,
861873 initialValue: room?.name ?? hintName,
862874 maxLength: 128,
863 hint: '小写字母、数字与连字符最稳妥',
875 hint: t('chat.room.nameHint'),
864876 },
865877 {
866878 key: 'topic',
867 label: '主题',
879 label: t('chat.rooms.topic'),
868880 kind: 'textarea',
869881 optional: true,
870882 initialValue: room?.topic ?? hintTopic ?? '',
871883 maxLength: 1024,
872 hint: '显示在标题栏,最多 1024 字符',
884 hint: t('chat.room.topicHint'),
873885 rows: 3,
874886 },
875887 {
876888 key: 'is_private',
877 label: '私密频道(仅成员可读)',
889 label: t('chat.room.private'),
878890 kind: 'toggle',
879891 initialValue: room?.is_private ?? false,
880892 },
881893 {
882894 key: 'rate_limit',
883 label: '慢速模式(秒)',
895 label: t('chat.room.rateLimit'),
884896 kind: 'number',
885897 optional: true,
886898 initialValue: String(room?.rate_limit_per_user ?? 0),
887 hint: '0 表示关闭;成员两条消息之间的最短间隔',
899 hint: t('chat.room.rateLimitHint'),
888900 },
889901 ]}
890902 onSubmit={async values => {
@@ -899,28 +911,28 @@
899911 rate_limit_per_user: Number.isFinite(rate) && rate > 0 ? rate : null,
900912 })
901913 onChanged()
902 return '已保存'
914 return t('chat.room.saved')
903915 }}
904916 onClose={onClose}
905917 extra={
906918 !dm && canManage ? (
907919 confirmDelete ? (
908920 <div className="rounded-md border-2 border-[#e5484d]/40 bg-[#e5484d]/8 p-2.5 text-[12px] leading-[1.6]">
909 删除后频道不再对外可见,消息也不会恢复。
921 {t('chat.room.deleteNote')}
910922 <div className="mt-2 flex gap-2">
911923 <button
912924 type="button"
913925 onClick={() => void deleteRoomApi(room?.id ?? '').then(() => onDeleted())}
914926 className="rounded bg-[#e5484d] px-2.5 py-1 text-[12px] font-semibold text-white"
915927 >
916 确认删除频道
928 {t('chat.room.deleteConfirm')}
917929 </button>
918930 <button
919931 type="button"
920932 onClick={() => setArmed(false)}
921933 className="rounded px-2 py-1 text-[12px] hover:bg-shell-field"
922934 >
923 取消
935 {t('common.action.cancel')}
924936 </button>
925937 </div>
926938 </div>
@@ -930,7 +942,7 @@
930942 onClick={() => setArmed(true)}
931943 className="rounded-md border-2 border-shell-hairline px-2.5 py-1 text-[12px] text-[#e5484d] hover:border-[#e5484d]/50"
932944 >
933 删除此频道
945 {t('chat.room.deleteAction')}
934946 </button>
935947 )
936948 ) : null
Modified web/src/features/chat/ui/emoji-picker.tsx View web/src/features/chat/ui/emoji-picker.tsx
@@ -9,22 +9,23 @@
99import { useState } from 'react'
1010import { QUICK_EMOJIS, type CustomEmoji } from '@/entities/chat'
1111import { emojiImageUrl } from '@/entities/chat'
12import { t } from '@/shared/i18n'
1213
1314const GROUPS: Array<{ label: string; emojis: string[] }> = [
14 { label: '常用', emojis: QUICK_EMOJIS },
15 { get label() { return t('chat.emoji.common') }, emojis: QUICK_EMOJIS },
1516 {
16 label: '表情',
17 get label() { return t('chat.composer.emoji') },
1718 emojis: [
1819 '😀', '😃', '😉', '😊', '😍', '🤔', '😎', '🥳', '😅', '😂', '🤣', '😇',
1920 '🙂', '🙃', '😏', '😌', '😴', '🤯', '😱', '😭', '😡', '🥺', '😳', '🤗',
2021 ],
2122 },
2223 {
23 label: '手势',
24 get label() { return t('chat.emoji.gestures') },
2425 emojis: ['👍', '👎', '👏', '🙌', '🤝', '🙏', '💪', '✍️', '👀', '🧠', '🫡', '✌️', '☝️', '🤞'],
2526 },
2627 {
27 label: '符号',
28 get label() { return t('chat.emoji.symbols') },
2829 emojis: ['🎉', '🔥', '✨', '⭐', '✅', '❌', '⚠️', '🚀', '💡', '🐛', '🔧', '📌', '📎', '☕'],
2930 },
3031]
@@ -66,7 +67,7 @@
6667 isCustom ? 'bg-shell-active text-shell-ink' : 'text-shell-ink-muted hover:bg-shell-field'
6768 }`}
6869 >
69 社区
70 {t('chat.emoji.custom')}
7071 </button>
7172 ) : null}
7273 </div>
Modified web/src/features/chat/ui/parts.tsx View web/src/features/chat/ui/parts.tsx
@@ -24,6 +24,7 @@
2424 type ChatMessage,
2525 type UserInfo,
2626} from '@/entities/chat'
27import { useT } from '@/shared/i18n'
2728
2829const avatarPalette = ['#b77945', '#448675', '#b6638d', '#6871c9', '#b96369', '#4f8a90', '#9272bb']
2930
@@ -202,11 +203,12 @@
202203
203204/** The「未读」rule: everything under it is what the viewer has not seen yet. */
204205export function UnreadDivider({ count }: { count: number }) {
206 const { t } = useT()
205207 return (
206208 <div className="flex items-center gap-3 px-3 py-2">
207209 <div className="h-px flex-1 bg-[#e5484d]/45" />
208210 <span className="shrink-0 rounded-full bg-[#e5484d] px-2 py-px text-[10.5px] font-bold text-white">
209 {count > 0 ? `${count} 条未读` : '未读'}
211 {count > 0 ? t('chat.unread.count', { count }) : t('chat.unread.label')}
210212 </span>
211213 <div className="h-px flex-1 bg-[#e5484d]/45" />
212214 </div>
@@ -440,6 +442,7 @@
440442 attachment: Attachment
441443 onOpen: (attachment: Attachment) => void
442444}) {
445 const { t } = useT()
443446 const kind = attachmentKind(attachment)
444447 if (kind === 'video' || kind === 'audio') {
445448 return (
@@ -457,13 +460,13 @@
457460 <div className="min-w-0 flex-1">
458461 <p className="truncate text-[13px] font-medium">{attachment.filename}</p>
459462 <p className="text-[11px] text-shell-ink-muted">
460 {formatSize(attachment.size)} · {attachment.content_type || '未知类型'}
463 {formatSize(attachment.size)} · {attachment.content_type || t('chat.attachment.unknownType')}
461464 </p>
462465 </div>
463 <IconAction label="下载附件" onClick={() => downloadAttachment(attachment)}>
466 <IconAction label={t('chat.attachment.download')} onClick={() => downloadAttachment(attachment)}>
464467 <Download className="h-4 w-4" />
465468 </IconAction>
466 <IconAction label="预览" onClick={() => onOpen(attachment)}>
469 <IconAction label={t('chat.attachment.preview')} onClick={() => onOpen(attachment)}>
467470 <Maximize2 className="h-4 w-4" />
468471 </IconAction>
469472 </div>
@@ -482,6 +485,7 @@
482485 attachment: Attachment | null
483486 onClose: () => void
484487}) {
488 const { t } = useT()
485489 useEffect(() => {
486490 if (!attachment) return
487491 const onKey = (event: KeyboardEvent) => {
@@ -502,7 +506,7 @@
502506 <span className="shrink-0 text-[12px] text-white/60">{formatSize(attachment.size)}</span>
503507 <button
504508 type="button"
505 aria-label="关闭预览"
509 aria-label={t('chat.attachment.closePreview')}
506510 onClick={onClose}
507511 className="shrink-0 rounded p-1 hover:bg-white/15"
508512 >
@@ -537,7 +541,7 @@
537541 }}
538542 className="rounded-md bg-white/15 px-3 py-1.5 text-[13px] text-white hover:bg-white/25"
539543 >
540 下载
544 {t('common.action.download')}
541545 </button>
542546 </div>
543547 )
Modified web/src/features/chat/ui/side-panel.tsx View web/src/features/chat/ui/side-panel.tsx
@@ -42,6 +42,7 @@
4242import { Transcript, type MessageInteractions } from './transcript'
4343import { ChatComposer } from './chat-composer'
4444import { AttachmentGrid, MessageBody, UserAvatar } from './parts'
45import { useT } from '@/shared/i18n'
4546import { useComposer } from '../model/use-composer'
4647import { useRoomStream } from '../model/use-room-stream'
4748
@@ -56,6 +57,7 @@
5657 onClose: () => void
5758 actions?: React.ReactNode
5859}) {
60 const { t } = useT()
5961 return (
6062 <div className="chat-panel-header flex shrink-0 items-center gap-2 border-b border-shell-hairline px-3">
6163 <div className="min-w-0 flex-1">
@@ -65,7 +67,7 @@
6567 {actions}
6668 <button
6769 type="button"
68 aria-label="关闭面板"
70 aria-label={t('chat.panel.close')}
6971 onClick={onClose}
7072 className="chat-panel-close shrink-0 text-shell-ink-muted transition-colors hover:text-shell-ink"
7173 >
@@ -106,10 +108,11 @@
106108 onClose,
107109 onChanged,
108110}: ThreadPanelProps) {
111 const { t } = useT()
109112 const readOnly = thread.locked
110 ? '这个话题已被锁定,无法继续回复'
113 ? t('chat.thread.lockedNotice')
111114 : thread.archived
112 ? '这个话题已归档,只能查看'
115 ? t('chat.thread.archivedReadOnly')
113116 : null
114117 const stream = useRoomStream({
115118 roomId,
@@ -139,8 +142,11 @@
139142 return (
140143 <>
141144 <PanelHeader
142 title={thread.name || `${nameOf(thread.owner_id)} 的话题`}
143 subtitle={`${thread.reply_count} 条回复 · ${thread.participant_count} 人参与`}
145 title={thread.name || t('chat.thread.byOwner', { name: nameOf(thread.owner_id) })}
146 subtitle={t('chat.thread.summary', {
147 replies: thread.reply_count,
148 participants: thread.participant_count,
149 })}
144150 onClose={onClose}
145151 actions={
146152 <ThreadTools
@@ -187,14 +193,14 @@
187193 retryPending={stream.retryPending}
188194 dismissPending={stream.dismissPending}
189195 typingNames={stream.typingUserIds.map(nameOf)}
190 roomLabel="话题"
196 roomLabel={t('chat.thread.title')}
191197 customEmojis={customEmojis}
192198 spaceName={spaceName}
193199 />
194200 <div className="shrink-0 px-3 pb-3">
195201 <ChatComposer
196202 composer={composer}
197 roomLabel="话题"
203 roomLabel={t('chat.thread.title')}
198204 threadRoot={root}
199205 members={members}
200206 customEmojis={customEmojis}
@@ -218,6 +224,7 @@
218224 canManage: boolean
219225 onChanged: () => void
220226}) {
227 const { t } = useT()
221228 const [busy, setBusy] = useState(false)
222229 const [error, setError] = useState<string | null>(null)
223230 const run = (action: () => Promise<unknown>) => {
@@ -244,24 +251,24 @@
244251 ) : null}
245252 <button
246253 type="button"
247 title="话题内每条新回复都提醒我"
254 title={t('chat.thread.followTitle')}
248255 onClick={() => run(() => followThread(thread.id, 'All'))}
249256 className="rounded border border-shell-hairline px-1.5 py-0.5 text-[11px] hover:bg-shell-field"
250257 >
251 关注
258 {t('chat.thread.follow')}
252259 </button>
253260 <button
254261 type="button"
255 title="取消话题提醒"
262 title={t('chat.thread.unfollowTitle')}
256263 onClick={() => run(() => unfollowThread(thread.id))}
257264 className="rounded border border-shell-hairline px-1.5 py-0.5 text-[11px] hover:bg-shell-field"
258265 >
259 取关
266 {t('chat.thread.unfollow')}
260267 </button>
261268 {canManage ? (
262269 <button
263270 type="button"
264 title={thread.locked ? '解锁话题' : '锁定话题(停止新回复)'}
271 title={thread.locked ? t('chat.thread.unlock') : t('chat.thread.lock')}
265272 onClick={() => run(() => updateThread(thread.id, { locked: !thread.locked }))}
266273 className={`grid h-6 w-6 place-content-center rounded hover:bg-shell-field ${
267274 thread.locked ? 'text-[#b26a00]' : ''
@@ -292,14 +299,19 @@
292299 onUnpin: (message: ChatMessage) => void
293300 onClose: () => void
294301}) {
302 const { t } = useT()
295303 return (
296304 <>
297 <PanelHeader title="置顶消息" subtitle={`${pins.length} 条`} onClose={onClose} />
305 <PanelHeader
306 title={t('chat.room.pins')}
307 subtitle={t('chat.pins.count', { count: pins.length })}
308 onClose={onClose}
309 />
298310 <div className="min-h-0 flex-1 overflow-y-auto p-2">
299311 {loading ? (
300312 <PanelBusy />
301313 ) : pins.length === 0 ? (
302 <PanelEmpty>还没有置顶消息。把鼠标移到一条消息上,在「更多操作」里置顶。</PanelEmpty>
314 <PanelEmpty>{t('chat.pins.empty')}</PanelEmpty>
303315 ) : (
304316 <ul className="space-y-1.5">
305317 {pins.map(message => (
@@ -311,7 +323,7 @@
311323 <button
312324 type="button"
313325 onClick={() => onJump(message.id)}
314 title="跳转到该消息"
326 title={t('chat.pins.jump')}
315327 className="min-w-0 flex-1 text-left"
316328 >
317329 <span className="flex items-baseline gap-1.5 text-[12px]">
@@ -321,13 +333,15 @@
321333 </span>
322334 </span>
323335 <span className="line-clamp-3 block whitespace-pre-wrap break-words text-[12.5px]">
324 {message.deleted_at ? '(已删除)' : message.content || '[附件]'}
336 {message.deleted_at
337 ? t('chat.transcript.deletedShort')
338 : message.content || t('chat.composer.attachmentPlaceholder')}
325339 </span>
326340 </button>
327341 <div className="flex shrink-0 flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
328342 <button
329343 type="button"
330 title="跳转"
344 title={t('chat.pins.jumpShort')}
331345 onClick={() => onJump(message.id)}
332346 className="grid h-6 w-6 place-content-center rounded hover:bg-shell-active"
333347 >
@@ -335,7 +349,7 @@
335349 </button>
336350 <button
337351 type="button"
338 title="取消置顶"
352 title={t('chat.message.unpin')}
339353 onClick={() => onUnpin(message)}
340354 className="grid h-6 w-6 place-content-center rounded hover:bg-shell-active"
341355 >
@@ -380,6 +394,7 @@
380394 onChanged: () => void
381395 onClose: () => void
382396}) {
397 const { t, tc } = useT()
383398 const [adding, setAdding] = useState(false)
384399 const online = useMemo(() => {
385400 const map = new Map<string, Presence>()
@@ -396,8 +411,8 @@
396411 return (
397412 <>
398413 <PanelHeader
399 title="成员"
400 subtitle={`${memberCount || members.length} 人`}
414 title={t('chat.member.list')}
415 subtitle={tc('chat.member.count', memberCount || members.length)}
401416 onClose={onClose}
402417 actions={
403418 canManage && spaceName ? (
@@ -407,7 +422,7 @@
407422 className="flex shrink-0 items-center gap-1 rounded border border-shell-hairline px-1.5 py-0.5 text-[11px] hover:bg-shell-field"
408423 >
409424 <UserPlus className="h-3.5 w-3.5" />
410 添加
425 {t('common.action.add')}
411426 </button>
412427 ) : null
413428 }
@@ -428,8 +443,8 @@
428443 {error && members.length === 0 ? (
429444 <PanelNote>
430445 {Number((error as { code?: number }).code ?? 0) === 403
431 ? '这是私密频道,成员列表未对你开放。'
432 : '成员列表加载失败。'}
446 ? t('chat.member.forbidden')
447 : t('chat.member.loadFailed')}
433448 </PanelNote>
434449 ) : loading && members.length === 0 ? (
435450 <PanelBusy />
@@ -444,7 +459,11 @@
444459 type="button"
445460 disabled={self}
446461 onClick={() => onDm(member.user)}
447 title={self ? '这是你自己' : `与 @${member.user.username} 私信`}
462 title={
463 self
464 ? t('chat.member.self')
465 : t('chat.member.dmTitle', { username: member.user.username })
466 }
448467 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-shell-field disabled:cursor-default disabled:hover:bg-transparent"
449468 >
450469 <span className="relative shrink-0">
@@ -475,7 +494,7 @@
475494 </span>
476495 {member.role !== 'member' ? (
477496 <span className="shrink-0 rounded bg-shell-field px-1 text-[10px] font-semibold text-shell-ink-muted">
478 {member.role === 'owner' ? '房主' : member.role}
497 {member.role === 'owner' ? t('chat.member.owner') : member.role}
479498 </span>
480499 ) : null}
481500 </button>
@@ -514,6 +533,7 @@
514533 onDone: () => void
515534 onCancel: () => void
516535}) {
536 const { t } = useT()
517537 const [picked, setPicked] = useState<string[]>([])
518538 const [busy, setBusy] = useState(false)
519539 const [error, setError] = useState<string | null>(null)
@@ -526,9 +546,9 @@
526546 <div className="shrink-0 border-b border-shell-hairline bg-shell-board/70 p-2">
527547 <div className="max-h-48 space-y-0.5 overflow-y-auto">
528548 {people.loading ? (
529 <PanelBusy label="加载社区成员…" />
549 <PanelBusy label={t('chat.panel.loadedMembers')} />
530550 ) : rows.length === 0 ? (
531 <PanelNote>社区里的人已经都在这个频道里了。</PanelNote>
551 <PanelNote>{t('chat.member.allInRoom')}</PanelNote>
532552 ) : (
533553 rows.map(member => (
534554 <label
@@ -575,16 +595,20 @@
575595 }}
576596 className="rounded bg-shell-action px-2.5 py-1 text-[12px] font-semibold text-white disabled:opacity-50"
577597 >
578 添加{picked.length > 0 ? ` (${picked.length})` : ''}
598 {picked.length > 0
599 ? t('chat.member.addCount', { count: picked.length })
600 : t('common.action.add')}
579601 </button>
580602 <button
581603 type="button"
582604 onClick={onCancel}
583605 className="rounded px-2 py-1 text-[12px] text-shell-ink-muted hover:bg-shell-field"
584606 >
585 取消
607 {t('common.action.cancel')}
586608 </button>
587 {people.error ? <span className="text-[11px] text-shell-ink-muted">社区成员读取失败</span> : null}
609 {people.error ? (
610 <span className="text-[11px] text-shell-ink-muted">{t('chat.member.listFailed')}</span>
611 ) : null}
588612 </div>
589613 </div>
590614 )
@@ -617,24 +641,25 @@
617641 nameOf: (userId: string | null | undefined) => string
618642 roomLabel: string
619643}) {
644 const { t } = useT()
620645 return (
621646 <>
622 <PanelHeader title="搜索消息" subtitle={`#${roomLabel}`} onClose={onClose} />
647 <PanelHeader title={t('chat.search.placeholder')} subtitle={`#${roomLabel}`} onClose={onClose} />
623648 <div className="shrink-0 space-y-2 p-2.5">
624649 <div className="flex items-center gap-2 rounded-md border-2 border-shell-hairline bg-shell-field px-2">
625650 <Search className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" />
626651 <input
627652 autoFocus
628653 value={query}
629 aria-label="搜索聊天消息"
654 aria-label={t('chat.search.aria')}
630655 onChange={event => onQuery(event.target.value)}
631 placeholder="关键词,输入即搜索"
656 placeholder={t('chat.search.inputPlaceholder')}
632657 className="h-8 min-w-0 flex-1 bg-transparent text-[13px] outline-none"
633658 />
634659 {query ? (
635660 <button
636661 type="button"
637 aria-label="清空"
662 aria-label={t('common.action.clear')}
638663 onClick={() => onQuery('')}
639664 className="text-shell-ink-muted hover:text-shell-ink"
640665 >
@@ -648,21 +673,21 @@
648673 checked={onlyThisRoom}
649674 onChange={event => onToggleScope(event.target.checked)}
650675 />
651 仅本频道(取消则搜索整个社区)
676 {t('chat.search.onlyRoom')}
652677 </label>
653678 </div>
654679 <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
655680 {error ? (
656681 <PanelNote>
657682 {Number((error as { code?: number }).code ?? 0) === 404
658 ? '这里没有可检索的社区消息:私信与个人范围不支持全文检索。'
683 ? t('chat.search.unsupported')
659684 : describeFailure(error)}
660685 </PanelNote>
661686 ) : loading ? (
662 <PanelBusy label="搜索中…" />
687 <PanelBusy label={t('chat.panel.searching')} />
663688 ) : query.trim() ? (
664689 hits.length === 0 ? (
665 <PanelEmpty>没有匹配「{query.trim()}」的消息。</PanelEmpty>
690 <PanelEmpty>{t('chat.search.emptyFor', { query: query.trim() })}</PanelEmpty>
666691 ) : (
667692 <ul className="space-y-1.5">
668693 {hits.map(hit => (
@@ -687,7 +712,7 @@
687712 ) : (
688713 <PanelEmpty>
689714 <Hash className="mr-1 inline h-3.5 w-3.5" />
690 输入关键词开始搜索
715 {t('chat.search.hint')}
691716 </PanelEmpty>
692717 )}
693718 </div>
@@ -710,14 +735,19 @@
710735 onOpen: (thread: ThreadBrief) => void
711736 onClose: () => void
712737}) {
738 const { t, tc } = useT()
713739 return (
714740 <>
715 <PanelHeader title="话题" subtitle={`${threads.length} 个`} onClose={onClose} />
741 <PanelHeader
742 title={t('chat.thread.title')}
743 subtitle={t('chat.thread.count', { count: threads.length })}
744 onClose={onClose}
745 />
716746 <div className="min-h-0 flex-1 overflow-y-auto p-2">
717747 {loading ? (
718748 <PanelBusy />
719749 ) : threads.length === 0 ? (
720 <PanelEmpty>还没有话题。在消息的操作栏选择「在话题中回复」,让讨论更有条理。</PanelEmpty>
750 <PanelEmpty>{t('chat.thread.emptyHint')}</PanelEmpty>
721751 ) : (
722752 <ul className="space-y-1.5">
723753 {threads.map(thread => (
@@ -728,16 +758,16 @@
728758 className="w-full rounded-md border border-shell-hairline bg-shell-field p-2 text-left transition-colors hover:border-shell-ink-muted"
729759 >
730760 <p className="truncate text-[12.5px] font-semibold">
731 {thread.name || `来自 ${nameOf(thread.owner_id)} 的话题`}
761 {thread.name || t('chat.thread.fromOwner', { name: nameOf(thread.owner_id) })}
732762 </p>
733763 <p className="mt-0.5 flex items-center gap-2 text-[11px] text-shell-ink-muted">
734 <span>{thread.reply_count} 条回复</span>
735 <span>{thread.participant_count} 人</span>
736 {thread.archived ? <span>已归档</span> : null}
764 <span>{t('chat.thread.replies', { count: thread.reply_count })}</span>
765 <span>{tc('chat.thread.participants', thread.participant_count)}</span>
766 {thread.archived ? <span>{t('chat.thread.archived')}</span> : null}
737767 {thread.locked ? (
738768 <span className="flex items-center gap-0.5">
739769 <Lock className="h-3 w-3" />
740 已锁定
770 {t('chat.thread.locked')}
741771 </span>
742772 ) : null}
743773 </p>
@@ -753,11 +783,12 @@
753783
754784/* ------------------------------------------------------------------- parts */
755785
756function PanelBusy({ label = '加载中…' }: { label?: string }) {
786function PanelBusy({ label }: { label?: string }) {
787 const { t } = useT()
757788 return (
758789 <p className="py-6 text-center text-[12px] text-shell-ink-muted">
759790 <Loader2 className="mr-1 inline h-3 w-3 animate-spin" />
760 {label}
791 {label ?? t('common.action.loading')}
761792 </p>
762793 )
763794}
Modified web/src/features/chat/ui/system-lines.ts View web/src/features/chat/ui/system-lines.ts
@@ -5,6 +5,7 @@
55 * real content, which must not be swallowed by the system styling.
66 */
77import type { ChatMessage } from '@/entities/chat'
8import { t } from '@/shared/i18n'
89
910export function systemLine(
1011 message: ChatMessage,
@@ -13,27 +14,33 @@
1314 const who = nameOf(message.author_id)
1415 if (message.author_type !== 'User') {
1516 // Bots and webhooks post normal text; the label keeps it attributable.
16 const label = message.author?.display_name || message.author?.username || '集成'
17 return message.content ? `${label}:${message.content}` : `${label} 发布了一条消息`
17 const label = message.author?.display_name || message.author?.username || t('chat.system.integration')
18 return message.content
19 ? t('chat.system.postWithContent', { author: label, content: message.content })
20 : t('chat.system.post', { author: label })
1821 }
1922 switch (message.message_type) {
2023 case 'Reply':
21 return `${who} 回复了一条消息`
24 return t('chat.system.replied', { author: who })
2225 case 'ThreadCreated':
23 return `${who} 开启了一个话题`
26 return t('chat.system.threadCreated', { author: who })
2427 case 'ChannelJoin':
25 return `${who} 加入了这个频道`
28 return t('chat.system.joined', { author: who })
2629 case 'ChannelLeave':
27 return `${who} 离开了这个频道`
30 return t('chat.system.left', { author: who })
2831 case 'ChannelTopic':
29 return message.content ? `${who} 把主题改为「${message.content}」` : `${who} 更新了频道主题`
32 return message.content
33 ? t('chat.system.topicChanged', { author: who, topic: message.content })
34 : t('chat.system.topicUpdated', { author: who })
3035 case 'ChannelPinnedItem':
31 return `${who} 置顶了一条消息`
36 return t('chat.system.pinned', { author: who })
3237 case 'Reminder':
33 return message.content ? `${who} 设置了提醒:${message.content}` : `${who} 设置了一个提醒`
38 return message.content
39 ? t('chat.system.reminder', { author: who, content: message.content })
40 : t('chat.system.reminderSet', { author: who })
3441 case 'System':
35 return message.content || '系统消息'
42 return message.content || t('chat.system.message')
3643 default:
37 return message.content || `${who} 发送了一条消息`
44 return message.content || t('chat.system.sent', { author: who })
3845 }
3946}
Modified web/src/features/chat/ui/transcript.tsx View web/src/features/chat/ui/transcript.tsx
@@ -63,6 +63,7 @@
6363} from './parts'
6464import { EmojiPicker } from './emoji-picker'
6565import { systemLine } from './system-lines'
66import { useT } from '@/shared/i18n'
6667
6768/** Distance from the bottom (px) that still counts as "reading the tail". */
6869const STICK_THRESHOLD = 96
@@ -133,6 +134,7 @@
133134 customEmojis,
134135 spaceName,
135136}: TranscriptProps) {
137 const { t, tc } = useT()
136138 const anchorHeight = useRef<number | null>(null)
137139 const [preview, setPreview] = useState<Attachment | null>(null)
138140 const [flash, setFlash] = useState<string | null>(null)
@@ -195,8 +197,8 @@
195197 return (
196198 <TranscriptNotice
197199 icon={<TriangleAlert className="h-5 w-5" />}
198 title="你已无法访问这个频道"
199 body="服务器撤回了这里的读取权限。请从左侧选择其他频道。"
200 title={t('chat.transcript.accessDenied')}
201 body={t('chat.transcript.accessDeniedHint')}
200202 />
201203 )
202204 }
@@ -210,16 +212,18 @@
210212 <div
211213 ref={listRef}
212214 onScroll={handleScroll}
213 aria-label="聊天消息"
215 aria-label={t('chat.transcript.aria')}
214216 className="chat-message-list min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-3"
215217 >
216218 {state.loadingOlder ? (
217219 <p className="py-2 text-center text-[11.5px] text-shell-ink-muted">
218220 <Loader2 className="mr-1 inline h-3 w-3 animate-spin" />
219 加载更早的消息…
221 {t('chat.transcript.loadingOlder')}
220222 </p>
221223 ) : state.hasOlder ? null : state.rows.length > 0 ? (
222 <p className="chat-history-start">{state.threadId ? '话题从这里开始' : '每一段精彩的交流,都从这里开始'}</p>
224 <p className="chat-history-start">
225 {state.threadId ? t('chat.transcript.threadStart') : t('chat.transcript.start')}
226 </p>
223227 ) : null}
224228
225229 {state.rows.map((message, index) => {
@@ -270,9 +274,9 @@
270274 {atError ? (
271275 <TranscriptNotice
272276 icon={<TriangleAlert className="h-5 w-5" />}
273 title="消息加载失败"
277 title={t('chat.transcript.loadFailed')}
274278 body={state.error ?? ''}
275 action={{ label: '重试', onClick: onRetrySeed }}
279 action={{ label: t('common.action.retry'), onClick: onRetrySeed }}
276280 retry
277281 />
278282 ) : null}
@@ -284,8 +288,16 @@
284288 !state.error ? (
285289 <TranscriptNotice
286290 icon={<MessageSquare className="h-5 w-5" />}
287 title={state.threadId ? '让讨论继续' : `欢迎来到 ${roomLabel}`}
288 body={state.threadId ? '分享你的想法,成为这段讨论的第一位回应者。' : '一个新话题、一点新灵感,或者一句简单的你好。让我们从这里开始。'}
291 title={
292 state.threadId
293 ? t('chat.transcript.threadEmptyTitle')
294 : t('chat.transcript.roomEmptyTitle', { room: roomLabel })
295 }
296 body={
297 state.threadId
298 ? t('chat.transcript.threadEmptyBody')
299 : t('chat.transcript.roomEmptyBody')
300 }
289301 />
290302 ) : null}
291303
@@ -301,7 +313,9 @@
301313 className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-2 rounded-full bg-shell-action px-3.5 py-1.5 text-[12.5px] font-semibold text-white shadow-lg transition-[filter] hover:brightness-95"
302314 >
303315 <ArrowDown className="h-3.5 w-3.5" />
304 {missedCount > 0 ? `${missedCount} 条新消息` : '回到最新消息'}
316 {missedCount > 0
317 ? tc('chat.transcript.newMessages', missedCount)
318 : t('chat.transcript.backToLatest')}
305319 </button>
306320 ) : null}
307321
@@ -311,11 +325,12 @@
311325}
312326
313327function UnreadMarker({ count }: { count: number }) {
328 const { t } = useT()
314329 return (
315330 <div className="chat-unread-marker flex items-center gap-3 px-3 py-1.5">
316331 <div className="h-px flex-1 bg-shell-tile" />
317332 <span className="shrink-0 rounded-full bg-shell-active px-2 py-px text-[11px] font-semibold text-shell-ink-muted">
318 {count > 0 ? `${count} 条未读` : '未读'}
333 {count > 0 ? t('chat.unread.count', { count }) : t('chat.unread.label')}
319334 </span>
320335 <div className="h-px flex-1 bg-shell-tile" />
321336 </div>
@@ -366,6 +381,7 @@
366381 customEmojis?: Parameters<typeof EmojiPicker>[0]['custom']
367382 spaceName?: string
368383}) {
384 const { t } = useT()
369385 const mine = Boolean(viewerId && message.author_id === viewerId)
370386 const replyId = message.reply_to_id ?? null
371387 const replyRow = replyId ? state.rows.find(row => row.id === replyId) : undefined
@@ -402,7 +418,7 @@
402418 {clockStamp(message.created_at)}
403419 </span>
404420 {message.pinned ? (
405 <span title="已置顶" className="shrink-0 text-shell-ink-muted">
421 <span title={t('chat.transcript.pinned')} className="shrink-0 text-shell-ink-muted">
406422 <Pin className="h-3 w-3" />
407423 </span>
408424 ) : null}
@@ -416,7 +432,7 @@
416432 onJump={() => replyId && interactions.onJump(replyId)}
417433 />
418434 ) : replyId && replyRef?.state === 'gone' ? (
419 <p className="text-[12px] text-shell-ink-muted">引用了已删除的消息</p>
435 <p className="text-[12px] text-shell-ink-muted">{t('chat.transcript.replyDeleted')}</p>
420436 ) : null}
421437
422438 {editing ? (
@@ -430,7 +446,7 @@
430446 }}
431447 />
432448 ) : deleted ? (
433 <p className="text-[14px] italic text-shell-ink-muted">(此消息已删除)</p>
449 <p className="text-[14px] italic text-shell-ink-muted">{t('chat.transcript.thisDeleted')}</p>
434450 ) : (
435451 <>
436452 <MessageBody message={message} viewerId={viewerId} />
@@ -442,7 +458,7 @@
442458
443459 {message.edited_at && !editing ? (
444460 <span className="text-[10.5px] text-shell-ink-muted" title={fullStamp(message.edited_at)}>
445 已编辑
461 {t('chat.message.edited')}
446462 </span>
447463 ) : null}
448464
@@ -454,8 +470,12 @@
454470 type="button"
455471 onClick={() => interactions.onReact(message, reaction.emoji, !reaction.me)}
456472 aria-pressed={reaction.me}
457 aria-label={`${reaction.emoji},${reaction.count} 人回应${reaction.me ? ',你已回应' : ''}`}
458 title={`${reaction.count} 人回应`}
473 aria-label={
474 reaction.me
475 ? t('chat.reaction.ariaMine', { emoji: reaction.emoji, count: reaction.count })
476 : t('chat.reaction.aria', { emoji: reaction.emoji, count: reaction.count })
477 }
478 title={t('chat.reaction.title', { count: reaction.count })}
459479 className={`flex items-center gap-1 rounded-full border px-1.5 py-px text-[12px] transition-colors ${
460480 reaction.me
461481 ? 'border-shell-tile bg-shell-active text-shell-ink'
@@ -476,13 +496,15 @@
476496 className="mt-1 flex max-w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-[12.5px] font-medium text-shell-ink underline-offset-2 transition-colors hover:bg-shell-field hover:underline"
477497 >
478498 <MessageSquare className="h-3.5 w-3.5 shrink-0" />
479 <span className="shrink-0">{thread.reply_count} 条回复</span>
499 <span className="shrink-0">{t('chat.thread.replies', { count: thread.reply_count })}</span>
480500 {thread.last_reply_at ? (
481501 <span className="min-w-0 truncate text-shell-ink-muted">
482 最后回复 {clockStamp(thread.last_reply_at)}
502 {t('chat.transcript.lastReply', { time: clockStamp(thread.last_reply_at) })}
483503 </span>
484504 ) : null}
485 {thread.locked ? <span className="shrink-0 text-[11px]">· 已锁定</span> : null}
505 {thread.locked ? (
506 <span className="shrink-0 text-[11px]">· {t('chat.thread.locked')}</span>
507 ) : null}
486508 </button>
487509 ) : null}
488510 </div>
@@ -511,17 +533,20 @@
511533 nameOf: (userId: string | null | undefined) => string
512534 onJump: () => void
513535}) {
536 const { t } = useT()
514537 return (
515538 <button
516539 type="button"
517540 onClick={onJump}
518 title="跳转到原消息"
541 title={t('chat.transcript.jumpToOriginal')}
519542 className="mb-0.5 flex max-w-full items-center gap-1.5 text-[12px] text-shell-ink-muted transition-colors hover:text-shell-ink"
520543 >
521544 <CornerUpLeft className="h-3 w-3 shrink-0" />
522545 <span className="shrink-0 font-medium">{nameOf(message.author_id)}</span>
523546 <span className="min-w-0 truncate">
524 {message.deleted_at ? '(已删除)' : message.content || '[附件]'}
547 {message.deleted_at
548 ? t('chat.transcript.deletedShort')
549 : message.content || t('chat.composer.attachmentPlaceholder')}
525550 </span>
526551 </button>
527552 )
@@ -544,6 +569,7 @@
544569 customEmojis?: Parameters<typeof EmojiPicker>[0]['custom']
545570 spaceName?: string
546571}) {
572 const { t } = useT()
547573 const [picker, setPicker] = useState(false)
548574 const [menu, setMenu] = useState(false)
549575 const [copied, setCopied] = useState(false)
@@ -553,9 +579,9 @@
553579 interactions.onReact(message, emoji, on)
554580 }
555581 return (
556 <div className="chat-message-toolbar" role="group" aria-label="消息操作">
582 <div className="chat-message-toolbar" role="group" aria-label={t('chat.transcript.actions')}>
557583 <div className="relative">
558 <IconAction label="表情回应" onClick={() => setPicker(value => !value)} active={picker}>
584 <IconAction label={t('chat.transcript.react')} onClick={() => setPicker(value => !value)} active={picker}>
559585 <SmilePlus className="h-4 w-4" />
560586 </IconAction>
561587 <Popover open={picker} onClose={() => setPicker(false)} align="end" className="w-72">
@@ -571,16 +597,16 @@
571597 </div>
572598 {canParticipate ? (
573599 <>
574 <IconAction label="在话题中回复" onClick={() => interactions.onOpenThread(message)}>
600 <IconAction label={t('chat.transcript.replyInThread')} onClick={() => interactions.onOpenThread(message)}>
575601 <MessageSquare className="h-4 w-4" />
576602 </IconAction>
577 <IconAction label="引用回复" onClick={() => interactions.onReply(message)}>
603 <IconAction label={t('chat.transcript.quoteReply')} onClick={() => interactions.onReply(message)}>
578604 <CornerUpLeft className="h-4 w-4" />
579605 </IconAction>
580606 </>
581607 ) : null}
582608 <IconAction
583 label={copied ? '已复制' : '复制文本'}
609 label={copied ? t('chat.transcript.copied') : t('chat.transcript.copyText')}
584610 onClick={() => {
585611 void navigator.clipboard?.writeText(message.content).then(
586612 () => setCopied(true),
@@ -591,14 +617,14 @@
591617 {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
592618 </IconAction>
593619 <div className="relative">
594 <IconAction label="更多操作" onClick={() => setMenu(value => !value)} active={menu}>
620 <IconAction label={t('chat.transcript.more')} onClick={() => setMenu(value => !value)} active={menu}>
595621 <MoreHorizontal className="h-4 w-4" />
596622 </IconAction>
597623 <Popover open={menu} onClose={() => setMenu(false)} align="end" className="w-40 py-1">
598624 {mine ? (
599625 <MenuItem
600626 icon={<Pencil className="h-3.5 w-3.5" />}
601 label="编辑"
627 label={t('common.action.edit')}
602628 onClick={() => {
603629 setMenu(false)
604630 interactions.onEdit(message)
@@ -607,7 +633,7 @@
607633 ) : null}
608634 <MenuItem
609635 icon={<Pin className="h-3.5 w-3.5" />}
610 label={message.pinned ? '取消置顶' : '置顶'}
636 label={message.pinned ? t('chat.message.unpin') : t('chat.message.pin')}
611637 disabled={!canModerate}
612638 onClick={() => {
613639 setMenu(false)
@@ -617,7 +643,7 @@
617643 {mine || canModerate ? (
618644 <MenuItem
619645 icon={<Trash2 className="h-3.5 w-3.5" />}
620 label="删除"
646 label={t('common.action.delete')}
621647 danger
622648 onClick={() => {
623649 setMenu(false)
@@ -672,20 +698,21 @@
672698 onRetry: (localId: string) => void
673699 onDismiss: (localId: string) => void
674700}) {
701 const { t } = useT()
675702 const failed = item.state === 'failed'
676703 return (
677704 <div className="chat-pending-row flex gap-3 px-3 pb-0.5 pt-2.5">
678705 <UserAvatar id={viewerId ?? ''} size={40} />
679706 <div className="min-w-0 flex-1">
680707 <div className="flex items-baseline gap-2">
681 <span className="text-[15px] font-semibold">我</span>
708 <span className="text-[15px] font-semibold">{t('chat.transcript.mine')}</span>
682709 <span className="text-[11px] text-shell-ink-muted">{clockStamp(item.created_at)}</span>
683710 <span
684711 className={`shrink-0 rounded px-1 text-[10px] font-semibold ${
685712 failed ? 'bg-[#e5484d]/12 text-[#e5484d]' : 'bg-shell-field text-shell-ink-muted'
686713 }`}
687714 >
688 {failed ? '未发送' : '发送中'}
715 {failed ? t('chat.transcript.unsent') : t('chat.transcript.sending')}
689716 </span>
690717 </div>
691718 <p className="whitespace-pre-wrap break-words text-[15px] leading-[1.5]">{item.content}</p>
@@ -694,14 +721,14 @@
694721 ) : null}
695722 {failed ? (
696723 <div className="mt-1 flex flex-wrap items-center gap-2 text-[12px] text-[#e5484d]">
697 <span className="min-w-0 truncate">{item.error ?? '发送失败'}</span>
724 <span className="min-w-0 truncate">{item.error ?? t('chat.transcript.sendFailed')}</span>
698725 <button
699726 type="button"
700727 onClick={() => onRetry(item.localId)}
701728 className="flex shrink-0 items-center gap-1 rounded border border-shell-hairline px-1.5 py-px text-shell-ink hover:bg-shell-field"
702729 >
703730 <RefreshCw className="h-3 w-3" />
704 重试
731 {t('common.action.retry')}
705732 </button>
706733 <button
707734 type="button"
@@ -709,7 +736,7 @@
709736 className="flex shrink-0 items-center gap-1 rounded border border-shell-hairline px-1.5 py-px text-shell-ink-muted hover:bg-shell-field"
710737 >
711738 <X className="h-3 w-3" />
712 丢弃
739 {t('common.action.dismiss')}
713740 </button>
714741 </div>
715742 ) : null}
@@ -727,6 +754,7 @@
727754 onSave: (content: string) => Promise<unknown>
728755 onCancel: () => void
729756}) {
757 const { t } = useT()
730758 const [value, setValue] = useState(initial)
731759 const [busy, setBusy] = useState(false)
732760 const over = textLength(value) > MAX_MESSAGE_LEN
@@ -739,7 +767,7 @@
739767 <div className="mt-1">
740768 <textarea
741769 autoFocus
742 aria-label="编辑消息"
770 aria-label={t('chat.message.edit')}
743771 value={value}
744772 onChange={event => setValue(event.target.value)}
745773 onKeyDown={event => {
@@ -760,29 +788,34 @@
760788 onClick={save}
761789 className="rounded bg-shell-action px-2.5 py-1 text-[12px] font-semibold text-white disabled:opacity-50"
762790 >
763 保存
791 {t('common.action.save')}
764792 </button>
765793 <button
766794 type="button"
767795 onClick={onCancel}
768796 className="rounded px-2 py-1 text-[12px] hover:bg-shell-field"
769797 >
770 取消
798 {t('common.action.cancel')}
771799 </button>
772 <span>Enter 保存 · Shift+Enter 换行 · Esc 取消</span>
773 {over ? <span className="text-[#e5484d]">超出 {textLength(value) - MAX_MESSAGE_LEN} 字</span> : null}
800 <span>{t('chat.transcript.editHint')}</span>
801 {over ? (
802 <span className="text-[#e5484d]">
803 {t('chat.composer.tooLongBy', { count: textLength(value) - MAX_MESSAGE_LEN })}
804 </span>
805 ) : null}
774806 </div>
775807 </div>
776808 )
777809}
778810
779811function TypingRow({ names }: { names: string[] }) {
812 const { t } = useT()
780813 const label =
781814 names.length === 1
782 ? `${names[0]} 正在输入…`
815 ? t('chat.transcript.typingOne', { name: names[0] })
783816 : names.length === 2
784 ? `${names[0]} 和 ${names[1]} 正在输入…`
785 : `${names.slice(0, 3).join('、')} 正在输入…`
817 ? t('chat.transcript.typingTwo', { first: names[0], second: names[1] })
818 : t('chat.transcript.typingMany', { names: names.slice(0, 3).join(', ') })
786819 return (
787820 <div className="flex items-center gap-2 px-6 pb-1 pt-1 text-[12px] text-shell-ink-muted">
788821 <span className="flex gap-0.5" aria-hidden>
Modified web/src/features/issue-board/ui/board-filters.tsx View web/src/features/issue-board/ui/board-filters.tsx
@@ -18,6 +18,7 @@
1818 type Milestone,
1919 type UserDto,
2020} from '@/entities/issue'
21import { useT } from '@/shared/i18n'
2122import { FilterMenu } from './parts'
2223
2324export interface BoardFiltersProps {
@@ -41,6 +42,7 @@
4142 onFilter,
4243 onClear,
4344}: BoardFiltersProps) {
45 const { t } = useT()
4446 // The input keeps its own text while the debounce is pending, and re-syncs
4547 // whenever the URL wins (back button, cleared chip, another tab's change).
4648 const [text, setText] = useState(view.q)
@@ -62,18 +64,18 @@
6264 <input
6365 value={text}
6466 onChange={event => setText(event.target.value)}
65 placeholder="搜索工单…"
66 aria-label="搜索工单标题"
67 placeholder={t('issue.filter.searchPlaceholder')}
68 aria-label={t('issue.filter.searchAria')}
6769 className="min-w-0 flex-1 bg-transparent text-[12.5px] outline-none placeholder:text-shell-ink-muted"
6870 />
6971 {text ? (
70 <button type="button" aria-label="清空搜索" onClick={() => setText('')} className="shrink-0 text-shell-ink-muted hover:text-shell-ink">
72 <button type="button" aria-label={t('issue.filter.clearSearch')} onClick={() => setText('')} className="shrink-0 text-shell-ink-muted hover:text-shell-ink">
7173 <X className="h-3.5 w-3.5" />
7274 </button>
7375 ) : null}
7476 </label>
7577
76 <div className="board-segmented flex shrink-0 items-center rounded-md bg-shell-field p-0.5" role="group" aria-label="状态筛选">
78 <div className="board-segmented flex shrink-0 items-center rounded-md bg-shell-field p-0.5" role="group" aria-label={t('issue.filter.statusGroup')}>
7779 {STATE_FILTERS.map(item => (
7880 <button
7981 key={item.id}
@@ -90,38 +92,38 @@
9092 </div>
9193
9294 <FilterMenu
93 label="优先级"
94 placeholder="全部优先级"
95 label={t('issue.board.filter.priority')}
96 placeholder={t('issue.filter.allPriorities')}
9597 value={view.priority}
9698 options={PRIORITIES.map(meta => ({ value: meta.id, label: meta.label, color: meta.color }))}
9799 onChange={value => onFilter({ priority: value as IssuePriority | '' })}
98100 />
99101 <FilterMenu
100 label="标签"
101 placeholder={labelsFailed ? '标签加载失败' : '全部标签'}
102 label={t('issue.board.filter.label')}
103 placeholder={labelsFailed ? t('issue.filter.labelsFailed') : t('issue.filter.allLabels')}
102104 value={view.labelId}
103105 options={labels.map(label => ({ value: label.id, label: label.name, color: label.color }))}
104106 onChange={value => onFilter({ labelId: value })}
105107 />
106108 <FilterMenu
107 label="里程碑"
108 placeholder="全部里程碑"
109 label={t('issue.board.filter.milestone')}
110 placeholder={t('issue.filter.allMilestones')}
109111 value={view.milestoneId}
110112 options={[
111113 ...milestones.map(m => ({
112114 value: m.id,
113115 label: m.name,
114 hint: m.is_closed ? '已关闭' : undefined,
116 hint: m.is_closed ? t('label.issueState.closed') : undefined,
115117 })),
116118 // `none` is not a milestone id: the backend cannot filter it, so it is
117119 // handled client-side on the loaded page (see `IssueBoardBody`).
118 { value: 'none', label: '未设里程碑' },
120 { value: 'none', label: t('issue.filter.noMilestone') },
119121 ]}
120122 onChange={value => onFilter({ milestoneId: value })}
121123 />
122124 <FilterMenu
123 label="负责人"
124 placeholder="全部负责人"
125 label={t('issue.board.filter.assignee')}
126 placeholder={t('issue.filter.allAssignees')}
125127 value={view.assigneeId}
126128 options={members.map(user => ({
127129 value: user.user_id,
@@ -131,18 +133,18 @@
131133 onChange={value => onFilter({ assigneeId: value })}
132134 />
133135 <FilterMenu
134 label="排序"
136 label={t('issue.board.filter.sort')}
135137 value={view.sort}
136 active={`排序:${(SORTS.find(s => s.id === view.sort) ?? SORTS[0]).label}`}
138 active={t('issue.filter.sortActive', { label: (SORTS.find(s => s.id === view.sort) ?? SORTS[0]).label })}
137139 options={SORTS.map(sort => ({ value: sort.id, label: sort.label }))}
138140 onChange={value => onFilter({ sort: value as IssueSort })}
139141 />
140142
141 <div className="board-segmented flex shrink-0 items-center rounded-md bg-shell-field p-0.5" role="group" aria-label="视图切换">
143 <div className="board-segmented flex shrink-0 items-center rounded-md bg-shell-field p-0.5" role="group" aria-label={t('issue.filter.viewGroup')}>
142144 {(
143145 [
144 ['board', '看板', LayoutGrid],
145 ['list', '列表', List],
146 ['board', t('issue.board.viewBoard'), LayoutGrid],
147 ['list', t('issue.board.viewList'), List],
146148 ] as const
147149 ).map(([id, label, Icon]) => (
148150 <button
@@ -168,7 +170,7 @@
168170 className="flex h-7 shrink-0 items-center gap-1 rounded-md px-2 text-[12.5px] text-shell-ink-muted transition-colors hover:bg-shell-field hover:text-shell-ink"
169171 >
170172 <X className="h-3.5 w-3.5" aria-hidden />
171 清除筛选
173 {t('issue.filter.clear')}
172174 </button>
173175 ) : null}
174176
@@ -197,25 +199,26 @@
197199 memberName: (id: string) => string | undefined
198200 onFilter: (updates: Partial<IssueBoardView>) => void
199201}) {
202 const { t } = useT()
200203 const chips: Array<{ key: string; text: string; clear: Partial<IssueBoardView> }> = []
201 if (view.state !== 'open') chips.push({ key: 'state', text: `状态:${STATE_FILTERS.find(s => s.id === view.state)?.label ?? view.state}`, clear: { state: 'open' } })
202 if (view.q) chips.push({ key: 'q', text: `关键词:${view.q}`, clear: { q: '' } })
203 if (view.priority) chips.push({ key: 'priority', text: `优先级:${priorityOf(view.priority).label}`, clear: { priority: '' } })
204 if (view.labelId) chips.push({ key: 'label', text: `标签:${labelName(view.labelId) ?? '未知标签'}`, clear: { labelId: '' } })
204 if (view.state !== 'open') chips.push({ key: 'state', text: t('issue.filter.chip.state', { value: STATE_FILTERS.find(s => s.id === view.state)?.label ?? view.state }), clear: { state: 'open' } })
205 if (view.q) chips.push({ key: 'q', text: t('issue.filter.chip.query', { value: view.q }), clear: { q: '' } })
206 if (view.priority) chips.push({ key: 'priority', text: t('issue.filter.chip.priority', { value: priorityOf(view.priority).label }), clear: { priority: '' } })
207 if (view.labelId) chips.push({ key: 'label', text: t('issue.filter.chip.label', { value: labelName(view.labelId) ?? t('issue.filter.unknownLabel') }), clear: { labelId: '' } })
205208 if (view.milestoneId)
206209 chips.push({
207210 key: 'milestone',
208 text: `里程碑:${view.milestoneId === 'none' ? '未设' : (milestoneName(view.milestoneId) ?? '未知')}`,
211 text: t('issue.filter.chip.milestone', { value: view.milestoneId === 'none' ? t('issue.filter.noMilestoneShort') : (milestoneName(view.milestoneId) ?? t('issue.filter.unknownMilestone')) }),
209212 clear: { milestoneId: '' },
210213 })
211 if (view.assigneeId) chips.push({ key: 'assignee', text: `负责人:${memberName(view.assigneeId) ?? '未知用户'}`, clear: { assigneeId: '' } })
214 if (view.assigneeId) chips.push({ key: 'assignee', text: t('issue.filter.chip.assignee', { value: memberName(view.assigneeId) ?? t('common.state.unknownUser') }), clear: { assigneeId: '' } })
212215 if (chips.length === 0) return null
213216 return (
214217 <div className="board-active-filters flex w-full flex-wrap items-center gap-1.5">
215218 {chips.map(chip => (
216219 <span key={chip.key} className="flex items-center gap-1 rounded-full bg-shell-active px-2 py-0.5 text-[11.5px] text-shell-ink">
217220 {chip.text}
218 <button type="button" aria-label={`移除筛选 ${chip.text}`} onClick={() => onFilter(chip.clear)} className="opacity-60 hover:opacity-100">
221 <button type="button" aria-label={t('issue.filter.remove', { text: chip.text })} onClick={() => onFilter(chip.clear)} className="opacity-60 hover:opacity-100">
219222 <X className="h-3 w-3" aria-hidden />
220223 </button>
221224 </span>
@@ -232,23 +235,24 @@
232235
233236/** Toolbar right segment: watchers are the board's own avatars, then the actions. */
234237export function BoardActions({ onCreate, onManage }: BoardActionsProps) {
238 const { t } = useT()
235239 return (
236240 <>
237241 <button
238242 type="button"
239243 onClick={onManage}
240 title="问题集设置、标签与里程碑"
244 title={t('issue.board.manageTitle')}
241245 className="board-manage-button flex h-[30px] shrink-0 items-center gap-1.5 rounded-md bg-shell-field px-2.5 text-[12.5px] text-shell-ink-muted transition-colors hover:text-shell-ink"
242246 >
243247 <Settings2 className="h-3.5 w-3.5" aria-hidden />
244 管理
248 {t('issue.board.manageButton')}
245249 </button>
246250 <button
247251 type="button"
248252 onClick={onCreate}
249253 className="board-create-button flex h-[30px] shrink-0 items-center gap-1 rounded-md bg-shell-action px-3 text-[13px] font-semibold text-white transition-[filter] hover:brightness-95"
250254 >
251 <Plus className="h-4 w-4" aria-hidden /> 新建工单
255 <Plus className="h-4 w-4" aria-hidden /> {t('issue.board.new')}
252256 </button>
253257 </>
254258 )
Modified web/src/features/issue-board/ui/board-manage-modal.tsx View web/src/features/issue-board/ui/board-manage-modal.tsx
@@ -11,6 +11,7 @@
1111import { Loader2 } from 'lucide-react'
1212import { Alert, Button } from '@/shared/ui'
1313import { ApiFailure } from '@/shared/lib'
14import { useT } from '@/shared/i18n'
1415import {
1516 LABEL_COLORS,
1617 createLabel,
@@ -30,16 +31,19 @@
3031 type Milestone,
3132} from '@/entities/issue'
3233import type { BoardData } from '../model/use-board-data'
34import type { MessageKey } from '@/shared/i18n'
3335import { fieldInputClass, fieldLabelClass, smallInputClass, Dialog } from './dialog'
3436import { DangerButton } from './parts'
3537
3638type Pane = 'general' | 'labels' | 'milestones'
3739
38const PANES: Array<{ id: Pane; label: string }> = [
39 { id: 'general', label: '常规' },
40 { id: 'labels', label: '标签' },
41 { id: 'milestones', label: '里程碑' },
42]
40const PANE_KEYS: Record<Pane, MessageKey> = {
41 general: 'issue.board.manage.general',
42 labels: 'issue.board.manage.labels',
43 milestones: 'issue.board.manage.milestones',
44}
45
46const PANES: Pane[] = ['general', 'labels', 'milestones']
4347
4448export interface BoardManageModalProps {
4549 open: boolean
@@ -53,6 +57,7 @@
5357}
5458
5559export function BoardManageModal({ open, boardRef, board, data, onClose, onChanged, onDeleted }: BoardManageModalProps) {
60 const { t } = useT()
5661 const [pane, setPane] = useState<Pane>('general')
5762 const [error, setError] = useState<string | null>(null)
5863 const [busy, setBusy] = useState<string | null>(null)
@@ -70,7 +75,7 @@
7075 if (!keepOpen) onDeleted()
7176 return true
7277 } catch (err) {
73 setError(err instanceof ApiFailure ? err.message : '操作失败,请稍后重试。')
78 setError(err instanceof ApiFailure ? err.message : t('issue.board.manage.runFailed'))
7479 return false
7580 } finally {
7681 setBusy(null)
@@ -81,26 +86,26 @@
8186 <Dialog
8287 open={open}
8388 onClose={onClose}
84 title="问题集管理"
85 description="重命名、维护标签与里程碑,或删除整个问题集。"
89 title={t('issue.board.manage.title')}
90 description={t('issue.board.manage.description')}
8691 width="max-w-[620px]"
87 footer={<Button variant="secondary" onClick={onClose}>关闭</Button>}
92 footer={<Button variant="secondary" onClick={onClose}>{t('common.action.close')}</Button>}
8893 >
8994 <div className="flex gap-1 border-b-2 border-shell-hairline" role="tablist">
90 {PANES.map(item => (
95 {PANES.map(id => (
9196 <button
92 key={item.id}
97 key={id}
9398 type="button"
9499 role="tab"
95 aria-selected={pane === item.id}
96 onClick={() => setPane(item.id)}
100 aria-selected={pane === id}
101 onClick={() => setPane(id)}
97102 className={`-mb-0.5 border-b-2 px-2.5 pb-1.5 text-[13px] font-medium transition-colors ${
98 pane === item.id
103 pane === id
99104 ? 'border-shell-ink text-shell-ink'
100105 : 'border-transparent text-shell-ink-muted hover:text-shell-ink'
101106 }`}
102107 >
103 {item.label}
108 {t(PANE_KEYS[id])}
104109 </button>
105110 ))}
106111 </div>
@@ -108,7 +113,7 @@
108113 {error ? <Alert>{error}</Alert> : null}
109114 {busy ? (
110115 <p className="flex items-center gap-1.5 text-[12px] text-shell-ink-muted">
111 <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> 处理中…
116 <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> {t('issue.board.manage.processing')}
112117 </p>
113118 ) : null}
114119
@@ -158,11 +163,12 @@
158163 onDelete: () => void
159164}) {
160165 const [name, setName] = useState(board?.name ?? '')
166 const { t } = useT()
161167 const [description, setDescription] = useState(board?.description ?? '')
162168 return (
163169 <div className="space-y-4">
164170 <label className="block space-y-1.5">
165 <span className={fieldLabelClass}>名称</span>
171 <span className={fieldLabelClass}>{t('issue.board.manage.name')}</span>
166172 <input
167173 className={fieldInputClass}
168174 value={name}
@@ -172,7 +178,7 @@
172178 />
173179 </label>
174180 <label className="block space-y-1.5">
175 <span className={fieldLabelClass}>描述</span>
181 <span className={fieldLabelClass}>{t('issue.board.manage.descriptionField')}</span>
176182 <textarea
177183 value={description}
178184 onChange={event => setDescription(event.target.value)}
@@ -182,7 +188,7 @@
182188 />
183189 </label>
184190 <p className="text-[12px] text-shell-ink-muted">
185 统计:未关闭 {board?.open_issues_count ?? 0} · 已关闭 {board?.closed_issues_count ?? 0}
191 {t('issue.board.manage.stats', { open: board?.open_issues_count ?? 0, closed: board?.closed_issues_count ?? 0 })}
186192 </p>
187193 <div className="flex items-center justify-between gap-3">
188194 <Button
@@ -193,9 +199,9 @@
193199 )
194200 }
195201 >
196 保存
202 {t('common.action.save')}
197203 </Button>
198 <DangerButton label="删除问题集" busy={busy === 'delete-board'} onConfirm={onDelete} />
204 <DangerButton label={t('issue.board.manage.deleteBoard')} busy={busy === 'delete-board'} onConfirm={onDelete} />
199205 </div>
200206 </div>
201207 )
@@ -214,6 +220,7 @@
214220 failed: boolean
215221}) {
216222 const [draftName, setDraftName] = useState('')
223 const { t } = useT()
217224 const [draftColor, setDraftColor] = useState<string>(LABEL_COLORS[5])
218225
219226 async function add() {
@@ -225,8 +232,8 @@
225232
226233 return (
227234 <div className="space-y-2">
228 {failed ? <p className="text-[12px] text-[#b42318]">标签列表加载失败。</p> : null}
229 {labels.length === 0 && !failed ? <p className="text-[12.5px] text-shell-ink-muted">还没有标签。</p> : null}
235 {failed ? <p className="text-[12px] text-[#b42318]">{t('issue.board.manage.labelsFailed')}</p> : null}
236 {labels.length === 0 && !failed ? <p className="text-[12.5px] text-shell-ink-muted">{t('issue.board.manage.noLabels')}</p> : null}
230237 {labels.map(label => (
231238 <LabelRow key={label.id} boardRef={boardRef} label={label} busy={busy === `label-${label.id}`} onRun={onRun} />
232239 ))}
@@ -238,8 +245,9 @@
238245 onKeyDown={event => {
239246 if (event.key === 'Enter') void add()
240247 }}
241 placeholder="新标签名称"
242 aria-label="新标签名称"
248 placeholder={t('issue.board.manage.newLabel')}
249 aria-label={t('issue.board.manage.newLabel')}
250 name="new-label-name"
243251 maxLength={48}
244252 />
245253 <div className="flex shrink-0 items-center gap-1">
@@ -247,7 +255,7 @@
247255 <button
248256 key={color}
249257 type="button"
250 aria-label={`使用颜色 ${color}`}
258 aria-label={t('issue.board.manage.useColor', { color })}
251259 aria-pressed={draftColor === color}
252260 onClick={() => setDraftColor(color)}
253261 className={`h-5 w-5 rounded-full transition-transform ${draftColor === color ? 'scale-110 ring-2 ring-shell-ink' : ''}`}
@@ -261,7 +269,7 @@
261269 onClick={() => void add()}
262270 className="ml-auto shrink-0 rounded-md bg-shell-action px-3 py-1 text-[12.5px] font-semibold text-white transition-[filter] hover:brightness-95 disabled:opacity-50"
263271 >
264 添加
272 {t('issue.board.manage.add')}
265273 </button>
266274 </div>
267275 </div>
@@ -278,6 +286,7 @@
278286 busy: boolean
279287 onRun: Runner
280288}) {
289 const { t } = useT()
281290 const [name, setName] = useState(label.name)
282291 const [color, setColor] = useState(normalizeColor(label.color))
283292 const dirty = name.trim() !== label.name || color !== normalizeColor(label.color)
@@ -286,7 +295,7 @@
286295 <div className="flex items-center gap-2">
287296 <button
288297 type="button"
289 aria-label="选择颜色"
298 aria-label={t('issue.board.manage.selectColor')}
290299 onClick={() => setColor(LABEL_COLORS[(LABEL_COLORS.indexOf(color as never) + 1) % LABEL_COLORS.length] ?? LABEL_COLORS[0])}
291300 className="h-5 w-5 shrink-0 rounded-full ring-1 ring-shell-hairline"
292301 style={{ backgroundColor: color }}
@@ -294,7 +303,7 @@
294303 <input
295304 value={name}
296305 onChange={event => setName(event.target.value)}
297 aria-label="标签名称"
306 aria-label={t('issue.board.manage.labelName')}
298307 className={smallInputClass}
299308 maxLength={48}
300309 />
@@ -304,10 +313,10 @@
304313 onClick={() => void onRun(`label-${label.id}`, () => updateLabel(boardRef, label.id, { name: name.trim(), color }))}
305314 className="shrink-0 rounded px-2 py-1 text-[12px] text-shell-ink-muted transition-colors hover:bg-shell-field hover:text-shell-ink disabled:opacity-40"
306315 >
307 {busy ? '保存中…' : dirty ? '保存' : '已保存'}
316 {busy ? t('issue.board.manage.saving') : dirty ? t('common.action.save') : t('issue.board.manage.saved')}
308317 </button>
309318 <DangerButton
310 label="删除"
319 label={t('common.action.delete')}
311320 busy={busy}
312321 onConfirm={() => void onRun(`label-${label.id}`, () => deleteLabel(boardRef, label.id))}
313322 />
@@ -327,6 +336,7 @@
327336 onRun: Runner
328337 failed: boolean
329338}) {
339 const { t } = useT()
330340 const [name, setName] = useState('')
331341 const [due, setDue] = useState('')
332342
@@ -342,8 +352,8 @@
342352
343353 return (
344354 <div className="space-y-2">
345 {failed ? <p className="text-[12px] text-[#b42318]">里程碑列表加载失败。</p> : null}
346 {milestones.length === 0 && !failed ? <p className="text-[12.5px] text-shell-ink-muted">还没有里程碑。</p> : null}
355 {failed ? <p className="text-[12px] text-[#b42318]">{t('issue.board.manage.milestonesFailed')}</p> : null}
356 {milestones.length === 0 && !failed ? <p className="text-[12.5px] text-shell-ink-muted">{t('issue.board.manage.noMilestones')}</p> : null}
347357 {milestones.map(milestone => (
348358 <div key={milestone.id} className={`flex items-center gap-2 ${milestone.is_closed ? 'opacity-60' : ''}`}>
349359 <input
@@ -353,12 +363,13 @@
353363 updateMilestone(boardRef, milestone.id, { name: event.target.value }),
354364 )
355365 }
356 aria-label="里程碑名称"
366 aria-label={t('issue.board.manage.milestoneName')}
367 name="milestone-name"
357368 className={smallInputClass}
358369 maxLength={64}
359370 />
360371 <label className="flex shrink-0 items-center gap-1 text-[11.5px] text-shell-ink-muted">
361 截止
372 {t('issue.board.manage.due')}
362373 <input
363374 type="date"
364375 value={dateInputValue(milestone.due_at)}
@@ -381,10 +392,10 @@
381392 }
382393 className="shrink-0 rounded px-2 py-1 text-[12px] text-shell-ink-muted transition-colors hover:bg-shell-field hover:text-shell-ink"
383394 >
384 {milestone.is_closed ? '重新打开' : '关闭'}
395 {milestone.is_closed ? t('issue.board.reopen') : t('issue.board.manage.close')}
385396 </button>
386397 <DangerButton
387 label="删除"
398 label={t('common.action.delete')}
388399 busy={busy === `milestone-${milestone.id}`}
389400 onConfirm={() => void onRun(`milestone-${milestone.id}`, () => deleteMilestone(boardRef, milestone.id))}
390401 />
@@ -398,15 +409,15 @@
398409 onKeyDown={event => {
399410 if (event.key === 'Enter') void add()
400411 }}
401 placeholder="新里程碑名称"
402 aria-label="新里程碑名称"
412 placeholder={t('issue.board.manage.newMilestone')}
413 aria-label={t('issue.board.manage.newMilestone')}
403414 maxLength={64}
404415 />
405416 <input
406417 type="date"
407418 value={due}
408419 onChange={event => setDue(event.target.value)}
409 aria-label="里程碑截止日期"
420 aria-label={t('issue.board.manage.milestoneDue')}
410421 className="h-7 shrink-0 rounded-md border-2 border-shell-hairline bg-shell-field px-2 text-[12px] outline-none"
411422 />
412423 <button
@@ -415,7 +426,7 @@
415426 onClick={() => void add()}
416427 className="ml-auto shrink-0 rounded-md bg-shell-action px-3 py-1 text-[12.5px] font-semibold text-white transition-[filter] hover:brightness-95 disabled:opacity-50"
417428 >
418 添加
429 {t('issue.board.manage.add')}
419430 </button>
420431 </div>
421432 </div>
Modified web/src/features/issue-board/ui/board-polish.css View web/src/features/issue-board/ui/board-polish.css
@@ -165,8 +165,8 @@
165165 .issue-dialog-overlay { padding: 12px; }
166166 .issue-form-metadata { grid-template-columns: 1fr; }
167167 .issue-dialog-content .flex:has(> input[type='date']) { flex-wrap: wrap; }
168 .issue-dialog-content .flex:has(> input[aria-label='新标签名称']) { flex-wrap: wrap; }
169 .issue-dialog-content input[aria-label='里程碑名称'] { min-width: 150px; }
168 .issue-dialog-content .flex:has(> input[name='new-label-name']) { flex-wrap: wrap; }
169 .issue-dialog-content input[name='milestone-name'] { min-width: 150px; }
170170}
171171@media (prefers-reduced-motion: reduce) {
172172 .issue-board-skeleton { animation: none; }
Modified web/src/features/issue-board/ui/dialog.tsx View web/src/features/issue-board/ui/dialog.tsx
@@ -1,5 +1,6 @@
11import { useEffect, useId, useRef, type ReactNode } from 'react'
22import { X } from 'lucide-react'
3import { useT } from '@/shared/i18n'
34
45/**
56 * Feature-local dialog chrome (overlay + panel + Escape/outside-click close),
@@ -23,6 +24,7 @@
2324 children: ReactNode
2425 footer?: ReactNode
2526}) {
27 const { t } = useT()
2628 const panel = useRef<HTMLDivElement>(null)
2729 const closeRef = useRef(onClose)
2830 const titleId = useId()
@@ -89,7 +91,7 @@
8991 </div>
9092 <button
9193 type="button"
92 aria-label="关闭"
94 aria-label={t('issue.board.closeDialog')}
9395 onClick={onClose}
9496 className="issue-dialog-close shrink-0 text-shell-ink-muted transition-colors hover:text-shell-ink"
9597 >
Modified web/src/features/issue-board/ui/issue-board.tsx View web/src/features/issue-board/ui/issue-board.tsx
@@ -11,6 +11,7 @@
1111import { ArrowRight, CircleCheck, Inbox, Loader2, Plus } from 'lucide-react'
1212import './board-polish.css'
1313import { ErrorPanel } from '@/shared/ui'
14import { useT } from '@/shared/i18n'
1415import {
1516 PRIORITIES,
1617 priorityOf,
@@ -42,6 +43,7 @@
4243const PAGE_SIZE_FALLBACK = 50
4344
4445export function IssueBoardBody(props: IssueBoardBodyProps) {
46 const { t, tc } = useT()
4547 const { data, view } = props
4648 const state = data.issues
4749 const failure = state.error as { code?: number; message?: string } | undefined
@@ -56,7 +58,7 @@
5658 <div className="w-full rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
5759 <ErrorPanel
5860 status={typeof failure.code === 'number' ? failure.code : 0}
59 what="工单列表"
61 what={t('issue.board.errorList')}
6062 note={failure.message}
6163 onRetry={props.onRetry}
6264 />
@@ -72,14 +74,14 @@
7274 return (
7375 <div className="flex w-full flex-col items-center gap-3 rounded-lg border-2 border-shell-hairline bg-shell-surface px-4 py-10 text-center">
7476 <p className="text-[13px]">
75 第 {view.page} 页超出范围:共 {total} 个工单,每页 {limit} 条。
77 {tc('issue.board.pageOutOfRange', total, { page: view.page, total, limit })}
7678 </p>
7779 <button
7880 type="button"
7981 onClick={() => props.onFilter({ page: 1 })}
8082 className="rounded-md border-2 border-shell-hairline bg-shell-field px-4 py-1.5 text-[13px] transition-colors hover:border-shell-ink"
8183 >
82 回到第 1 页
84 {t('issue.board.backToFirst')}
8385 </button>
8486 </div>
8587 )
@@ -90,15 +92,15 @@
9092 if (rows.length === 0) {
9193 return props.filtered ? (
9294 <EmptyNote
93 title="没有符合条件的工单"
94 hint="调整或清除上方筛选后重试。"
95 action={{ label: '清除筛选', onClick: props.onClear }}
95 title={t('issue.board.emptyFilteredTitle')}
96 hint={t('issue.board.emptyFilteredHint')}
97 action={{ label: t('issue.filter.clear'), onClick: props.onClear }}
9698 />
9799 ) : (
98100 <EmptyNote
99 title="这个问题集还没有工单"
100 hint="把想法变成清晰的下一步,从一个工单开始。"
101 action={{ label: '新建工单', onClick: () => props.onCreate() }}
101 title={t('issue.board.emptyTitle')}
102 hint={t('issue.board.emptyHint')}
103 action={{ label: t('issue.board.new'), onClick: () => props.onCreate() }}
102104 />
103105 )
104106 }
@@ -107,7 +109,7 @@
107109 <div className="issue-board-body flex min-h-0 w-full flex-col gap-2">
108110 {rows.length !== loaded.length ? (
109111 <p className="text-[11.5px] text-shell-ink-muted">
110 「未设里程碑」按当前页筛选(后端不支持该条件),共 {total} 个工单中另有 {loaded.length - rows.length} 条被隐藏。
112 {t('issue.board.milestoneClipped', { total, hidden: loaded.length - rows.length })}
111113 </p>
112114 ) : null}
113115 {view.view === 'board' ? (
@@ -118,7 +120,7 @@
118120 <Pager view={view} total={total} limit={limit} onFilter={props.onFilter} />
119121 {state.loading ? (
120122 <p className="flex items-center gap-1.5 text-[11.5px] text-shell-ink-muted">
121 <Loader2 className="h-3 w-3 animate-spin" aria-hidden /> 正在刷新…
123 <Loader2 className="h-3 w-3 animate-spin" aria-hidden /> {t('issue.board.refreshing')}
122124 </p>
123125 ) : null}
124126 </div>
@@ -143,6 +145,7 @@
143145 // jumps back. The overrides are stamped with the row set they were painted
144146 // over: as soon as the data changes (reload, page switch) they expire on
145147 // their own, which keeps a failed write from sticking forever.
148 const { t, tc } = useT()
146149 const stamp = rows.map(row => row.id).join(',')
147150 const [override, setOverride] = useState<{
148151 stamp: string
@@ -168,7 +171,7 @@
168171 setOverride({
169172 stamp,
170173 moves: next,
171 error: `移动到「${priorityOf(priority).label}」失败:${(err as Error)?.message ?? '未知错误'}`,
174 error: t('issue.board.moveFailed', { priority: priorityOf(priority).label, reason: (err as Error)?.message ?? t('common.state.unknown') }),
172175 })
173176 }
174177 }
@@ -180,7 +183,7 @@
180183 return (
181184 <section
182185 key={meta.id}
183 aria-label={`${meta.label}优先级,${cards.length} 个工单`}
186 aria-label={tc('issue.board.columnAria', cards.length, { priority: meta.label, count: cards.length })}
184187 data-priority={meta.id}
185188 data-drag-over={over === meta.id || undefined}
186189 onDragOver={event => {
@@ -206,8 +209,8 @@
206209 <span className="issue-board-count text-[12px] text-shell-ink-muted">{cards.length}</span>
207210 <button
208211 type="button"
209 aria-label={`在「${meta.label}」新建工单`}
210 title={`在「${meta.label}」新建工单`}
212 aria-label={t('issue.board.addInColumn', { column: meta.label })}
213 title={t('issue.board.addInColumn', { column: meta.label })}
211214 onClick={() => onCreate({ priority: meta.id })}
212215 className="issue-board-add ml-auto rounded px-1 text-[15px] leading-none text-shell-ink-muted transition-colors hover:bg-shell-active hover:text-shell-ink"
213216 >
@@ -230,8 +233,8 @@
230233 {cards.length === 0 ? (
231234 <button type="button" onClick={() => onCreate({ priority: meta.id })} className="issue-board-column-empty">
232235 <Plus className="h-5 w-5" aria-hidden />
233 <span>添加一个工单</span>
234 <small>也可以将卡片拖到这里</small>
236 <span>{t('issue.board.addCard')}</span>
237 <small>{t('issue.board.addCardHint')}</small>
235238 </button>
236239 ) : null}
237240 </div>
@@ -264,6 +267,7 @@
264267 onDragStart?: () => void
265268 onDragEnd?: () => void
266269}) {
270 const { t } = useT()
267271 return (
268272 <article
269273 draggable={draggable}
@@ -283,7 +287,7 @@
283287 }}
284288 role="button"
285289 tabIndex={0}
286 aria-label={`工单 #${issue.number} ${issue.title}`}
290 aria-label={t('issue.board.cardAria', { number: issue.number, title: issue.title })}
287291 className="issue-board-card cursor-pointer space-y-2 rounded-lg bg-shell-surface p-3 shadow-[0_1px_2px_rgb(28_30_34/8%)] outline-none transition-shadow hover:shadow-[0_2px_8px_rgb(28_30_34/14%)] focus-visible:ring-2 focus-visible:ring-shell-tile"
288292 >
289293 <div className="flex items-start gap-2">
@@ -293,14 +297,14 @@
293297 <div className="issue-board-card-meta flex items-center gap-2">
294298 <span className="font-mono text-[11px] text-shell-ink-muted">#{issue.number}</span>
295299 {milestone ? (
296 <span className="truncate text-[11px] text-shell-ink-muted" title={`里程碑:${milestone.name}`}>
300 <span className="truncate text-[11px] text-shell-ink-muted" title={t('issue.board.milestoneTitle', { name: milestone.name })}>
297301 {milestone.name}
298302 </span>
299303 ) : null}
300304 <DueChip due={issue.due_at} state={issue.state} />
301305 <span className="ml-auto flex items-center gap-1.5">
302306 <AvatarStack users={issue.assignees} size={23} />
303 {issue.state === 'closed' ? <CircleCheck className="h-3.5 w-3.5 text-shell-ink-muted" aria-label="已关闭" /> : null}
307 {issue.state === 'closed' ? <CircleCheck className="h-3.5 w-3.5 text-shell-ink-muted" aria-label={t('label.issueState.closed')} /> : null}
304308 </span>
305309 </div>
306310 </article>
@@ -315,19 +319,20 @@
315319 onOpenIssue,
316320 onChanged,
317321}: IssueBoardBodyProps & { rows: IssueListItem[] }) {
322 const { t } = useT()
318323 return (
319324 <div className="issue-board-table min-w-0 overflow-x-auto rounded-lg border-2 border-shell-hairline bg-shell-surface">
320325 <table className="w-full border-collapse text-[13px]">
321 <caption className="sr-only">工单列表,可选择标题查看详情</caption>
326 <caption className="sr-only">{t('issue.board.tableCaption')}</caption>
322327 <thead className="bg-shell-field text-[12px] text-shell-ink-muted">
323328 <tr>
324 <th className="w-10 px-2 py-2 text-left font-medium">状态</th>
325 <th className="w-16 px-2 py-2 text-left font-medium">编号</th>
326 <th className="px-2 py-2 text-left font-medium">标题</th>
327 <th className="w-24 px-2 py-2 text-left font-medium">优先级</th>
328 <th className="w-28 px-2 py-2 text-left font-medium">里程碑</th>
329 <th className="w-24 px-2 py-2 text-left font-medium">负责人</th>
330 <th className="w-28 px-2 py-2 text-left font-medium">更新</th>
329 <th className="w-10 px-2 py-2 text-left font-medium">{t('issue.board.col.state')}</th>
330 <th className="w-16 px-2 py-2 text-left font-medium">{t('issue.board.col.number')}</th>
331 <th className="px-2 py-2 text-left font-medium">{t('issue.board.col.title')}</th>
332 <th className="w-24 px-2 py-2 text-left font-medium">{t('issue.board.col.priority')}</th>
333 <th className="w-28 px-2 py-2 text-left font-medium">{t('issue.board.col.milestone')}</th>
334 <th className="w-24 px-2 py-2 text-left font-medium">{t('issue.board.col.assignee')}</th>
335 <th className="w-28 px-2 py-2 text-left font-medium">{t('issue.board.col.updated')}</th>
331336 </tr>
332337 </thead>
333338 <tbody>
@@ -382,13 +387,14 @@
382387 busy: boolean
383388 onChange: (priority: IssuePriority) => void
384389}) {
390 const { t } = useT()
385391 return (
386392 <label className="flex items-center gap-1" onClick={event => event.stopPropagation()}>
387393 <select
388394 value={value}
389395 disabled={busy}
390396 onChange={event => onChange(event.target.value as IssuePriority)}
391 aria-label="修改优先级"
397 aria-label={t('issue.board.changePriority')}
392398 className="h-6 max-w-[92px] rounded border-2 border-shell-hairline bg-shell-field px-1 text-[12px] outline-none focus-visible:border-shell-ink"
393399 >
394400 {PRIORITIES.map(meta => (
@@ -414,21 +420,20 @@
414420 limit: number
415421 onFilter: (updates: Partial<IssueBoardView>) => void
416422}) {
423 const { t, tc } = useT()
417424 const pages = Math.max(1, Math.ceil(total / limit))
418425 if (pages <= 1) return null
419426 const page = Math.min(view.page, pages)
420427 return (
421428 <div className="flex shrink-0 items-center justify-end gap-2 text-[12.5px] text-shell-ink-muted">
422 <span>
423 第 {page} / {pages} 页 · 共 {total} 个工单
424 </span>
429 <span>{tc('issue.board.pager', total, { page, pages, total })}</span>
425430 <button
426431 type="button"
427432 disabled={page <= 1}
428433 onClick={() => onFilter({ page: page - 1 })}
429434 className="rounded-md border-2 border-shell-hairline bg-shell-field px-2 py-1 transition-colors hover:border-shell-ink disabled:opacity-40"
430435 >
431 上一页
436 {t('issue.board.prev')}
432437 </button>
433438 <button
434439 type="button"
@@ -436,15 +441,16 @@
436441 onClick={() => onFilter({ page: page + 1 })}
437442 className="rounded-md border-2 border-shell-hairline bg-shell-field px-2 py-1 transition-colors hover:border-shell-ink disabled:opacity-40"
438443 >
439 下一页
444 {t('issue.board.next')}
440445 </button>
441446 </div>
442447 )
443448}
444449
445450function BoardSkeleton({ columns }: { columns: number }) {
451 const { t } = useT()
446452 return (
447 <div className="issue-board-columns issue-board-skeleton flex min-h-0 flex-1 gap-3" role="status" aria-label="正在加载工单" aria-busy>
453 <div className="issue-board-columns issue-board-skeleton flex min-h-0 flex-1 gap-3" role="status" aria-label={t('issue.board.loading')} aria-busy>
448454 {Array.from({ length: columns }, (_, index) => (
449455 <section key={index} className="issue-board-column flex h-full w-[266px] shrink-0 flex-col gap-2 rounded-lg bg-shell-board p-2.5">
450456 <div className="h-6 w-24 rounded-full bg-shell-active" />
Modified web/src/features/issue-board/ui/issue-detail.tsx View web/src/features/issue-board/ui/issue-detail.tsx
@@ -7,10 +7,10 @@
77import { useEffect, useState } from 'react'
88import { ArrowLeft, Link2, Loader2, Pencil, Tag, Users } from 'lucide-react'
99import { fmtTime, issueKey, useAsync } from '@/shared/lib'
10import { t as tr, useT } from '@/shared/i18n'
1011import { ErrorPanel, PanelNote } from '@/shared/ui'
1112import {
1213 PRIORITIES,
13 WATCH_LABELS,
1414 bindLabel,
1515 createComment,
1616 dateApiValue,
@@ -37,6 +37,12 @@
3737import { listIssuePulls, linkIssuePull, unlinkIssuePull, type IssuePullLink } from '@/entities/pull-request'
3838import type { BoardData } from '../model/use-board-data'
3939
40const WATCH_LABEL_KEYS: Record<WatchMode, 'issue.watch.watching' | 'issue.watch.unwatched' | 'issue.watch.ignore'> = {
41 watching: 'issue.watch.watching',
42 unwatched: 'issue.watch.unwatched',
43 ignore: 'issue.watch.ignore',
44}
45
4046export interface IssueDetailProps {
4147 boardRef: BoardRef
4248 /** Board-scoped number or uuid, straight from the route. */
@@ -52,6 +58,7 @@
5258}
5359
5460export function IssueDetail({boardRef, issueId, reloadKey, data, viewerId, onBack, onChanged, onEdit }: IssueDetailProps) {
61 const { t, tc } = useT()
5562 const [retry, setRetry] = useState(0)
5663 const [notice, setNotice] = useState<string | null>(null)
5764 const [showActivity, setShowActivity] = useState(false)
@@ -84,12 +91,12 @@
8491 <div className="rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
8592 <p className="mb-2 flex items-center gap-2 text-[13px] text-shell-ink-muted">
8693 <button type="button" onClick={onBack} className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-shell-active">
87 <ArrowLeft className="h-3.5 w-3.5" aria-hidden /> 返回看板
94 <ArrowLeft className="h-3.5 w-3.5" aria-hidden /> {t('issue.board.backToBoard')}
8895 </button>
8996 </p>
9097 <ErrorPanel
9198 status={typeof failure.code === 'number' ? failure.code : 0}
92 what="工单"
99 what={t('issue.board.errorIssue')}
93100 note={failure.message}
94101 onRetry={() => setRetry(v => v + 1)}
95102 />
@@ -100,7 +107,7 @@
100107 if (!issue) {
101108 return (
102109 <div className="flex items-center gap-2 rounded-lg border-2 border-shell-hairline bg-shell-surface px-4 py-10 text-[13px] text-shell-ink-muted">
103 <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> 正在加载工单…
110 <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> {t('issue.board.loadingIssue')}
104111 </div>
105112 )
106113 }
@@ -115,7 +122,7 @@
115122 onChanged()
116123 return true
117124 } catch (err) {
118 setNotice((err as Error)?.message ? `操作失败:${(err as Error).message}` : '操作失败,请稍后重试。')
125 setNotice((err as Error)?.message ? t('issue.board.actionFailed', { reason: (err as Error).message }) : t('issue.board.actionFailedGeneric'))
119126 return false
120127 }
121128 }
@@ -137,7 +144,7 @@
137144 onClick={onBack}
138145 className="flex items-center gap-1 rounded-md px-2 py-1 text-[13px] transition-colors hover:bg-shell-active"
139146 >
140 <ArrowLeft className="h-4 w-4" aria-hidden /> 返回看板
147 <ArrowLeft className="h-4 w-4" aria-hidden /> {t('issue.board.backToBoard')}
141148 </button>
142149 <span className="font-mono text-[13px] text-shell-ink-muted">#{issue.number}</span>
143150 <StateBadge state={issue.state} />
@@ -148,26 +155,26 @@
148155 onClick={() => onEdit(issue)}
149156 className="flex items-center gap-1 rounded-md bg-shell-field px-2.5 py-1 text-[12.5px] transition-colors hover:brightness-95"
150157 >
151 <Pencil className="h-3.5 w-3.5" aria-hidden /> 编辑
158 <Pencil className="h-3.5 w-3.5" aria-hidden /> {t('issue.board.edit')}
152159 </button>
153160 {issue.state === 'open' ? (
154161 <button
155162 type="button"
156 onClick={() => void run(() => closeIssue(boardRef, issue.id), '已关闭该工单。')}
163 onClick={() => void run(() => closeIssue(boardRef, issue.id), t('issue.board.closedIssue'))}
157164 className="rounded-md bg-shell-action px-2.5 py-1 text-[12.5px] font-semibold text-white transition-[filter] hover:brightness-95"
158165 >
159 关闭工单
166 {t('issue.board.closeIssue')}
160167 </button>
161168 ) : (
162169 <button
163170 type="button"
164 onClick={() => void run(() => reopenIssue(boardRef, issue.id), '已重新打开。')}
171 onClick={() => void run(() => reopenIssue(boardRef, issue.id), t('issue.board.reopenedIssue'))}
165172 className="rounded-md bg-shell-field px-2.5 py-1 text-[12.5px] font-semibold transition-colors hover:brightness-95"
166173 >
167 重新打开
174 {t('issue.board.reopen')}
168175 </button>
169176 )}
170 <DangerButton label="删除" onConfirm={() => void deleteAndBack(issue.id)} />
177 <DangerButton label={t('common.action.delete')} onConfirm={() => void deleteAndBack(issue.id)} />
171178 </div>
172179 </div>
173180
@@ -183,15 +190,15 @@
183190 {issue.content ? (
184191 <p className="mt-3 whitespace-pre-wrap text-[13.5px] leading-6 break-words">{issue.content}</p>
185192 ) : !issue.description ? (
186 <p className="mt-3 text-[12.5px] text-shell-ink-muted">该工单没有正文描述。</p>
193 <p className="mt-3 text-[12.5px] text-shell-ink-muted">{t('issue.board.noBody')}</p>
187194 ) : null}
188195 <p className="mt-4 flex flex-wrap items-center gap-2 border-t-2 border-shell-hairline pt-3 text-[12px] text-shell-ink-muted">
189196 {issue.author ? <UserAvatar user={issue.author} size={20} /> : null}
190 <span>{issue.author?.display_name ?? '未知作者'} 创建于 {fmtTime(issue.created_at)}</span>
197 <span>{t('issue.board.createdAt', { author: issue.author?.display_name ?? t('issue.board.unknownAuthor'), time: fmtTime(issue.created_at) })}</span>
191198 {issue.updated_at && issue.updated_at !== issue.created_at ? (
192 <span>· 更新于 {fmtTime(issue.updated_at)}</span>
199 <span>· {t('issue.board.updatedAt', { time: fmtTime(issue.updated_at) })}</span>
193200 ) : null}
194 {issue.closed_at ? <span>· 关闭于 {fmtTime(issue.closed_at)}</span> : null}
201 {issue.closed_at ? <span>· {t('issue.board.closedAt', { time: fmtTime(issue.closed_at) })}</span> : null}
195202 </p>
196203 </article>
197204
@@ -213,9 +220,9 @@
213220 aria-expanded={showActivity}
214221 >
215222 <Link2 className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
216 活动记录
217 <span className="text-[12px] font-normal text-shell-ink-muted">{events.data?.total ?? 0} 条</span>
218 <span className="ml-auto text-[12px] font-normal text-shell-ink-muted">{showActivity ? '收起' : '展开'}</span>
223 {t('issue.board.activity')}
224 <span className="text-[12px] font-normal text-shell-ink-muted">{tc('issue.board.activityCount', events.data?.total ?? 0)}</span>
225 <span className="ml-auto text-[12px] font-normal text-shell-ink-muted">{showActivity ? t('issue.board.collapse') : t('issue.board.expand')}</span>
219226 </button>
220227 {showActivity ? (
221228 <ActivityList events={events.data?.items ?? []} loading={events.loading && !events.data} failed={Boolean(events.error)} />
@@ -224,26 +231,26 @@
224231 </div>
225232
226233 <aside className="issue-detail-sidebar w-[240px] shrink-0 space-y-3.5">
227 <Field label="优先级">
234 <Field label={t('issue.board.filter.priority')}>
228235 <FilterMenu
229 label="优先级"
236 label={t('issue.board.filter.priority')}
230237 value={issue.priority}
231238 options={PRIORITIES.map(meta => ({ value: meta.id, label: meta.label, color: meta.color }))}
232239 onChange={value => void run(() => updateIssue(boardRef, issue.id, { priority: value as never }))}
233240 />
234241 </Field>
235242
236 <Field label="里程碑">
243 <Field label={t('issue.board.filter.milestone')}>
237244 <FilterMenu
238 label="里程碑"
239 placeholder="未设置"
245 label={t('issue.board.filter.milestone')}
246 placeholder={t('issue.board.unset')}
240247 value={issue.milestone_id ?? ''}
241248 options={milestoneOptions.map(m => ({ value: m.id, label: m.name }))}
242249 onChange={value => void run(() => updateIssue(boardRef, issue.id, { milestone_id: value || null }))}
243250 />
244251 </Field>
245252
246 <Field label="截止日期">
253 <Field label={t('issue.board.filter.due')}>
247254 <input
248255 type="date"
249256 value={dateInputValue(issue.due_at)}
@@ -258,12 +265,12 @@
258265 onClick={() => void run(() => updateIssue(boardRef, issue.id, { due_at: null }))}
259266 className="mt-1 text-[11.5px] text-shell-ink-muted hover:text-shell-ink"
260267 >
261 清除日期
268 {t('issue.board.clearDate')}
262269 </button>
263270 ) : null}
264271 </Field>
265272
266 <Field label="负责人">
273 <Field label={t('issue.board.filter.assignee')}>
267274 <AssigneePicker
268275 members={data.members.data?.items ?? []}
269276 selected={issue.assignees.map(user => user.user_id)}
@@ -272,7 +279,7 @@
272279 />
273280 </Field>
274281
275 <Field label="标签">
282 <Field label={t('issue.board.labelsField')}>
276283 <div className="flex flex-wrap gap-1">
277284 {boundLabels.map(label => (
278285 <LabelChip
@@ -283,7 +290,7 @@
283290 ))}
284291 {boundLabels.length === 0 ? (
285292 <span className="text-[12px] text-shell-ink-muted">
286 {labels.error ? '标签加载失败' : '未打标签'}
293 {labels.error ? t('issue.board.labelsFailed') : t('issue.board.noLabels')}
287294 </span>
288295 ) : null}
289296 </div>
@@ -296,17 +303,17 @@
296303 />
297304 </Field>
298305
299 <Field label="关联合并请求">
306 <Field label={t('issue.board.linkedPulls')}>
300307 <IssuePullLinks boardRef={boardRef} issueId={issue.id} onChanged={() => void onChanged()} />
301308 </Field>
302309
303 <Field label="通知">
310 <Field label={t('issue.board.notifyField')}>
304311 <FilterMenu
305 label="通知"
312 label={t('issue.board.notifyField')}
306313 value={issue.viewer_watch ?? 'unwatched'}
307 options={(Object.keys(WATCH_LABELS) as WatchMode[]).map(mode => ({
314 options={(Object.keys(WATCH_LABEL_KEYS) as WatchMode[]).map(mode => ({
308315 value: mode,
309 label: WATCH_LABELS[mode],
316 label: t(WATCH_LABEL_KEYS[mode]),
310317 }))}
311318 onChange={value => void run(() => setIssueWatch(boardRef, issue.id, value as WatchMode))}
312319 />
@@ -314,7 +321,7 @@
314321
315322 {issue.milestone_id ? (
316323 <p className="text-[11.5px] text-shell-ink-muted">
317 里程碑 {milestoneOptions.find(m => m.id === issue.milestone_id)?.name ?? '已关闭或已删除'}
324 {t('issue.board.milestoneLabel', { name: milestoneOptions.find(m => m.id === issue.milestone_id)?.name ?? t('issue.board.milestoneUnknown') })}
318325 </p>
319326 ) : null}
320327 </aside>
@@ -328,7 +335,7 @@
328335 onChanged()
329336 onBack()
330337 } catch (err) {
331 setNotice(`删除失败:${(err as Error)?.message ?? '未知错误'}`)
338 setNotice(t('issue.board.deleteFailed', { reason: (err as Error)?.message ?? t('common.state.unknown') }))
332339 }
333340 }
334341}
@@ -337,6 +344,7 @@
337344 * 工单侧的 PR 关联面板:列出关联(含 relation id,可解除)并支持新增关联。
338345 */
339346function IssuePullLinks({ boardRef, issueId, onChanged }: { boardRef: BoardRef; issueId: string; onChanged: () => void }) {
347 const { t } = useT()
340348 const [tick, setTick] = useState(0)
341349 const [repoName, setRepoName] = useState('')
342350 const [pullRef, setPullRef] = useState('')
@@ -359,7 +367,7 @@
359367 setTick(t => t + 1)
360368 onChanged()
361369 } catch (f) {
362 setError(f instanceof Error ? f.message : '关联失败')
370 setError(f instanceof Error ? f.message : t('issue.board.linkFailed'))
363371 } finally {
364372 setBusy(false)
365373 }
@@ -372,12 +380,12 @@
372380 <Link2 className="mt-1 inline h-3 w-3 shrink-0 text-shell-ink-muted" aria-hidden />
373381 <span className="min-w-0 flex-1 truncate" title={link.pull.title}>
374382 #{link.pull.number} {link.pull.title}
375 <span className="ml-1 text-shell-ink-muted">({link.pull.state === 'open' ? '进行中' : link.pull.state === 'merged' ? '已合并' : '已关闭'})</span>
383 <span className="ml-1 text-shell-ink-muted">{t('issue.board.pullState', { state: link.pull.state === 'open' ? t('issue.board.stateOpen') : link.pull.state === 'merged' ? t('pull.state.merged') : t('pull.state.closed') })}</span>
376384 </span>
377385 <button
378386 type="button"
379 aria-label="解除关联"
380 title="解除关联"
387 aria-label={t('issue.board.unlink')}
388 title={t('issue.board.unlink')}
381389 className="shrink-0 text-shell-ink-muted hover:text-[#b42318]"
382390 onClick={() =>
383391 void unlinkIssuePull(boardRef.space, boardRef.board, issueId, link.relation_id)
@@ -389,12 +397,12 @@
389397 </button>
390398 </p>
391399 ))}
392 {rows.length === 0 ? <p className="text-[12px] text-shell-ink-muted">尚未关联合并请求。</p> : null}
400 {rows.length === 0 ? <p className="text-[12px] text-shell-ink-muted">{t('issue.board.noPullLinks')}</p> : null}
393401 <div className="flex gap-1">
394 <input value={repoName} onChange={event => setRepoName(event.target.value)} placeholder="仓库名" className="h-7 min-w-0 flex-1 rounded-md border border-shell-hairline bg-shell-field px-2 text-[12px] outline-none focus-visible:border-shell-ink" />
395 <input value={pullRef} onChange={event => setPullRef(event.target.value)} placeholder="PR 编号" className="h-7 w-16 rounded-md border border-shell-hairline bg-shell-field px-2 text-[12px] outline-none focus-visible:border-shell-ink" />
402 <input value={repoName} onChange={event => setRepoName(event.target.value)} placeholder={t('issue.board.repoPlaceholder')} className="h-7 min-w-0 flex-1 rounded-md border border-shell-hairline bg-shell-field px-2 text-[12px] outline-none focus-visible:border-shell-ink" />
403 <input value={pullRef} onChange={event => setPullRef(event.target.value)} placeholder={t('issue.board.pullPlaceholder')} className="h-7 w-16 rounded-md border border-shell-hairline bg-shell-field px-2 text-[12px] outline-none focus-visible:border-shell-ink" />
396404 <button type="button" disabled={busy || !repoName.trim() || !pullRef.trim()} onClick={() => void link()} className="h-7 shrink-0 rounded-md bg-shell-active px-2 text-[12px] font-medium disabled:opacity-50">
397 关联
405 {t('issue.board.link')}
398406 </button>
399407 </div>
400408 {error ? <p className="text-[11.5px] text-[#b42318]">{error}</p> : null}
@@ -423,7 +431,7 @@
423431 onChange: (ids: string[]) => void
424432}) {
425433 if (members.length === 0) {
426 return <span className="text-[12px] text-shell-ink-muted">没有可选成员</span>
434 return <span className="text-[12px] text-shell-ink-muted">{tr('issue.board.noMembers')}</span>
427435 }
428436 return (
429437 <div className="w-full space-y-1">
@@ -446,7 +454,7 @@
446454 onChange(on ? selected.filter(id => id !== user.user_id) : [...selected, user.user_id])
447455 }
448456 className="h-3.5 w-3.5 accent-[#2f3438]"
449 aria-label={`指派给 ${user.display_name || user.username}`}
457 aria-label={tr('issue.board.assignAria', { name: user.display_name || user.username })}
450458 />
451459 <UserAvatar user={user} size={18} />
452460 <span className="min-w-0 flex-1 truncate">{user.display_name || user.username}</span>
@@ -476,7 +484,7 @@
476484 onClick={() => setOpen(v => !v)}
477485 className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[12px] text-shell-ink-muted transition-colors hover:bg-shell-field hover:text-shell-ink"
478486 >
479 <Tag className="h-3 w-3" aria-hidden /> {open ? '完成' : '添加标签'}
487 <Tag className="h-3 w-3" aria-hidden /> {open ? tr('issue.board.done') : tr('issue.board.addLabel')}
480488 </button>
481489 {open && missing.length > 0 ? (
482490 <div className="mt-1 max-h-[180px] space-y-0.5 overflow-y-auto rounded-md border-2 border-shell-hairline bg-shell-surface p-1">
@@ -494,7 +502,7 @@
494502 </div>
495503 ) : null}
496504 {open && missing.length === 0 ? (
497 <p className="mt-1 text-[11.5px] text-shell-ink-muted">所有标签都已绑定。</p>
505 <p className="mt-1 text-[11.5px] text-shell-ink-muted">{tr('issue.board.allLabelsBound')}</p>
498506 ) : null}
499507 </div>
500508 )
@@ -532,7 +540,7 @@
532540 setDraft('')
533541 onReload()
534542 } catch (err) {
535 setError((err as Error)?.message ?? '评论发送失败。')
543 setError((err as Error)?.message ?? tr('issue.board.commentSendFailed'))
536544 } finally {
537545 setBusy(false)
538546 }
@@ -540,9 +548,9 @@
540548
541549 return (
542550 <section className="space-y-3 rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
543 <h3 className="text-[13px] font-semibold">评论</h3>
544 {loading ? <PanelNote text="加载评论…" /> : null}
545 {failed && !loading ? <PanelNote text="评论加载失败,重新打开工单可重试。" /> : null}
551 <h3 className="text-[13px] font-semibold">{tr('issue.board.comments')}</h3>
552 {loading ? <PanelNote text={tr('issue.board.loadingComments')} /> : null}
553 {failed && !loading ? <PanelNote text={tr('issue.board.commentsFailed')} /> : null}
546554 {nodes.map(node => (
547555 <CommentNodeView
548556 key={node.comment.id}
@@ -554,7 +562,7 @@
554562 />
555563 ))}
556564 {nodes.length === 0 && !loading && !failed ? (
557 <p className="text-[12.5px] text-shell-ink-muted">还没有评论。</p>
565 <p className="text-[12.5px] text-shell-ink-muted">{tr('issue.board.noComments')}</p>
558566 ) : null}
559567 {error ? <p role="alert" className="text-[12px] text-[#b42318]">{error}</p> : null}
560568 <div className="space-y-2">
@@ -565,8 +573,8 @@
565573 if ((event.metaKey || event.ctrlKey) && event.key === 'Enter' && !event.nativeEvent.isComposing) void submit()
566574 }}
567575 rows={3}
568 placeholder="添加评论…(Ctrl+Enter 发送)"
569 aria-label="新评论"
576 placeholder={tr('issue.board.commentPlaceholder')}
577 aria-label={tr('issue.board.newCommentAria')}
570578 className="w-full resize-y rounded-md border-2 border-shell-hairline bg-shell-field p-2.5 text-[13px] leading-5 outline-none focus-visible:border-shell-ink"
571579 />
572580 <div className="flex items-center justify-end gap-2">
@@ -577,7 +585,7 @@
577585 onClick={() => void submit()}
578586 className="rounded-md bg-shell-action px-3 py-1.5 text-[12.5px] font-semibold text-white transition-[filter] hover:brightness-95 disabled:opacity-50"
579587 >
580 发表评论
588 {tr('issue.board.submitComment')}
581589 </button>
582590 </div>
583591 </div>
@@ -614,7 +622,7 @@
614622 setEditing(false)
615623 onReload()
616624 } catch (err) {
617 setError((err as Error)?.message ?? '操作失败。')
625 setError((err as Error)?.message ?? tr('issue.board.actionFailedGeneric'))
618626 } finally {
619627 setBusy(false)
620628 }
@@ -624,10 +632,10 @@
624632 <div className="rounded-md bg-shell-board p-3">
625633 <div className="flex items-center gap-2">
626634 {comment.poster ? <UserAvatar user={comment.poster} size={20} /> : null}
627 <span className="text-[12.5px] font-semibold">{comment.poster?.display_name ?? '未知用户'}</span>
635 <span className="text-[12.5px] font-semibold">{comment.poster?.display_name ?? tr('common.state.unknownUser')}</span>
628636 <span className="text-[11.5px] text-shell-ink-muted">{fmtTime(comment.created_at)}</span>
629637 {comment.updated_at && comment.updated_at !== comment.created_at ? (
630 <span className="text-[11px] text-shell-ink-muted">已编辑</span>
638 <span className="text-[11px] text-shell-ink-muted">{tr('issue.board.edited')}</span>
631639 ) : null}
632640 <span className="ml-auto flex items-center gap-1">
633641 {mine ? (
@@ -636,12 +644,12 @@
636644 onClick={() => setEditing(v => !v)}
637645 className="rounded px-1.5 py-0.5 text-[11.5px] text-shell-ink-muted transition-colors hover:bg-shell-active hover:text-shell-ink"
638646 >
639 {editing ? '取消' : '编辑'}
647 {editing ? tr('common.action.cancel') : tr('common.action.edit')}
640648 </button>
641649 ) : null}
642650 {mine ? (
643651 <DangerButton
644 label="删除"
652 label={tr('common.action.delete')}
645653 busy={busy}
646654 onConfirm={() => void act(() => deleteComment(boardRef, issueId, comment.id))}
647655 />
@@ -654,7 +662,7 @@
654662 value={text}
655663 onChange={event => setText(event.target.value)}
656664 rows={3}
657 aria-label="编辑评论"
665 aria-label={tr('issue.board.editCommentAria')}
658666 className="w-full resize-y rounded-md border-2 border-shell-hairline bg-shell-surface p-2 text-[13px] outline-none focus-visible:border-shell-ink"
659667 />
660668 <div className="flex justify-end gap-2">
@@ -664,7 +672,7 @@
664672 onClick={() => void act(() => updateComment(boardRef, issueId, comment.id, text.trim()))}
665673 className="rounded-md bg-shell-action px-2.5 py-1 text-[12.5px] font-semibold text-white disabled:opacity-50"
666674 >
667 保存
675 {tr('common.action.save')}
668676 </button>
669677 </div>
670678 </div>
@@ -700,13 +708,13 @@
700708 setReply('')
701709 onReload()
702710 } catch (err) {
703 setError((err as Error)?.message ?? '回复失败。')
711 setError((err as Error)?.message ?? tr('issue.board.replyFailed'))
704712 } finally {
705713 setBusy(false)
706714 }
707715 }}
708 placeholder="回复…(Enter 发送)"
709 aria-label={`回复 ${comment.poster?.display_name ?? '评论'}`}
716 placeholder={tr('issue.board.replyPlaceholder')}
717 aria-label={tr('issue.board.replyAria', { name: comment.poster?.display_name ?? tr('issue.board.commentFallback') })}
710718 className="h-7 min-w-0 flex-1 rounded-md border-2 border-shell-hairline bg-shell-surface px-2 text-[12.5px] outline-none focus-visible:border-shell-ink"
711719 />
712720 {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin text-shell-ink-muted" aria-hidden /> : null}
@@ -725,16 +733,16 @@
725733 loading: boolean
726734 failed: boolean
727735}) {
728 if (loading) return <PanelNote text="加载活动…" />
729 if (failed) return <PanelNote text="活动记录加载失败。" />
730 if (events.length === 0) return <PanelNote text="暂无活动记录。" />
736 if (loading) return <PanelNote text={tr('issue.board.loadingActivity')} />
737 if (failed) return <PanelNote text={tr('issue.board.activityFailed')} />
738 if (events.length === 0) return <PanelNote text={tr('issue.board.noActivity')} />
731739 return (
732740 <ul className="divide-y divide-shell-hairline border-t-2 border-shell-hairline">
733741 {events.map(event => (
734742 <li key={event.id} className="flex items-center gap-2 px-4 py-2 text-[12.5px]">
735743 {event.user ? <UserAvatar user={event.user} size={18} /> : <Users className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />}
736744 <span className="min-w-0 flex-1 truncate">
737 <span className="font-medium">{event.user?.display_name ?? '系统'}</span> {eventLabel(event.event_type)}
745 <span className="font-medium">{event.user?.display_name ?? tr('issue.board.system')}</span> {eventLabel(event.event_type)}
738746 {event.new_title ? <span className="text-shell-ink-muted"> → {event.new_title}</span> : null}
739747 </span>
740748 <span className="shrink-0 text-[11.5px] text-shell-ink-muted">{fmtTime(event.created_at)}</span>
Modified web/src/features/issue-board/ui/issue-form-modal.tsx View web/src/features/issue-board/ui/issue-form-modal.tsx
@@ -9,6 +9,7 @@
99import { Loader2 } from 'lucide-react'
1010import { Alert, Button } from '@/shared/ui'
1111import { ApiFailure } from '@/shared/lib'
12import { useT } from '@/shared/i18n'
1213import {
1314 PRIORITIES,
1415 createIssue,
@@ -43,6 +44,7 @@
4344const TITLE_MAX = 255
4445
4546export function IssueFormModal({ open, boardRef, data, editing, presetPriority, onClose, onSaved }: IssueFormModalProps) {
47 const { t } = useT()
4648 const fieldId = useId()
4749 const [title, setTitle] = useState('')
4850 const [description, setDescription] = useState('')
@@ -101,13 +103,13 @@
101103 if (busy) return
102104 const trimmed = title.trim()
103105 if (!trimmed) {
104 setValidation('请填写标题。')
106 setValidation(t('issue.board.form.validationTitle'))
105107 return
106108 }
107109 // The backend counts Unicode code points (`chars().count()`), so do the
108110 // same instead of JS UTF-16 units — an emoji title is not "too long".
109111 if ([...trimmed].length > TITLE_MAX) {
110 setValidation(`标题最长 ${TITLE_MAX} 个字符。`)
112 setValidation(t('issue.board.form.validationTitleMax', { max: TITLE_MAX }))
111113 return
112114 }
113115 setValidation(null)
@@ -130,7 +132,7 @@
130132 onSaved(saved)
131133 onClose()
132134 } catch (err) {
133 setError(err instanceof ApiFailure ? err.message : '保存失败,请稍后重试。')
135 setError(err instanceof ApiFailure ? err.message : t('issue.board.form.saveFailed'))
134136 } finally {
135137 setBusy(false)
136138 }
@@ -140,23 +142,23 @@
140142 <Dialog
141143 open={open}
142144 onClose={() => { if (!busy) onClose() }}
143 title={editing ? `编辑工单 #${editing.number}` : '新建工单'}
144 description={editing ? '让信息保持清晰,协作就更轻松。' : '记录一个问题或想法,让下一步更清晰。'}
145 title={editing ? t('issue.board.form.editTitle', { number: editing.number }) : t('issue.board.new')}
146 description={editing ? t('issue.board.form.editDescription') : t('issue.board.form.createDescription')}
145147 footer={
146148 <>
147149 {busy ? <Loader2 className="mr-auto h-4 w-4 animate-spin text-shell-ink-muted" aria-hidden /> : null}
148150 <Button variant="secondary" onClick={onClose} disabled={busy}>
149 取消
151 {t('common.action.cancel')}
150152 </Button>
151153 <Button onClick={() => void submit()} disabled={busy}>
152 {busy ? '正在保存…' : editing ? '保存修改' : '创建工单'}
154 {busy ? t('common.state.busy') : editing ? t('common.action.save') : t('issue.board.new')}
153155 </Button>
154156 </>
155157 }
156158 >
157159 <div className="space-y-1.5">
158160 <div className="flex items-center justify-between gap-2">
159 <label htmlFor={`${fieldId}-title`} className={fieldLabelClass}>标题 <span className="text-shell-ink-muted">*</span></label>
161 <label htmlFor={`${fieldId}-title`} className={fieldLabelClass}>{t('issue.board.form.title')} <span className="text-shell-ink-muted">*</span></label>
160162 <span className="text-[11px] text-shell-ink-muted">{[...title].length} / {TITLE_MAX}</span>
161163 </div>
162164 <input
@@ -168,7 +170,7 @@
168170 value={title}
169171 onChange={event => setTitle(event.target.value)}
170172 maxLength={TITLE_MAX + 20}
171 placeholder="给这个工单一个清晰的标题"
173 placeholder={t('issue.board.form.titlePlaceholder')}
172174 autoFocus
173175 />
174176 </div>
@@ -176,31 +178,31 @@
176178 {error ? <Alert>{error}</Alert> : null}
177179
178180 <div className="space-y-1.5">
179 <label htmlFor={`${fieldId}-summary`} className={fieldLabelClass}>摘要 <span className="font-normal text-shell-ink-muted">(选填)</span></label>
181 <label htmlFor={`${fieldId}-summary`} className={fieldLabelClass}>{t('issue.board.form.summary')} <span className="font-normal text-shell-ink-muted">{t('issue.board.form.summaryOptional')}</span></label>
180182 <input
181183 id={`${fieldId}-summary`}
182184 className={fieldInputClass}
183185 value={description}
184186 onChange={event => setDescription(event.target.value)}
185 placeholder="一句话说明影响范围"
187 placeholder={t('issue.board.form.summaryPlaceholder')}
186188 />
187189 </div>
188190
189191 <div className="space-y-1.5">
190 <label htmlFor={`${fieldId}-content`} className={fieldLabelClass}>详细描述</label>
192 <label htmlFor={`${fieldId}-content`} className={fieldLabelClass}>{t('issue.board.form.content')}</label>
191193 <textarea
192194 id={`${fieldId}-content`}
193195 value={content}
194196 onChange={event => setContent(event.target.value)}
195197 rows={6}
196 placeholder={'复现步骤:\n1. …\n2. …\n\n期望结果:\n实际结果:'}
198 placeholder={t('issue.board.form.contentPlaceholder')}
197199 className="w-full resize-y rounded-md border-2 border-shell-hairline bg-shell-field p-3 text-[13.5px] leading-6 outline-none focus:border-shell-ink"
198200 />
199201 </div>
200202
201203 <div className="issue-form-metadata grid grid-cols-3 gap-3">
202204 <label className="space-y-1.5">
203 <span className={fieldLabelClass}>优先级</span>
205 <span className={fieldLabelClass}>{t('issue.board.filter.priority')}</span>
204206 <select
205207 value={priority}
206208 onChange={event => setPriority(event.target.value as IssuePriority)}
@@ -214,13 +216,13 @@
214216 </select>
215217 </label>
216218 <label className="space-y-1.5">
217 <span className={fieldLabelClass}>里程碑</span>
219 <span className={fieldLabelClass}>{t('issue.board.filter.milestone')}</span>
218220 <select
219221 value={milestoneId}
220222 onChange={event => setMilestoneId(event.target.value)}
221223 className={fieldInputClass}
222224 >
223 <option value="">未设置</option>
225 <option value="">{t('issue.board.unset')}</option>
224226 {milestones.map(item => (
225227 <option key={item.id} value={item.id}>
226228 {item.name}
@@ -229,14 +231,14 @@
229231 </select>
230232 </label>
231233 <label className="space-y-1.5">
232 <span className={fieldLabelClass}>截止日期</span>
234 <span className={fieldLabelClass}>{t('issue.board.filter.due')}</span>
233235 <input type="date" value={due} onChange={event => setDue(event.target.value)} className={fieldInputClass} />
234236 </label>
235237 </div>
236238
237239 <Picker
238 label="标签"
239 empty="这个问题集还没有标签,可在「管理 · 标签」中创建。"
240 label={t('issue.board.labelsField')}
241 empty={t('issue.board.form.noLabels')}
240242 options={labels.map(label => ({
241243 id: label.id,
242244 name: label.name,
@@ -247,8 +249,8 @@
247249 />
248250
249251 <Picker
250 label="负责人"
251 empty="暂时没有可指派的成员,可以稍后再添加负责人。"
252 label={t('issue.board.filter.assignee')}
253 empty={t('issue.board.form.noAssignees')}
252254 options={members.map(member => ({
253255 id: (member.user as UserDto).user_id,
254256 name: (member.user as UserDto).display_name || (member.user as UserDto).username,
Modified web/src/features/issue-board/ui/parts.tsx View web/src/features/issue-board/ui/parts.tsx
@@ -6,16 +6,18 @@
66 */
77import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
88import { Check, ChevronDown } from 'lucide-react'
9import { useT } from '@/shared/i18n'
910import { describeDue, normalizeColor, priorityOf, readableText, STATES } from '@/entities/issue'
1011import type { IssueLabel, IssuePriority, IssueState, UserDto } from '@/entities/issue'
1112
1213export function PriorityBadge({ priority, compact = false }: { priority: IssuePriority; compact?: boolean }) {
14 const { t } = useT()
1315 const meta = priorityOf(priority)
1416 return (
1517 <span
1618 data-priority={meta.id}
1719 className={`issue-priority-badge inline-flex shrink-0 items-center rounded-full font-semibold ${compact ? 'px-1.5 py-px text-[10.5px]' : 'px-2 py-0.5 text-[11px]'}`}
18 title={`优先级:${meta.label}`}
20 title={t('issue.filter.chip.priority', { value: meta.label })}
1921 >
2022 {meta.label}
2123 </span>
@@ -36,6 +38,7 @@
3638}
3739
3840export function LabelChip({ label, onRemove }: { label: IssueLabel; onRemove?: () => void }) {
41 const { t } = useT()
3942 const color = normalizeColor(label.color)
4043 return (
4144 <span
@@ -47,7 +50,7 @@
4750 {onRemove ? (
4851 <button
4952 type="button"
50 aria-label={`移除标签 ${label.name}`}
53 aria-label={t('issue.board.removeLabel', { name: label.name })}
5154 onClick={event => {
5255 event.stopPropagation()
5356 onRemove()
@@ -83,7 +86,7 @@
8386 <span
8487 className="grid place-content-center rounded-full bg-shell-tile text-[10px] font-semibold text-shell-ink ring-2 ring-shell-surface"
8588 style={{ width: size, height: size }}
86 title={users.map(u => u.display_name || u.username).join('、')}
89 title={users.map(u => u.display_name || u.username).join(', ')}
8790 >
8891 +{rest}
8992 </span>
@@ -151,6 +154,7 @@
151154 /** Free-form block under the option list (an inline "new label" row, …). */
152155 extra?: ReactNode
153156}) {
157 const { t } = useT()
154158 const [open, setOpen] = useState(false)
155159 const box = useRef<HTMLDivElement | null>(null)
156160 const trigger = useRef<HTMLButtonElement | null>(null)
@@ -189,7 +193,7 @@
189193 <button
190194 type="button"
191195 ref={trigger}
192 aria-label={`${label}:${active ?? current?.label ?? placeholder ?? "未选择"}`}
196 aria-label={t('issue.filter.triggerAria', { label, value: active ?? current?.label ?? placeholder ?? t('issue.board.unset') })}
193197 aria-expanded={open}
194198 aria-controls={open ? menuId : undefined}
195199 aria-haspopup="listbox"
@@ -259,7 +263,7 @@
259263 />
260264 ))}
261265 {options.length === 0 ? (
262 <p className="px-2 py-3 text-center text-[12px] text-shell-ink-muted">暂无可选项</p>
266 <p className="px-2 py-3 text-center text-[12px] text-shell-ink-muted">{t('issue.board.noOptions')}</p>
263267 ) : null}
264268 {extra ? <div className="mt-1 border-t-2 border-shell-hairline p-2">{extra}</div> : null}
265269 </div>
@@ -311,6 +315,7 @@
311315 onConfirm: () => void
312316 className?: string
313317}) {
318 const { t } = useT()
314319 const [armed, setArmed] = useState(false)
315320 useEffect(() => {
316321 if (!armed) return
@@ -326,7 +331,7 @@
326331 armed ? 'bg-[#e5484d] font-semibold text-white' : 'text-[#b42318] hover:bg-[#e5484d]/10'
327332 } ${className}`}
328333 >
329 {busy ? '处理中…' : armed ? '再次点击确认' : label}
334 {busy ? t('common.action.working') : armed ? t('issue.board.confirmDiscard') : label}
330335 </button>
331336 )
332337}
Modified web/src/features/notifications/model/target.ts View web/src/features/notifications/model/target.ts
@@ -15,6 +15,8 @@
1515 * `space_name` is always the route slug (`lower_name`), which is what
1616 * `/app/{space}/…` addresses.
1717 */
18import { t } from '@/shared/i18n'
19
1820/**
1921 * A notification as far as routing is concerned: the type picks the shape and
2022 * `data` is the server's free JSON column. Both the HTTP row and the WS event
@@ -85,15 +87,15 @@
8587 if (payload.spaceName && payload.boardId && payload.issueNumber) {
8688 return {
8789 href: `/app/${scope(payload.spaceName)}/board/${payload.boardId}?issue=${encodeURIComponent(payload.issueNumber)}`,
88 label: `查看工单 #${payload.issueNumber}`,
90 label: t('notifications.target.issue', { number: payload.issueNumber }),
8991 }
9092 }
9193 if (payload.roomId) {
9294 const jump = payload.messageId ? `?jump=${encodeURIComponent(payload.messageId)}` : ''
93 return { href: `${roomHref(payload)}${jump}`, label: '打开频道' }
95 return { href: `${roomHref(payload)}${jump}`, label: t('notifications.target.room') }
9496 }
9597 if (payload.spaceName) {
96 return { href: `/app/${scope(payload.spaceName)}`, label: '进入社区' }
98 return { href: `/app/${scope(payload.spaceName)}`, label: t('notifications.target.space') }
9799 }
98100 return null
99101}
Modified web/src/features/notifications/ui/inbox-button.tsx View web/src/features/notifications/ui/inbox-button.tsx
@@ -1,5 +1,6 @@
11import { Link } from 'react-router-dom'
22import { Bell } from 'lucide-react'
3import { useT } from '@/shared/i18n'
34import { useUnreadNotificationCount } from '../model/use-unread-count'
45import { NOTIFY_PATH } from './notify-nav-item'
56
@@ -9,12 +10,13 @@
910 * the workspace list; both share the same live count.
1011 */
1112export function NotifyInboxButton() {
13 const { t } = useT()
1214 const { count, capped } = useUnreadNotificationCount()
1315 return (
1416 <Link
1517 to={NOTIFY_PATH}
16 aria-label={count > 0 ? `通知,${count} 条未读` : '通知'}
17 title="通知"
18 aria-label={count > 0 ? t('notifications.bellUnread', { count }) : t('notifications.bell')}
19 title={t('notifications.bell')}
1820 className="workspace-icon-button relative"
1921 >
2022 <Bell className="h-4 w-4" />
Modified web/src/features/notifications/ui/meta.ts View web/src/features/notifications/ui/meta.ts
@@ -1,14 +1,15 @@
11import { AtSign, Bell, Flag, MessageSquare, UserPlus, Users } from 'lucide-react'
2import { t } from '@/shared/i18n'
23import type { NotificationType } from '@/shared/api'
34
45export const NOTIFY_META: Record<
56 NotificationType,
67 { label: string; icon: typeof AtSign }
78> = {
8 mention: { label: '提及', icon: AtSign },
9 reply: { label: '回复', icon: MessageSquare },
10 follow: { label: '关注', icon: Users },
11 invite: { label: '邀请', icon: UserPlus },
12 assignment: { label: '指派', icon: Flag },
13 system: { label: '系统', icon: Bell },
14}
9 mention: { get label() { return t('notifications.type.mention') }, icon: AtSign },
10 reply: { get label() { return t('notifications.type.reply') }, icon: MessageSquare },
11 follow: { get label() { return t('notifications.type.follow') }, icon: Users },
12 invite: { get label() { return t('notifications.type.invite') }, icon: UserPlus },
13 assignment: { get label() { return t('notifications.type.assignment') }, icon: Flag },
14 system: { get label() { return t('notifications.type.system') }, icon: Bell },
15}
No newline at end of file
Modified web/src/features/notifications/ui/notify-nav-item.tsx View web/src/features/notifications/ui/notify-nav-item.tsx
@@ -1,5 +1,6 @@
11import { Link, useLocation } from 'react-router-dom'
22import { Bell } from 'lucide-react'
3import { useT } from '@/shared/i18n'
34import { useUnreadNotificationCount } from '../model/use-unread-count'
45
56export const NOTIFY_PATH = '/app/@me/notify'
@@ -10,13 +11,14 @@
1011 * sidebar's own rows; the route match is read off the location directly.
1112 */
1213export function NotifyNavItem() {
14 const { t } = useT()
1315 const { pathname } = useLocation()
1416 const { count, capped } = useUnreadNotificationCount()
1517 const active = pathname === NOTIFY_PATH
1618 return (
1719 <Link
1820 to={NOTIFY_PATH}
19 title="通知"
21 title={t('notifications.title')}
2022 aria-current={active ? 'page' : undefined}
2123 className={`workspace-nav-row flex h-10 items-center gap-1.5 rounded-md pl-2 pr-2 text-[14px] font-medium ${
2224 active
@@ -27,7 +29,7 @@
2729 <span className="shrink-0 text-shell-ink-muted">
2830 <Bell className="h-4 w-4" aria-hidden />
2931 </span>
30 <span className="truncate">通知</span>
32 <span className="truncate">{t('notifications.title')}</span>
3133 {count > 0 ? (
3234 <span className="ml-auto shrink-0 rounded-full bg-shell-tile px-1.5 text-[11px] font-semibold leading-[20px] text-shell-ink">
3335 {capped ? '50+' : count > 99 ? '99+' : count}
Modified web/src/features/notifications/ui/notify-toaster.tsx View web/src/features/notifications/ui/notify-toaster.tsx
@@ -2,6 +2,7 @@
22import { useNavigate } from 'react-router-dom'
33import { X } from 'lucide-react'
44import { fmtRelative } from '@/shared/lib'
5import { useT } from '@/shared/i18n'
56import { dismissToast, getToasts, subscribeToasts, type NotifyToast } from '../model/live'
67import { notificationTarget } from '../model/target'
78import { NOTIFY_META } from './meta'
@@ -28,12 +29,13 @@
2829 * this is the only place they surface at all.
2930 */
3031export function NotifyToaster() {
32 const { t } = useT()
3133 const toasts = useSyncExternalStore(subscribeToasts, getToasts, () => EMPTY)
3234 if (toasts.length === 0) return null
3335 return (
3436 <div
3537 role="region"
36 aria-label="系统通知"
38 aria-label={t('notifications.region')}
3739 className="pointer-events-none fixed bottom-5 right-5 z-50 flex w-[340px] max-w-[calc(100vw-2.5rem)] flex-col gap-2"
3840 >
3941 {toasts.map(toast => (
@@ -44,6 +46,7 @@
4446}
4547
4648function ToastCard({ toast }: { toast: NotifyToast }) {
49 const { t } = useT()
4750 const navigate = useNavigate()
4851 useEffect(() => {
4952 const timer = setTimeout(() => dismissToast(toast.key), 6000)
@@ -78,7 +81,7 @@
7881 </button>
7982 <button
8083 type="button"
81 aria-label="关闭通知"
84 aria-label={t('notifications.closeToast')}
8285 className="workspace-icon-button -mr-1 h-7 w-7"
8386 onClick={() => dismissToast(toast.key)}
8487 >
Modified web/src/features/notifications/ui/notify-view.tsx View web/src/features/notifications/ui/notify-view.tsx
@@ -3,6 +3,7 @@
33import { ArrowUpRight, Check, CheckCheck, Loader2, Trash2 } from 'lucide-react'
44import { Alert, Busy as Spinner, EmptyNote, SectionTitle, errText } from '@/shared/ui'
55import { ApiFailure, fmtRelative, notifyKey, useAsync } from '@/shared/lib'
6import { useT } from '@/shared/i18n'
67import {
78 acceptRoomInvite,
89 acceptSpaceInvite,
@@ -49,6 +50,7 @@
4950 * the bell badge in place.
5051 */
5152export function NotifyView({ memberSpaceSlugs }: NotifyViewProps) {
53 const { t } = useT()
5254 const navigate = useNavigate()
5355 const [tick, setTick] = useState(0)
5456 const [onlyUnread, setOnlyUnread] = useState(false)
@@ -134,18 +136,18 @@
134136 <section className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-6">
135137 <div className="mx-auto w-full max-w-[720px]">
136138 <SectionTitle
137 title="通知"
138 sub="邀请、指派与系统提醒留在这里;提及和回复直接回到所在的频道与工单。"
139 title={t('notifications.title')}
140 sub={t('notifications.sub')}
139141 />
140142 <div className="mb-4 flex items-center gap-2">
141 <div className="settings-segment-group" role="group" aria-label="通知筛选">
143 <div className="settings-segment-group" role="group" aria-label={t('notifications.filter')}>
142144 <button
143145 type="button"
144146 className="settings-segment"
145147 aria-pressed={!onlyUnread}
146148 onClick={() => setOnlyUnread(false)}
147149 >
148 全部
150 {t('common.state.all')}
149151 </button>
150152 <button
151153 type="button"
@@ -153,7 +155,7 @@
153155 aria-pressed={onlyUnread}
154156 onClick={() => setOnlyUnread(true)}
155157 >
156 未读
158 {t('notifications.unread')}
157159 </button>
158160 </div>
159161 <button
@@ -162,7 +164,7 @@
162164 onClick={() => void run(() => markNotificationsRead({ all: true }))}
163165 >
164166 <CheckCheck className="h-4 w-4" />
165 全部标为已读
167 {t('notifications.markAllRead')}
166168 </button>
167169 </div>
168170 {failed ? (
@@ -173,7 +175,7 @@
173175 {list.loading && rows.length === 0 ? (
174176 <Spinner />
175177 ) : rows.length === 0 ? (
176 <EmptyNote>{onlyUnread ? '没有未读通知。' : '暂时没有需要你处理的通知。'}</EmptyNote>
178 <EmptyNote>{onlyUnread ? t('notifications.emptyUnread') : t('notifications.emptyAll')}</EmptyNote>
177179 ) : (
178180 <ul className="divide-y divide-shell-hairline overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
179181 {rows.map(row => (
@@ -212,6 +214,7 @@
212214 onDecide: (kind: 'space' | 'room', code: string, doing: 'accept' | 'reject') => void
213215 onDelete: () => void
214216}) {
217 const { t } = useT()
215218 const meta = NOTIFY_META[row.notification_type]
216219 const unread = !row.read_at
217220 const target = notificationTarget(row)
@@ -261,7 +264,7 @@
261264 {row.title}
262265 </span>
263266 {unread ? (
264 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-shell-ink-muted" aria-label="未读" />
267 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-shell-ink-muted" aria-label={t('notifications.unread')} />
265268 ) : null}
266269 </span>
267270 {row.body ? (
@@ -271,18 +274,16 @@
271274 {meta.label} · {fmtRelative(row.created_at)}
272275 </span>
273276 {invite && !decision ? (
274 <span className="notify-note mt-1.5 block">这条邀请请在邮件中处理。</span>
277 <span className="notify-note mt-1.5 block">{t('notifications.inviteByEmail')}</span>
275278 ) : invite && state === 'void' ? (
276 <span className="notify-note mt-1.5 block">
277 这条邀请已失效:可能已在别处接受、被撤销或已过期。
278 </span>
279 <span className="notify-note mt-1.5 block">{t('notifications.inviteVoid')}</span>
279280 ) : null}
280281 </span>
281282 <span className="flex shrink-0 items-center gap-1.5">
282283 {state === 'joined' ? (
283284 <span className="notify-done">
284285 <Check className="h-3.5 w-3.5" aria-hidden />
285 已加入
286 {t('notifications.joined')}
286287 </span>
287288 ) : actionable && decision ? (
288289 <span className="notify-actions">
@@ -296,7 +297,7 @@
296297 }}
297298 >
298299 {pending === 'reject' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
299 拒绝
300 {t('notifications.reject')}
300301 </button>
301302 <button
302303 type="button"
@@ -308,7 +309,7 @@
308309 }}
309310 >
310311 {pending === 'accept' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
311 接受邀请
312 {t('notifications.accept')}
312313 </button>
313314 </span>
314315 ) : link ? (
@@ -316,8 +317,8 @@
316317 ) : null}
317318 <button
318319 type="button"
319 aria-label="删除通知"
320 title="删除通知"
320 aria-label={t('notifications.delete')}
321 title={t('notifications.delete')}
321322 className="workspace-icon-button -mr-1 h-8 w-8"
322323 onClick={event => {
323324 event.stopPropagation()
Modified web/src/features/pull-request/model/filters.tsx View web/src/features/pull-request/model/filters.tsx
@@ -1,3 +1,4 @@
1import { t } from '@/shared/i18n'
12import type { PullFilter } from './meta'
23import { PULL_STATE_LABEL } from './meta'
34
@@ -6,12 +7,12 @@
67 search: string
78}
89
9const CHOICES: Array<{ value: PullFilter; label: string }> = [
10 { value: 'open', label: PULL_STATE_LABEL.open },
11 { value: 'merged', label: PULL_STATE_LABEL.merged },
12 { value: 'closed', label: PULL_STATE_LABEL.closed },
13 { value: 'draft', label: '草稿' },
14 { value: 'all', label: '全部' },
10const CHOICES: { value: PullFilter; get label(): string }[] = [
11 { value: 'open', get label() { return PULL_STATE_LABEL.open } },
12 { value: 'merged', get label() { return PULL_STATE_LABEL.merged } },
13 { value: 'closed', get label() { return PULL_STATE_LABEL.closed } },
14 { value: 'draft', get label() { return t('pull.badge.draft') } },
15 { value: 'all', get label() { return t('common.state.all') } },
1516]
1617
1718/** State segmented control shared by the list tab and the inbox. */
Modified web/src/features/pull-request/model/meta.ts View web/src/features/pull-request/model/meta.ts
@@ -1,15 +1,21 @@
1import { t, type MessageKey } from '@/shared/i18n'
12import type { ChangedFile, MergeMethod, PullState, ReviewState, ReviewerState, UserDto } from '@/entities/pull-request'
23import type { DiffFile, DiffFileStatus } from '@/entities/repo'
34
45/** UI-only filter: the backend understands open/closed/merged and no filter. */
56export type PullFilter = PullState | 'all' | 'draft'
67
7export const PULL_STATE_LABEL: Record<PullState, string> = {
8 open: '待合并',
9 closed: '已关闭',
10 merged: '已合并',
8const PULL_STATE_KEYS: Record<PullState, MessageKey> = {
9 open: 'pull.state.open',
10 closed: 'pull.state.closed',
11 merged: 'pull.state.merged',
1112}
1213
14/** Lazy so badges follow the active locale. */
15export const PULL_STATE_LABEL = new Proxy({} as Record<PullState, string>, {
16 get: (_target, key: PullState) => t(PULL_STATE_KEYS[key]),
17})
18
1319/** tailwind-ish tone classes shared across badges (mirrors repo-history colors). */
1420export const TONE = {
1521 green: 'text-[#1a7f37] bg-[rgba(35,165,90,0.12)]',
@@ -19,14 +25,18 @@
1925 gray: 'text-shell-ink-muted bg-shell-active',
2026} as const
2127
22export const REVIEW_STATE_LABEL: Record<ReviewState, string> = {
23 approved: '已批准',
24 changes_requested: '请求修改',
25 commented: '评论',
26 pending: '待提交',
27 dismissed: '已撤销',
28const REVIEW_STATE_KEYS: Record<ReviewState, MessageKey> = {
29 approved: 'pull.reviewState.approved',
30 changes_requested: 'pull.reviewState.changesRequested',
31 commented: 'pull.reviewState.commented',
32 pending: 'pull.reviewState.pending',
33 dismissed: 'pull.reviewState.dismissed',
2834}
2935
36export const REVIEW_STATE_LABEL = new Proxy({} as Record<ReviewState, string>, {
37 get: (_target, key: ReviewState) => t(REVIEW_STATE_KEYS[key]),
38})
39
3040export const REVIEW_STATE_TONE: Record<ReviewState, keyof typeof TONE> = {
3141 approved: 'green',
3242 changes_requested: 'red',
@@ -35,20 +45,28 @@
3545 dismissed: 'gray',
3646}
3747
38export const REVIEWER_STATE_LABEL: Record<ReviewerState, string> = {
39 requested: '待评审',
40 approved: '已批准',
41 changes_requested: '请求修改',
42 commented: '已评论',
43 dismissed: '已撤销',
48const REVIEWER_STATE_KEYS: Record<ReviewerState, MessageKey> = {
49 requested: 'pull.reviewerState.requested',
50 approved: 'pull.reviewerState.approved',
51 changes_requested: 'pull.reviewerState.changesRequested',
52 commented: 'pull.reviewerState.commented',
53 dismissed: 'pull.reviewerState.dismissed',
4454}
4555
46export const MERGE_METHOD_LABEL: Record<MergeMethod, string> = {
47 merge: '普通合并',
48 squash: '压缩合并',
49 rebase: '变基合并',
56export const REVIEWER_STATE_LABEL = new Proxy({} as Record<ReviewerState, string>, {
57 get: (_target, key: ReviewerState) => t(REVIEWER_STATE_KEYS[key]),
58})
59
60const MERGE_METHOD_KEYS: Record<MergeMethod, MessageKey> = {
61 merge: 'pull.mergeMethod.merge',
62 squash: 'pull.mergeMethod.squash',
63 rebase: 'pull.mergeMethod.rebase',
5064}
5165
66export const MERGE_METHOD_LABEL = new Proxy({} as Record<MergeMethod, string>, {
67 get: (_target, key: MergeMethod) => t(MERGE_METHOD_KEYS[key]),
68})
69
5270/** The backend formats status with `{:?}` (Debug names) — normalize loosely. */
5371export function normalizeFileStatus(raw: string): DiffFileStatus {
5472 const s = raw.toLowerCase()
@@ -60,41 +78,42 @@
6078 return 'modified'
6179}
6280
63const EVENT_LABEL: Record<string, string> = {
64 created: '创建了本合并请求',
65 updated: '更新了合并请求',
66 title_changed: '修改了标题',
67 body_edited: '编辑了描述',
68 base_changed: '改动了目标分支',
69 marked_ready: '转为正式合并请求',
70 marked_draft: '转为草稿',
71 closed: '关闭了合并请求',
72 reopened: '重新打开了合并请求',
73 merged: '合并了本合并请求',
74 review_requested: '请求了评审',
75 review_request_removed: '移除了评审请求',
76 review_submitted: '提交了评审',
77 approved: '批准了本合并请求',
78 changes_requested: '请求修改',
79 comment: '发表了评论',
80 labeled: '添加了标签',
81 unlabeled: '移除了标签',
82 assigned: '添加了受理人',
83 unassigned: '移除了受理人',
84 locked: '锁定了讨论',
85 unlocked: '解锁了讨论',
86 force_push: '向源分支强推了提交',
87 branch_updated: '同步了目标分支',
88 head_ref_updated: '更新了源分支',
89 auto_merge_enabled: '开启了自动合并',
90 auto_merge_disabled: '关闭了自动合并',
81const EVENT_KEYS: Record<string, MessageKey> = {
82 created: 'pull.event.created',
83 updated: 'pull.event.updated',
84 title_changed: 'pull.event.titleChanged',
85 body_edited: 'pull.event.bodyEdited',
86 base_changed: 'pull.event.baseChanged',
87 marked_ready: 'pull.event.markedReady',
88 marked_draft: 'pull.event.markedDraft',
89 closed: 'pull.event.closed',
90 reopened: 'pull.event.reopened',
91 merged: 'pull.event.merged',
92 review_requested: 'pull.event.reviewRequested',
93 review_request_removed: 'pull.event.reviewRequestRemoved',
94 review_submitted: 'pull.event.reviewSubmitted',
95 approved: 'pull.event.approved',
96 changes_requested: 'pull.event.changesRequested',
97 comment: 'pull.event.comment',
98 labeled: 'pull.event.labeled',
99 unlabeled: 'pull.event.unlabeled',
100 assigned: 'pull.event.assigned',
101 unassigned: 'pull.event.unassigned',
102 locked: 'pull.event.locked',
103 unlocked: 'pull.event.unlocked',
104 force_push: 'pull.event.forcePush',
105 branch_updated: 'pull.event.branchUpdated',
106 head_ref_updated: 'pull.event.headRefUpdated',
107 auto_merge_enabled: 'pull.event.autoMergeEnabled',
108 auto_merge_disabled: 'pull.event.autoMergeDisabled',
91109}
92110
111/** Unknown backend event types keep rendering as their raw snake_case name. */
93112export const eventLabel = (eventType: string): string =>
94 EVENT_LABEL[eventType] ?? eventType.replaceAll('_', ' ')
113 EVENT_KEYS[eventType] ? t(EVENT_KEYS[eventType]) : eventType.replaceAll('_', ' ')
95114
96115export const userName = (user?: UserDto | null): string =>
97 user?.display_name || user?.username || '未知用户'
116 user?.display_name || user?.username || t('common.state.unknownUser')
98117
99118export interface InlineAnchor {
100119 path: string
Modified web/src/features/pull-request/ui/ai-tab.tsx View web/src/features/pull-request/ui/ai-tab.tsx
@@ -10,6 +10,7 @@
1010 triggerPullAiReview,
1111 type PullRef,
1212} from '@/entities/pull-request'
13import { useT } from '@/shared/i18n'
1314import { Copyable } from './parts'
1415
1516export interface AiTabProps {
@@ -26,6 +27,7 @@
2627
2728/** AI 评审页签:结论、行级发现与手动重跑。 */
2829export function AiTab({ refObj, headSha, onOpenFinding }: AiTabProps) {
30 const { t } = useT()
2931 const [tick, setTick] = useState(0)
3032 const [busy, setBusy] = useState(false)
3133 const [notice, setNotice] = useState<string | null>(null)
@@ -42,17 +44,17 @@
4244 setNotice(null)
4345 try {
4446 const job = await triggerPullAiReview(refObj, language || undefined)
45 const scope = language ? ` · ${language}` : ''
46 setNotice(`已提交评审任务(${job.model}${scope}),完成后刷新即可看到结果。`)
47 const scope = language ? ` · ${language}` : ''
48 setNotice(t('pull.ai.notice', { model: job.model, scope }))
4749 } catch (f) {
48 setError(f instanceof Error ? f.message : '触发 AI 评审失败')
50 setError(f instanceof Error ? f.message : t('pull.ai.triggerFailed'))
4951 } finally {
5052 setBusy(false)
5153 }
5254 }
5355
54 if (reviews.loading && !reviews.data) return <PanelNote text="加载 AI 评审…" />
55 if (failure) return <ErrorPanel status={failure.code ?? 0} what="AI 评审" note={failure.message} onRetry={() => setTick(t => t + 1)} />
56 if (reviews.loading && !reviews.data) return <PanelNote text={t('pull.ai.loading')} />
57 if (failure) return <ErrorPanel status={failure.code ?? 0} what={t('pull.ai.errorAi')} note={failure.message} onRetry={() => setTick(tick => tick + 1)} />
5658
5759 const rows = reviews.data ?? []
5860
@@ -61,21 +63,21 @@
6163 <div className="flex flex-wrap items-center gap-2">
6264 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => void rerun()}>
6365 {busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Sparkles className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
64 重新评审
66 {t('pull.ai.rerun')}
6567 </Button>
6668 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => setTick(t => t + 1)}>
6769 <RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
68 刷新
70 {t('pull.ai.refresh')}
6971 </Button>
7072 <LanguagePicker value={language} onChange={setLanguage} disabled={busy} />
71 {headSha ? <span className="text-[12px] text-shell-ink-muted">当前版本 <Copyable text={headSha} /></span> : null}
73 {headSha ? <span className="text-[12px] text-shell-ink-muted">{t('pull.ai.currentVersion')} <Copyable text={headSha} /></span> : null}
7274 </div>
7375
7476 {notice ? <p className="rounded-lg border border-shell-hairline bg-shell-field px-3 py-2 text-[12.5px] text-shell-ink-muted">{notice}</p> : null}
7577 {error ? <p className="text-[12.5px] text-[#b42318]" role="alert">{error}</p> : null}
7678
7779 {rows.length === 0 ? (
78 <PanelNote text="该合并请求还没有 AI 评审结果。开启 [ai] 后打开或更新 PR 会自动评审,也可点击上方按钮手动触发。" />
80 <PanelNote text={t('pull.ai.empty')} />
7981 ) : (
8082 rows.map(review => (
8183 <section key={review.id} className="rounded-xl border border-shell-hairline bg-shell-surface">
@@ -86,16 +88,16 @@
8688 </span>
8789 <span className="text-shell-ink-muted">{fmtRelative(review.created_at)}</span>
8890 {review.current ? (
89 <span className="text-[11.5px] text-[#1a7f37]">对应当前版本</span>
91 <span className="text-[11.5px] text-[#1a7f37]">{t('pull.ai.matchesCurrent')}</span>
9092 ) : (
91 <span className="text-[11.5px] text-[#8a6100]">基于旧版本 {review.commit_sha.slice(0, 8)}</span>
93 <span className="text-[11.5px] text-[#8a6100]">{t('pull.ai.oldVersion', { sha: review.commit_sha.slice(0, 8) })}</span>
9294 )}
9395 {review.language ? <span className="text-[11.5px] text-shell-ink-muted">{review.language}</span> : null}
94 {review.truncated ? <span className="text-[11.5px] text-[#8a6100]">变更内容被截断</span> : null}
96 {review.truncated ? <span className="text-[11.5px] text-[#8a6100]">{t('pull.ai.truncated')}</span> : null}
9597 </header>
9698 <div className="space-y-3 px-4 py-3">
97 <Markdown>{review.summary || '(无总结)'}</Markdown>
98 {review.job_error ? <p className="text-[12px] text-[#b42318]">任务错误:{review.job_error}</p> : null}
99 <Markdown>{review.summary || t('pull.ai.noSummary')}</Markdown>
100 {review.job_error ? <p className="text-[12px] text-[#b42318]">{t('pull.ai.jobError', { error: review.job_error })}</p> : null}
99101 {review.findings.length > 0 ? (
100102 <ul className="space-y-2">
101103 {review.findings.map((finding, index) => (
@@ -106,18 +108,18 @@
106108 type="button"
107109 onClick={() => onOpenFinding(finding.path, finding.line)}
108110 className="min-w-0 truncate font-mono underline decoration-shell-hairline underline-offset-4 hover:decoration-shell-ink-muted"
109 title="在变更文件中定位该行"
111 title={t('pull.ai.locate')}
110112 >
111113 {finding.path}:{finding.line}
112114 </button>
113 {finding.side === 'left' ? <span className="text-shell-ink-muted">旧行</span> : null}
115 {finding.side === 'left' ? <span className="text-shell-ink-muted">{t('pull.ai.oldLine')}</span> : null}
114116 </div>
115117 <Markdown compact>{finding.body}</Markdown>
116118 </li>
117119 ))}
118120 </ul>
119121 ) : (
120 <p className="text-[12.5px] text-shell-ink-muted">没有需要修改的问题。</p>
122 <p className="text-[12.5px] text-shell-ink-muted">{t('pull.ai.noFindings')}</p>
121123 )}
122124 </div>
123125 </section>
Modified web/src/features/pull-request/ui/checks-tab.tsx View web/src/features/pull-request/ui/checks-tab.tsx
@@ -1,65 +1,70 @@
11import { useState } from 'react'
22import { CircleCheck, CircleDashed, CircleX, Clock, Loader } from 'lucide-react'
33import { ErrorPanel, PanelNote } from '@/shared/ui'
4import type { MessageKey } from '@/shared/i18n'
45import { fmtRelative, useAsync } from '@/shared/lib'
6import { useT } from '@/shared/i18n'
57import { getPullChecks, type PullRef } from '@/entities/pull-request'
68import { Copyable } from './parts'
79
8const RUN_STATE: Record<string, { label: string; icon: typeof Clock; tone: string }> = {
9 pending: { label: '排队中', icon: CircleDashed, tone: 'text-shell-ink-muted' },
10 running: { label: '运行中', icon: Loader, tone: 'text-[#8a6100]' },
11 success: { label: '通过', icon: CircleCheck, tone: 'text-[#1a7f37]' },
12 failed: { label: '失败', icon: CircleX, tone: 'text-[#b42318]' },
13 cancelled: { label: '已取消', icon: Clock, tone: 'text-shell-ink-muted' },
10type CheckState = { labelKey: MessageKey | null; icon?: typeof Clock; tone: string }
11
12const RUN_STATE: Record<string, CheckState> = {
13 pending: { labelKey: 'pull.checks.pending', icon: CircleDashed, tone: 'text-shell-ink-muted' },
14 running: { labelKey: 'pull.checks.running', icon: Loader, tone: 'text-[#8a6100]' },
15 success: { labelKey: 'pull.checks.success', icon: CircleCheck, tone: 'text-[#1a7f37]' },
16 failed: { labelKey: 'pull.checks.failed', icon: CircleX, tone: 'text-[#b42318]' },
17 cancelled: { labelKey: 'pull.checks.cancelled', icon: Clock, tone: 'text-shell-ink-muted' },
1418}
1519
16const STATUS_STATE: Record<string, { label: string; tone: string }> = {
17 pending: { label: '等待', tone: 'text-shell-ink-muted' },
18 success: { label: '通过', tone: 'text-[#1a7f37]' },
19 failure: { label: '失败', tone: 'text-[#b42318]' },
20 error: { label: '错误', tone: 'text-[#b42318]' },
20const STATUS_STATE: Record<string, CheckState> = {
21 pending: { labelKey: 'pull.checks.waiting', tone: 'text-shell-ink-muted' },
22 success: { labelKey: 'pull.checks.success', tone: 'text-[#1a7f37]' },
23 failure: { labelKey: 'pull.checks.failed', tone: 'text-[#b42318]' },
24 error: { labelKey: 'pull.checks.error', tone: 'text-[#b42318]' },
2125}
2226
2327/** 检查页签:最新 head 的流水线运行 + 提交状态。 */
2428export function ChecksTab({ refObj, headSha }: { refObj: PullRef; headSha: string }) {
29 const { t } = useT()
2530 const [tick, setTick] = useState(0)
2631 const checks = useAsync(() => getPullChecks(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
2732 const failure = checks.error as { code?: number; message?: string } | undefined
28 if (checks.loading && !checks.data) return <PanelNote text="加载检查…" />
29 if (failure) return <ErrorPanel status={failure.code ?? 0} what="检查" note={failure.message} onRetry={() => setTick(t => t + 1)} />
33 if (checks.loading && !checks.data) return <PanelNote text={t('pull.checks.loading')} />
34 if (failure) return <ErrorPanel status={failure.code ?? 0} what={t('pull.checks.errorChecks')} note={failure.message} onRetry={() => setTick(tick => tick + 1)} />
3035 const data = checks.data
31 if (!data) return <PanelNote text="没有检查数据。" />
36 if (!data) return <PanelNote text={t('pull.checks.empty')} />
3237 const runs = data.runs ?? []
3338 const statuses = data.statuses ?? []
3439 if (!runs.length && !statuses.length) {
35 return <PanelNote text={`该版本(${(data.head_sha || headSha).slice(0, 8)})没有流水线运行或提交状态。`} />
40 return <PanelNote text={t('pull.checks.versionEmpty', { sha: (data.head_sha || headSha).slice(0, 8) })} />
3641 }
3742 return (
3843 <div className="space-y-3">
39 <p className="text-[12.5px] text-shell-ink-muted">版本 <Copyable text={data.head_sha || headSha} /></p>
44 <p className="text-[12.5px] text-shell-ink-muted">{t('pull.checks.version')} <Copyable text={data.head_sha || headSha} /></p>
4045 {runs.map(run => {
41 const hit = RUN_STATE[run.state] ?? { label: run.state, icon: Clock, tone: 'text-shell-ink-muted' }
42 const Icon = hit.icon
46 const hit: CheckState = RUN_STATE[run.state] ?? { labelKey: null, icon: Clock, tone: 'text-shell-ink-muted' }
47 const Icon = hit.icon ?? Clock
4348 return (
4449 <div key={run.id} className="flex flex-wrap items-center gap-2 rounded-xl border border-shell-hairline bg-shell-surface px-4 py-2.5 text-[13px]">
4550 <Icon className={`h-4 w-4 shrink-0 ${hit.tone}`} aria-hidden />
46 <span className="font-medium">流水线 #{run.id.slice(0, 8)}</span>
47 <span className={`text-[12px] ${hit.tone}`}>{hit.label}</span>
51 <span className="font-medium">{t('pull.checks.pipeline', { id: run.id.slice(0, 8) })}</span>
52 <span className={`text-[12px] ${hit.tone}`}>{hit.labelKey ? t(hit.labelKey) : run.state}</span>
4853 <span className="text-[12px] text-shell-ink-muted">{run.branch || '—'} · {fmtRelative(run.created_at)}</span>
4954 </div>
5055 )
5156 })}
5257 {statuses.map((status, index) => {
53 const hit = STATUS_STATE[status.state] ?? { label: status.state, tone: 'text-shell-ink-muted' }
58 const hit = STATUS_STATE[status.state] ?? { labelKey: null, tone: 'text-shell-ink-muted' }
5459 return (
5560 <div key={`${status.context}:${index}`} className="flex flex-wrap items-center gap-2 rounded-xl border border-shell-hairline bg-shell-surface px-4 py-2.5 text-[13px]">
5661 <span className={`h-2 w-2 shrink-0 rounded-full bg-current ${hit.tone}`} aria-hidden />
5762 <span className="font-medium">{status.context}</span>
58 <span className={`text-[12px] ${hit.tone}`}>{hit.label}</span>
63 <span className={`text-[12px] ${hit.tone}`}>{hit.labelKey ? t(hit.labelKey) : status.state}</span>
5964 {status.description ? <span className="text-[12px] text-shell-ink-muted">{status.description}</span> : null}
6065 {status.target_url ? (
6166 <button type="button" onClick={() => status.target_url && window.open(status.target_url, '_blank', 'noopener,noreferrer')} className="ml-auto text-[12px] text-shell-ink-muted underline underline-offset-2 hover:text-shell-ink">
62 详情
67 {t('pull.checks.details')}
6368 </button>
6469 ) : null}
6570 </div>
Modified web/src/features/pull-request/ui/commits-tab.tsx View web/src/features/pull-request/ui/commits-tab.tsx
@@ -1,6 +1,7 @@
11import { useState } from 'react'
22import { ErrorPanel, PanelNote } from '@/shared/ui'
33import { fmtRelative, fmtTime, useAsync } from '@/shared/lib'
4import { useT } from '@/shared/i18n'
45import { listPullCommits, type PullRef } from '@/entities/pull-request'
56import { Copyable } from './parts'
67
@@ -11,13 +12,14 @@
1112
1213/** 提交页签:head…base 区间内的提交(后端给 MergeCommitDto)。 */
1314export function CommitsTab({ refObj, onOpenCommit }: CommitsTabProps) {
15 const { t } = useT()
1416 const [tick, setTick] = useState(0)
1517 const commits = useAsync(() => listPullCommits(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
1618 const failure = commits.error as { code?: number; message?: string } | undefined
17 if (commits.loading && !commits.data) return <PanelNote text="加载提交…" />
18 if (failure) return <ErrorPanel status={failure.code ?? 0} what="提交列表" note={failure.message} onRetry={() => setTick(t => t + 1)} />
19 if (commits.loading && !commits.data) return <PanelNote text={t('pull.commits.loading')} />
20 if (failure) return <ErrorPanel status={failure.code ?? 0} what={t('pull.commits.errorList')} note={failure.message} onRetry={() => setTick(tick => tick + 1)} />
1921 const rows = commits.data ?? []
20 if (!rows.length) return <PanelNote text="该合并请求没有提交。" />
22 if (!rows.length) return <PanelNote text={t('pull.commits.empty')} />
2123 return (
2224 <ul className="divide-y divide-shell-hairline rounded-xl border border-shell-hairline bg-shell-surface">
2325 {rows.map(commit => (
@@ -29,12 +31,12 @@
2931 className="block max-w-full truncate text-left text-[13.5px] font-medium leading-6 hover:underline"
3032 title={commit.subject}
3133 >
32 {commit.subject || '(无标题)'}
34 {commit.subject || t('pull.commits.noSubject')}
3335 </button>
3436 <p className="text-[12px] text-shell-ink-muted">
35 {commit.author_name || '未知'} &lt;{commit.author_email}&gt; · <span title={fmtTime(commit.author_at)}>{fmtRelative(commit.author_at)}</span>
37 {commit.author_name || t('pull.commits.unknownAuthor')} &lt;{commit.author_email}&gt; · <span title={fmtTime(commit.author_at)}>{fmtRelative(commit.author_at)}</span>
3638 </p>
37 {commit.parents.length > 1 ? <p className="mt-0.5 text-[11.5px] text-shell-ink-muted">合并提交({commit.parents.length} 个父提交)</p> : null}
39 {commit.parents.length > 1 ? <p className="mt-0.5 text-[11.5px] text-shell-ink-muted">{t('pull.commits.mergeCommit', { count: commit.parents.length })}</p> : null}
3840 </div>
3941 <span className="flex shrink-0 items-center gap-1.5">
4042 <Copyable text={commit.sha} />
@@ -43,7 +45,7 @@
4345 onClick={() => onOpenCommit(commit.sha)}
4446 className="rounded-md border border-shell-hairline px-2 py-1 text-[11.5px] text-shell-ink-muted transition-colors hover:text-shell-ink"
4547 >
46 查看提交
48 {t('pull.commits.view')}
4749 </button>
4850 </span>
4951 </li>
Modified web/src/features/pull-request/ui/conversation.tsx View web/src/features/pull-request/ui/conversation.tsx
@@ -17,6 +17,7 @@
1717 updateComment,
1818} from '@/entities/pull-request'
1919import type { Pull, PullComment, PullCommentThread, PullEvent, PullRef, PullReview, ReviewIntent } from '@/entities/pull-request'
20import { useT, type MessageKey } from '@/shared/i18n'
2021import { eventLabel, REVIEW_STATE_LABEL, REVIEW_STATE_TONE, TONE, userName } from '../model/meta'
2122import { Avatar, Copyable, SideSection } from './parts'
2223
@@ -35,6 +36,7 @@
3536
3637/** 会话页签:评论线程 + 评审卡片 + 事件时间线,按时间归并。 */
3738export function Conversation({ refObj, pull, onChanged, onOpenFile }: ConversationProps) {
39 const { t } = useT()
3840 const [tick, setTick] = useState(0)
3941 const refresh = () => {
4042 setTick(t => t + 1)
@@ -58,7 +60,7 @@
5860 <div className="space-y-4">
5961 {pull.locked ? (
6062 <Alert tone="info">
61 <span className="inline-flex items-center gap-1"><Lock className="h-3.5 w-3.5" aria-hidden />该 PR 讨论已锁定,写操作可能被服务端拒绝。</span>
63 <span className="inline-flex items-center gap-1"><Lock className="h-3.5 w-3.5" aria-hidden />{t('pull.conv.locked')}</span>
6264 </Alert>
6365 ) : null}
6466
@@ -80,6 +82,7 @@
8082/* -------------------------------------------------------------- threads */
8183
8284function CommentBubble({ refObj, comment, onChanged }: { refObj: PullRef; comment: PullComment; onChanged: () => void }) {
85 const { t } = useT()
8386 const [editing, setEditing] = useState(false)
8487 const [draft, setDraft] = useState(comment.body)
8588 const [busy, setBusy] = useState(false)
@@ -93,14 +96,14 @@
9396 setEditing(false)
9497 onChanged()
9598 } catch (failure) {
96 setError(failure instanceof Error ? failure.message : '保存失败')
99 setError(failure instanceof Error ? failure.message : t('pull.conv.saveFailed'))
97100 } finally {
98101 setBusy(false)
99102 }
100103 }
101104
102105 async function remove() {
103 if (!window.confirm('删除这条评论?')) return
106 if (!window.confirm(t('pull.conv.deleteConfirm'))) return
104107 try {
105108 await deleteComment(refObj, comment.id)
106109 onChanged()
@@ -116,12 +119,12 @@
116119 <div className="flex flex-wrap items-center gap-2 text-[12px] text-shell-ink-muted">
117120 <span className="text-[12.5px] font-semibold text-shell-ink">{userName(comment.author)}</span>
118121 <span title={fmtTime(comment.created_at)}>{fmtRelative(comment.created_at)}</span>
119 {comment.updated_at && comment.updated_at !== comment.created_at ? <span>(已编辑)</span> : null}
122 {comment.updated_at && comment.updated_at !== comment.created_at ? <span>{t('pull.conv.edited')}</span> : null}
120123 <span className="ml-auto flex items-center gap-1">
121 <button type="button" title="编辑" onClick={() => { setDraft(comment.body); setEditing(v => !v) }} className="p-1 text-shell-ink-muted hover:text-shell-ink">
124 <button type="button" title={t('pull.conv.edit')} onClick={() => { setDraft(comment.body); setEditing(v => !v) }} className="p-1 text-shell-ink-muted hover:text-shell-ink">
122125 <Pencil className="h-3.5 w-3.5" />
123126 </button>
124 <button type="button" title="删除" onClick={() => void remove()} className="p-1 text-shell-ink-muted hover:text-[#b42318]">
127 <button type="button" title={t('pull.conv.delete')} onClick={() => void remove()} className="p-1 text-shell-ink-muted hover:text-[#b42318]">
125128 <Trash2 className="h-3.5 w-3.5" />
126129 </button>
127130 </span>
@@ -136,8 +139,8 @@
136139 />
137140 {error ? <p className="mt-1 text-[12px] text-[#b42318]">{error}</p> : null}
138141 <div className="mt-1.5 flex gap-2">
139 <Button className="h-7 text-[12px]" disabled={busy} onClick={() => void save()}>保存</Button>
140 <Button variant="ghost" className="h-7 text-[12px]" onClick={() => setEditing(false)}>取消</Button>
142 <Button className="h-7 text-[12px]" disabled={busy} onClick={() => void save()}>{t('common.action.save')}</Button>
143 <Button variant="ghost" className="h-7 text-[12px]" onClick={() => setEditing(false)}>{t('common.action.cancel')}</Button>
141144 </div>
142145 </div>
143146 ) : (
@@ -149,6 +152,7 @@
149152}
150153
151154function ThreadCard({ refObj, thread, onChanged, onOpenFile }: { refObj: PullRef; thread: PullCommentThread; onChanged: () => void; onOpenFile: (path: string) => void }) {
155 const { t } = useT()
152156 const [replying, setReplying] = useState(false)
153157 const [draft, setDraft] = useState('')
154158 const [busy, setBusy] = useState(false)
@@ -182,7 +186,7 @@
182186 <div className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
183187 <div className="mb-2 flex flex-wrap items-center gap-2 text-[12px] text-shell-ink-muted">
184188 <MessageSquare className="h-3.5 w-3.5" aria-hidden />
185 <span className="font-medium text-shell-ink">{inline ? '行内评审评论' : '会话评论'}</span>
189 <span className="font-medium text-shell-ink">{inline ? t('pull.conv.inline') : t('pull.conv.thread')}</span>
186190 {inline ? (
187191 <button type="button" onClick={() => onOpenFile(root.path ?? '')} className="rounded bg-shell-active/70 px-1.5 py-0.5 font-mono text-[11px] text-shell-ink-muted hover:text-shell-ink">
188192 {root.path}{root.line != null ? `:${root.line}` : ''}
@@ -195,7 +199,7 @@
195199 onClick={() => void toggleResolve()}
196200 className={`ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium ${root.resolved ? TONE.green : TONE.gray} hover:brightness-95`}
197201 >
198 {root.resolved ? <><CheckCheck className="h-3.5 w-3.5" aria-hidden />已解决</> : <><Check className="h-3.5 w-3.5" aria-hidden />标记解决</>}
202 {root.resolved ? <><CheckCheck className="h-3.5 w-3.5" aria-hidden />{t('pull.conv.resolved')}</> : <><Check className="h-3.5 w-3.5" aria-hidden />{t('pull.conv.markResolved')}</>}
199203 </button>
200204 ) : null}
201205 </div>
@@ -212,19 +216,19 @@
212216 value={draft}
213217 onChange={event => setDraft(event.target.value)}
214218 rows={2}
215 placeholder="回复这条评论…"
219 placeholder={t('pull.conv.replyPlaceholder')}
216220 className="min-w-0 flex-1 resize-y rounded-md border-2 border-shell-hairline bg-shell-field px-2.5 py-1.5 text-[13px] outline-none focus:border-shell-ink"
217221 />
218222 <div className="flex flex-col gap-1.5">
219223 <Button className="h-8 gap-1 text-[12.5px]" disabled={busy} onClick={() => void reply()}>
220 <Send className="h-3.5 w-3.5" aria-hidden />发送
224 <Send className="h-3.5 w-3.5" aria-hidden />{t('pull.conv.send')}
221225 </Button>
222 <Button variant="ghost" className="h-6 text-[12px]" onClick={() => setReplying(false)}>取消</Button>
226 <Button variant="ghost" className="h-6 text-[12px]" onClick={() => setReplying(false)}>{t('common.action.cancel')}</Button>
223227 </div>
224228 </div>
225229 ) : (
226230 <button type="button" onClick={() => setReplying(true)} className="mt-2 inline-flex items-center gap-1 text-[12px] text-shell-ink-muted hover:text-shell-ink">
227 <Reply className="h-3.5 w-3.5" aria-hidden />回复
231 <Reply className="h-3.5 w-3.5" aria-hidden />{t('pull.conv.reply')}
228232 </button>
229233 )}
230234 </div>
@@ -234,6 +238,7 @@
234238/* --------------------------------------------------------------- reviews */
235239
236240function ReviewCard({ refObj, review, onChanged }: { refObj: PullRef; review: PullReview; onChanged: () => void }) {
241 const { t, tc } = useT()
237242 const [busy, setBusy] = useState(false)
238243 const [full, setFull] = useState<PullReview | null>(null)
239244 const shown = full ?? review
@@ -267,20 +272,20 @@
267272 <span className={`rounded-full px-2 py-0.5 text-[11.5px] font-medium ${tone}`}>
268273 {REVIEW_STATE_LABEL[review.state]}
269274 </span>
270 {review.official ? <span className="rounded bg-shell-active px-1.5 py-0.5 text-[11px] text-shell-ink-muted">正式</span> : null}
275 {review.official ? <span className="rounded bg-shell-active px-1.5 py-0.5 text-[11px] text-shell-ink-muted">{t('pull.conv.official')}</span> : null}
271276 <span className="text-shell-ink-muted" title={fmtTime(review.created_at)}>{fmtRelative(review.created_at)}</span>
272277 {review.commit_sha ? <Copyable text={review.commit_sha} /> : null}
273 {review.comment_count > 0 ? <span className="text-shell-ink-muted">{review.comment_count} 条行内评论</span> : null}
278 {review.comment_count > 0 ? <span className="text-shell-ink-muted">{tc('pull.conv.inlineCount', review.comment_count)}</span> : null}
274279 <span className="ml-auto flex items-center gap-1.5">
275280 {review.state === 'pending' ? (
276281 <>
277 <Button variant="secondary" className="h-7 text-[12px]" disabled={busy} onClick={() => void publish('approve')}>批准并发布</Button>
278 <Button variant="secondary" className="h-7 text-[12px]" disabled={busy} onClick={() => void publish('request_changes')}>请求修改并发布</Button>
279 <Button variant="ghost" className="h-7 text-[12px]" disabled={busy} onClick={() => void publish('comment')}>仅评论发布</Button>
282 <Button variant="secondary" className="h-7 text-[12px]" disabled={busy} onClick={() => void publish('approve')}>{t('pull.conv.publishApprove')}</Button>
283 <Button variant="secondary" className="h-7 text-[12px]" disabled={busy} onClick={() => void publish('request_changes')}>{t('pull.conv.publishRequestChanges')}</Button>
284 <Button variant="ghost" className="h-7 text-[12px]" disabled={busy} onClick={() => void publish('comment')}>{t('pull.conv.publishComment')}</Button>
280285 </>
281286 ) : review.state === 'approved' || review.state === 'changes_requested' ? (
282287 <button type="button" onClick={() => void dismiss()} className="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[12px] text-shell-ink-muted hover:text-shell-ink">
283 <RotateCcw className="h-3.5 w-3.5" aria-hidden />撤销评审
288 <RotateCcw className="h-3.5 w-3.5" aria-hidden />{t('pull.conv.dismiss')}
284289 </button>
285290 ) : null}
286291 </span>
@@ -292,7 +297,7 @@
292297 onClick={() => void getReview(refObj, review.id).then(setFull).catch(() => setFull(review))}
293298 className="mt-1.5 text-[12px] text-shell-ink-muted underline underline-offset-2 hover:text-shell-ink"
294299 >
295 加载评审详情(含 {review.comment_count} 条行内意见的作者与状态)
300 {t('pull.conv.loadDetails', { count: review.comment_count })}
296301 </button>
297302 ) : null}
298303 </div>
@@ -316,13 +321,18 @@
316321
317322/* --------------------------------------------------------------- composer */
318323
319const REVIEW_MODES: Array<{ value: ReviewIntent | 'comment'; label: string }> = [
320 { value: 'comment', label: '普通评论' },
321 { value: 'approve', label: '批准' },
322 { value: 'request_changes', label: '请求修改' },
323]
324type ReviewMode = Exclude<ReviewIntent, 'pending'> | 'comment'
325
326const REVIEW_MODE_KEYS: Record<ReviewMode, MessageKey> = {
327 comment: 'pull.conv.mode.comment',
328 approve: 'pull.conv.mode.approve',
329 request_changes: 'pull.conv.mode.requestChanges',
330}
331
332const REVIEW_MODES: ReviewMode[] = ['comment', 'approve', 'request_changes']
324333
325334function Composer({ refObj, pull, onPosted }: { refObj: PullRef; pull: Pull; onPosted: () => void }) {
335 const { t } = useT()
326336 const [mode, setMode] = useState<ReviewIntent | 'comment'>('comment')
327337 const [body, setBody] = useState('')
328338 const [busy, setBusy] = useState(false)
@@ -330,7 +340,7 @@
330340
331341 async function post() {
332342 if (!body.trim()) {
333 setError('内容不能为空。')
343 setError(t('pull.conv.empty'))
334344 return
335345 }
336346 setBusy(true)
@@ -341,7 +351,7 @@
341351 setBody('')
342352 onPosted()
343353 } catch (failure) {
344 setError(failure instanceof Error ? failure.message : '提交失败')
354 setError(failure instanceof Error ? failure.message : t('pull.conv.submitFailed'))
345355 } finally {
346356 setBusy(false)
347357 }
@@ -350,21 +360,21 @@
350360 const closed = pull.state !== 'open'
351361 return (
352362 <SideSection
353 title={closed ? '追加记录' : '写评论 / 提交评审'}
363 title={closed ? t('pull.conv.append') : t('pull.conv.write')}
354364 action={
355365 <div className="flex items-center gap-0.5 rounded-md bg-shell-field p-0.5">
356366 {REVIEW_MODES.map(choice => (
357367 <button
358 key={choice.value}
368 key={choice}
359369 type="button"
360 aria-pressed={mode === choice.value}
361 disabled={choice.value !== 'comment' && (pull.is_draft || closed)}
362 onClick={() => setMode(choice.value)}
370 aria-pressed={mode === choice}
371 disabled={choice !== 'comment' && (pull.is_draft || closed)}
372 onClick={() => setMode(choice)}
363373 className={`rounded px-2 py-0.5 text-[12px] transition-colors disabled:opacity-40 ${
364 mode === choice.value ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:text-shell-ink'
374 mode === choice ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:text-shell-ink'
365375 }`}
366376 >
367 {choice.label}
377 {t(REVIEW_MODE_KEYS[choice])}
368378 </button>
369379 ))}
370380 </div>
@@ -374,17 +384,17 @@
374384 value={body}
375385 onChange={event => setBody(event.target.value)}
376386 rows={4}
377 placeholder={mode === 'comment' ? '评论这个合并请求…' : '附上评审意见…'}
387 placeholder={mode === 'comment' ? t('pull.conv.commentPlaceholder') : t('pull.conv.reviewPlaceholder')}
378388 className="w-full resize-y rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-2 text-[13px] leading-6 outline-none focus:border-shell-ink"
379389 />
380 {mode !== 'comment' && pull.is_draft ? <p className="mt-1 text-[12px] text-[#8a6100]">草稿 PR 通常不接受评审,服务端可能拒绝。</p> : null}
390 {mode !== 'comment' && pull.is_draft ? <p className="mt-1 text-[12px] text-[#8a6100]">{t('pull.conv.draftReviewHint')}</p> : null}
381391 {error ? <p className="mt-1 text-[12px] text-[#b42318]">{error}</p> : null}
382392 <div className="mt-2 flex items-center gap-2">
383393 <Button className="h-8 gap-1 text-[12.5px]" disabled={busy} onClick={() => void post()}>
384 <Send className="h-3.5 w-3.5" aria-hidden />{busy ? '提交中…' : mode === 'comment' ? '发表评论' : '提交评审'}
394 <Send className="h-3.5 w-3.5" aria-hidden />{busy ? t('pull.conv.submitting') : mode === 'comment' ? t('pull.conv.post') : t('pull.conv.submitReview')}
385395 </Button>
386396 {body ? (
387 <Button variant="ghost" className="h-8 text-[12px]" onClick={() => setBody('')}>清空</Button>
397 <Button variant="ghost" className="h-8 text-[12px]" onClick={() => setBody('')}>{t('pull.conv.clear')}</Button>
388398 ) : null}
389399 </div>
390400 </SideSection>
Modified web/src/features/pull-request/ui/files-tab.tsx View web/src/features/pull-request/ui/files-tab.tsx
@@ -1,6 +1,7 @@
11import { useEffect, useMemo, useState } from 'react'
22import { PanelNote, ErrorPanel, Button, modalTextareaClass } from '@/shared/ui'
33import { useAsync } from '@/shared/lib'
4import { t as tr, useT } from '@/shared/i18n'
45import {
56 createComment,
67 listCommentTree,
@@ -10,6 +11,7 @@
1011 type PullRef,
1112} from '@/entities/pull-request'
1213import type { ReviewIntent } from '@/entities/pull-request'
14import type { MessageKey } from '@/shared/i18n'
1315import { Copyable, InlineDiffCard } from './parts'
1416import { anchorKey, changedFileToDiff, type AnchoredComment, type InlineAnchor } from '../model/meta'
1517
@@ -25,6 +27,7 @@
2527
2628/** 变更文件页签:逐文件 diff + 行内评论 + 待提交评审草稿。 */
2729export function FilesTab({ refObj, headSha, focusPath, focusLine = 0, onChanged }: FilesTabProps) {
30 const { t, tc } = useT()
2831 const [tick, setTick] = useState(0)
2932 const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
3033 const [draftAnchor, setDraftAnchor] = useState<InlineAnchor | null>(null)
@@ -88,7 +91,7 @@
8891 setTick(t => t + 1)
8992 onChanged()
9093 } catch (f) {
91 setError(f instanceof Error ? f.message : '评论失败')
94 setError(f instanceof Error ? f.message : t('pull.files.commentFailed'))
9295 } finally {
9396 setBusy(false)
9497 }
@@ -111,22 +114,22 @@
111114 setTick(t => t + 1)
112115 onChanged()
113116 } catch (f) {
114 setError(f instanceof Error ? f.message : '评审提交失败')
117 setError(f instanceof Error ? f.message : t('pull.files.reviewFailed'))
115118 } finally {
116119 setBusy(false)
117120 }
118121 }
119122
120 if (files.loading && !files.data) return <PanelNote text="加载变更文件…" />
121 if (failure) return <ErrorPanel status={failure.code ?? 0} what="变更文件" note={failure.message} onRetry={() => setTick(t => t + 1)} />
122 if (!rows.length) return <PanelNote text="没有文件变更。" />
123 if (files.loading && !files.data) return <PanelNote text={t('pull.files.loading')} />
124 if (failure) return <ErrorPanel status={failure.code ?? 0} what={t('pull.files.errorFiles')} note={failure.message} onRetry={() => setTick(tick => tick + 1)} />
125 if (!rows.length) return <PanelNote text={t('pull.files.empty')} />
123126
124127 return (
125128 <div className="space-y-3">
126129 <p className="text-[12.5px] text-shell-ink-muted">
127 {rows.length} 个文件改动 · <span className="text-[#1a7f37]">+{totalAdd}</span>{' '}
130 {tc('pull.files.summary', rows.length)} · <span className="text-[#1a7f37]">+{totalAdd}</span>{' '}
128131 <span className="text-[#b42318]">−{totalDel}</span>
129 {headSha ? <> · 对比版本 <Copyable text={headSha} /></> : null}
132 {headSha ? <> · {t('pull.files.compareVersion')} <Copyable text={headSha} /></> : null}
130133 </p>
131134
132135 {rows.map(diff => {
@@ -160,14 +163,14 @@
160163 value={draftBody}
161164 onChange={event => setDraftBody(event.target.value)}
162165 rows={3}
163 placeholder={`评论 ${anchor.path}:${anchor.line}(${anchor.side === 'left' ? '旧行' : '新行'})…`}
166 placeholder={t('pull.files.linePlaceholder', { path: anchor.path, line: anchor.line, side: anchor.side === 'left' ? t('pull.files.sideOld') : t('pull.files.sideNew') })}
164167 className={modalTextareaClass}
165168 />
166169 {error ? <p className="text-[12px] text-[#b42318]">{error}</p> : null}
167170 <div className="flex items-center gap-2">
168 <Button className="h-7 text-[12px]" disabled={busy} onClick={() => void postLineComment()}>直接评论此行</Button>
169 <Button variant="secondary" className="h-7 text-[12px]" disabled={busy} onClick={queueAsReviewDraft}>加入待提交评审</Button>
170 <Button variant="ghost" className="h-7 text-[12px]" onClick={() => setDraftAnchor(null)}>取消</Button>
171 <Button className="h-7 text-[12px]" disabled={busy} onClick={() => void postLineComment()}>{t('pull.files.postLine')}</Button>
172 <Button variant="secondary" className="h-7 text-[12px]" disabled={busy} onClick={queueAsReviewDraft}>{t('pull.files.queueReview')}</Button>
173 <Button variant="ghost" className="h-7 text-[12px]" onClick={() => setDraftAnchor(null)}>{t('common.action.cancel')}</Button>
171174 </div>
172175 </div>
173176 ) : null
@@ -180,23 +183,23 @@
180183
181184 {pending.length > 0 ? (
182185 <div className="sticky bottom-3 rounded-xl border-2 border-shell-tile bg-shell-surface p-4 shadow-lg">
183 <h4 className="mb-2 text-[13px] font-semibold">待提交的评审({pending.length} 条行内意见)</h4>
186 <h4 className="mb-2 text-[13px] font-semibold">{t('pull.files.pendingTitle', { count: pending.length })}</h4>
184187 <ul className="mb-2 max-h-32 space-y-1 overflow-y-auto text-[12px] text-shell-ink-muted">
185188 {pending.map((draft, index) => (
186189 <li key={index} className="flex items-center gap-2">
187190 <span className="min-w-0 flex-1 truncate font-mono">{draft.path}:{draft.line}</span>
188191 <span className="min-w-0 max-w-[50%] truncate">{draft.body}</span>
189 <button type="button" aria-label="移除该条意见" onClick={() => setPending(prev => prev.filter((_, i) => i !== index))} className="text-shell-ink-muted hover:text-[#b42318]">×</button>
192 <button type="button" aria-label={t('pull.files.removeDraft')} onClick={() => setPending(prev => prev.filter((_, i) => i !== index))} className="text-shell-ink-muted hover:text-[#b42318]">×</button>
190193 </li>
191194 ))}
192195 </ul>
193 <textarea value={reviewBody} onChange={event => setReviewBody(event.target.value)} rows={2} placeholder="评审总结(可选)…" className={modalTextareaClass} />
196 <textarea value={reviewBody} onChange={event => setReviewBody(event.target.value)} rows={2} placeholder={t('pull.files.reviewSummaryPlaceholder')} className={modalTextareaClass} />
194197 <div className="mt-2 flex items-center gap-2">
195 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => void submitPendingReview('approve')}>批准</Button>
196 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void submitPendingReview('request_changes')}>请求修改</Button>
197 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void submitPendingReview('comment')}>仅评论</Button>
198 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void submitPendingReview('pending')}>存为待提交评审</Button>
199 <Button variant="ghost" className="h-8 text-[12px]" onClick={() => setPending([])}>丢弃</Button>
198 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => void submitPendingReview('approve')}>{t('pull.files.approve')}</Button>
199 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void submitPendingReview('request_changes')}>{t('pull.files.requestChanges')}</Button>
200 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void submitPendingReview('comment')}>{t('pull.files.commentOnly')}</Button>
201 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void submitPendingReview('pending')}>{t('pull.files.saveAsPending')}</Button>
202 <Button variant="ghost" className="h-8 text-[12px]" onClick={() => setPending([])}>{t('pull.files.discard')}</Button>
200203 </div>
201204 </div>
202205 ) : null}
@@ -205,12 +208,17 @@
205208}
206209
207210function statusLabel(status: string): string {
208 return status === 'added' ? '新增'
209 : status === 'deleted' ? '删除'
210 : status === 'renamed' ? '重命名'
211 : status === 'copied' ? '复制'
212 : status === 'binary' ? '二进制'
213 : '修改'
211 const key = STATUS_KEYS[status]
212 return key ? tr(key) : tr('pull.files.status.modified')
213}
214
215const STATUS_KEYS: Record<string, MessageKey> = {
216 added: 'pull.files.status.added',
217 deleted: 'pull.files.status.deleted',
218 renamed: 'pull.files.status.renamed',
219 copied: 'pull.files.status.copied',
220 binary: 'pull.files.status.binary',
221 modified: 'pull.files.status.modified',
214222}
215223
216224const statusTone = (status: string) =>
Modified web/src/features/pull-request/ui/parts.tsx View web/src/features/pull-request/ui/parts.tsx
@@ -1,7 +1,9 @@
11import { Fragment, type ReactNode } from 'react'
2import type { MessageKey } from '@/shared/i18n'
23import { GitMerge, MessageSquareText, Tag as TagIcon } from 'lucide-react'
34import { InitialAvatar } from '@/shared/ui'
45import { shortSha } from '@/shared/lib'
6import { t as tr, useT } from '@/shared/i18n'
57import { parseHunks, type DiffFile, type DiffLine } from '@/entities/repo'
68import type { Pull, UserDto } from '@/entities/pull-request'
79import { anchorKey, PULL_STATE_LABEL, TONE, userName, type AnchoredComment, type InlineAnchor } from '../model/meta'
@@ -12,7 +14,7 @@
1214
1315export function PullStateBadge({ pull }: { pull: Pull }) {
1416 if (pull.is_draft) {
15 return <span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium ${TONE.gray}`}>草稿</span>
17 return <span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium ${TONE.gray}`}>{tr('pull.badge.draft')}</span>
1618 }
1719 const tone = pull.state === 'open' ? TONE.green : pull.state === 'merged' ? TONE.violet : TONE.gray
1820 return (
@@ -24,22 +26,25 @@
2426 )
2527}
2628
27const MERGEABLE_LABEL: Record<string, { text: string; tone: keyof typeof TONE }> = {
28 clean: { text: '可合并', tone: 'green' },
29 blocked: { text: '被门禁阻止', tone: 'amber' },
30 dirty: { text: '存在冲突', tone: 'red' },
31 unstable: { text: '检查未通过', tone: 'amber' },
32 has_hooks: { text: '等待钩子', tone: 'gray' },
33 unknown: { text: '状态未知', tone: 'gray' },
29const MERGEABLE_KEYS: Record<string, { key: MessageKey; tone: keyof typeof TONE }> = {
30 clean: { key: 'pull.mergeable.clean', tone: 'green' },
31 blocked: { key: 'pull.mergeable.blocked', tone: 'amber' },
32 dirty: { key: 'pull.mergeable.dirty', tone: 'red' },
33 unstable: { key: 'pull.mergeable.unstable', tone: 'amber' },
34 has_hooks: { key: 'pull.mergeable.hasHooks', tone: 'gray' },
35 unknown: { key: 'pull.mergeable.unknown', tone: 'gray' },
3436}
3537
3638export function MergeableChip({ state }: { state?: string | null }) {
39 const { t } = useT()
3740 if (!state) return null
38 const hit = MERGEABLE_LABEL[state] ?? { text: state, tone: 'gray' as const }
41 const entry = MERGEABLE_KEYS[state]
42 const hit = entry ? { text: t(entry.key), tone: entry.tone } : { text: state, tone: 'gray' as const }
3943 return <span className={`rounded px-1.5 py-0.5 text-[11.5px] font-medium ${TONE[hit.tone]}`}>{hit.text}</span>
4044}
4145
4246export function LabelPill({ name, color, onRemove }: { name: string; color?: string | null; onRemove?: () => void }) {
47 const { t } = useT()
4348 return (
4449 <span
4550 className="inline-flex items-center gap-1 rounded-full border border-shell-hairline px-2 py-0.5 text-[11.5px] font-medium text-shell-ink"
@@ -48,7 +53,7 @@
4853 <TagIcon className="h-3 w-3" aria-hidden />
4954 {name}
5055 {onRemove ? (
51 <button type="button" aria-label={`移除标签 ${name}`} onClick={onRemove} className="ml-0.5 text-shell-ink-muted hover:text-shell-ink">
56 <button type="button" aria-label={t('issue.board.removeLabel', { name })} onClick={onRemove} className="ml-0.5 text-shell-ink-muted hover:text-shell-ink">
5257 ×
5358 </button>
5459 ) : null}
@@ -90,9 +95,9 @@
9095 const canComment = Boolean(onRequestComment)
9196 return (
9297 <div className="overflow-x-auto">
93 {file.status === 'binary' ? <p className="px-3 py-2 text-[12.5px] text-shell-ink-muted">二进制文件,不显示差异内容。</p> : null}
98 {file.status === 'binary' ? <p className="px-3 py-2 text-[12.5px] text-shell-ink-muted">{tr('pull.parts.binaryDiff')}</p> : null}
9499 {file.status !== 'binary' && hunks.length === 0 ? (
95 <p className="px-3 py-2 text-[12.5px] text-shell-ink-muted">仅元数据变更。</p>
100 <p className="px-3 py-2 text-[12.5px] text-shell-ink-muted">{tr('pull.parts.metaOnly')}</p>
96101 ) : null}
97102 <table className="w-full border-collapse font-mono text-[12px] leading-5">
98103 <tbody>
@@ -125,7 +130,7 @@
125130 {canComment && lineNo != null ? (
126131 <button
127132 type="button"
128 title="在此行添加评审评论"
133 title={tr('pull.parts.addComment')}
129134 onClick={() => onRequestComment?.({ path: file.path, line: lineNo, side })}
130135 className="rounded bg-shell-active px-1 text-shell-ink-muted opacity-0 transition-opacity hover:text-shell-ink focus:opacity-100 group-hover/diff:opacity-100"
131136 >
@@ -157,7 +162,7 @@
157162 </tbody>
158163 </table>
159164 {truncated ? (
160 <p className="border-t border-shell-hairline px-3 py-2 text-[12px] text-[#8a6100]">差异过长,已截断显示前 1500 行。</p>
165 <p className="border-t border-shell-hairline px-3 py-2 text-[12px] text-[#8a6100]">{tr('pull.parts.truncated')}</p>
161166 ) : null}
162167 </div>
163168 )
Modified web/src/features/pull-request/ui/pull-detail.tsx View web/src/features/pull-request/ui/pull-detail.tsx
@@ -15,6 +15,7 @@
1515} from 'lucide-react'
1616import { Alert, Button, ErrorPanel, Modal, PanelNote, modalInputClass, modalLabelClass, modalTextareaClass } from '@/shared/ui'
1717import { fmtRelative, fmtTime, repoKey, useAsync } from '@/shared/lib'
18import { t as tr, useT } from '@/shared/i18n'
1819import { fetchConflictDetail } from '@/entities/repo'
1920import {
2021 addAssignee,
@@ -39,6 +40,7 @@
3940 updateBranchPull,
4041 updatePull,
4142} from '@/entities/pull-request'
43import type { MessageKey } from '@/shared/i18n'
4244import type { MergeMethod, Pull, PullGate, PullRef, ReviewerState } from '@/entities/pull-request'
4345import { MERGE_METHOD_LABEL, REVIEWER_STATE_LABEL, TONE, userName } from '../model/meta'
4446import { Avatar, MergeableChip, PullStateBadge, SideSection } from './parts'
@@ -60,16 +62,25 @@
6062 onOpenCommit: (sha: string) => void
6163}
6264
63const SUB_TABS: Array<{ id: PullSubTab; label: string; icon: typeof MessagesSquare }> = [
64 { id: 'conversation', label: '会话', icon: MessagesSquare },
65 { id: 'files', label: '变更文件', icon: FileCode2 },
66 { id: 'commits', label: '提交', icon: GitMerge },
67 { id: 'checks', label: '检查', icon: ListChecks },
68 { id: 'ai', label: 'AI 评审', icon: Sparkles },
65const SUB_TABS: Array<{ id: PullSubTab; icon: typeof MessagesSquare }> = [
66 { id: 'conversation', icon: MessagesSquare },
67 { id: 'files', icon: FileCode2 },
68 { id: 'commits', icon: GitMerge },
69 { id: 'checks', icon: ListChecks },
70 { id: 'ai', icon: Sparkles },
6971]
7072
73const SUB_TAB_KEYS: Record<PullSubTab, MessageKey> = {
74 conversation: 'pull.detail.tab.conversation',
75 files: 'pull.tabs.files',
76 commits: 'pull.tabs.commits',
77 checks: 'pull.detail.tab.checks',
78 ai: 'pull.tabs.ai',
79}
80
7181/** PR 详情页:头部/操作 + 子页签 + 侧栏。 */
7282export function PullDetail({ space, repo, pullRef, ptab, onPtabChange, onBackToList, onOpenCommit }: PullDetailProps) {
83 const { t } = useT()
7384 const [tick, setTick] = useState(0)
7485 const [focusFile, setFocusFile] = useState('')
7586 /** 从 AI 发现跳转过来时要高亮的行号(0 表示不高亮)。 */
@@ -92,19 +103,19 @@
92103 setError(null)
93104 try {
94105 await fn()
95 setNotice(`${label}完成。`)
106 setNotice(t('pull.detail.complete', { label }))
96107 refresh()
97108 } catch (f) {
98 setError(`${label}失败:${f instanceof Error ? f.message : '未知错误'}`)
109 setError(t('pull.detail.actionFailed', { label, reason: f instanceof Error ? f.message : t('common.state.unknown') }))
99110 }
100111 }
101112
102 if (pullState.loading && !pull) return <PanelNote text="加载合并请求…" />
113 if (pullState.loading && !pull) return <PanelNote text={t('pull.detail.loading')} />
103114 if (!pull) {
104115 return failure ? (
105 <ErrorPanel status={failure.code ?? 0} what="合并请求" note={failure.message} onRetry={refresh} />
116 <ErrorPanel status={failure.code ?? 0} what={t('pull.detail.errorPull')} note={failure.message} onRetry={refresh} />
106117 ) : (
107 <PanelNote text="合并请求不存在。" />
118 <PanelNote text={t('pull.detail.notFound')} />
108119 )
109120 }
110121
@@ -112,7 +123,7 @@
112123 <div className="flex flex-col gap-4 lg:flex-row lg:items-start">
113124 <div className="min-w-0 flex-1">
114125 <div className="mb-2 flex flex-wrap items-center gap-2 text-[12.5px] text-shell-ink-muted">
115 <button type="button" onClick={onBackToList} className="hover:underline">合并请求</button>
126 <button type="button" onClick={onBackToList} className="hover:underline">{t('pull.detail.crumb')}</button>
116127 <span aria-hidden>/</span>
117128 <span className="font-mono">#{pull.number}</span>
118129 </div>
@@ -122,13 +133,12 @@
122133 <PullStateBadge pull={pull} />
123134 </div>
124135 <p className="mb-3 text-[12.5px] text-shell-ink-muted">
125 <span className="font-medium text-shell-ink">{userName(pull.author)}</span> 请求将{' '}
126 <code className="rounded bg-shell-active/70 px-1 py-0.5 font-mono text-[11.5px]">{pull.head.branch}</code> 合并到{' '}
127 <code className="rounded bg-shell-active/70 px-1 py-0.5 font-mono text-[11.5px]">{pull.base.branch}</code>
128 <span className="mx-1.5">·</span>更新于 {fmtRelative(pull.updated_at ?? pull.created_at)}
136 <span className="font-medium text-shell-ink">{userName(pull.author)}</span>{' '}
137 <code className="rounded bg-shell-active/70 px-1 py-0.5 font-mono text-[11.5px]">{t('pull.detail.requestsMerge', { head: pull.head.branch, base: pull.base.branch })}</code>
138 <span className="mx-1.5">·</span>{t('pull.detail.updatedAt', { time: fmtRelative(pull.updated_at ?? pull.created_at) }) }
129139 <MergeableChip state={pull.mergeable_state} />
130 {pull.locked ? <span className={`ml-2 rounded px-1.5 py-0.5 text-[11.5px] ${TONE.amber}`}>已锁定</span> : null}
131 {pull.auto_merge ? <span className={`ml-2 rounded px-1.5 py-0.5 text-[11.5px] ${TONE.green}`}>自动合并已开</span> : null}
140 {pull.locked ? <span className={`ml-2 rounded px-1.5 py-0.5 text-[11.5px] ${TONE.amber}`}>{t('pull.detail.locked')}</span> : null}
141 {pull.auto_merge ? <span className={`ml-2 rounded px-1.5 py-0.5 text-[11.5px] ${TONE.green}`}>{t('pull.detail.autoMergeOn')}</span> : null}
132142 </p>
133143
134144 {notice ? <Alert tone="success">{notice}</Alert> : null}
@@ -147,9 +157,9 @@
147157 ptab === tab.id ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:text-shell-ink'
148158 }`}
149159 >
150 <Icon className="h-3.5 w-3.5" aria-hidden />{tab.label}
151 {tab.id === 'files' && pull.files_changed ? `(${pull.files_changed})` : ''}
152 {tab.id === 'commits' && pull.commits_ahead ? `(${pull.commits_ahead})` : ''}
160 <Icon className="h-3.5 w-3.5" aria-hidden />{t(SUB_TAB_KEYS[tab.id])}
161 {tab.id === 'files' && pull.files_changed ? ` (${pull.files_changed})` : ''}
162 {tab.id === 'commits' && pull.commits_ahead ? ` (${pull.commits_ahead})` : ''}
153163 </button>
154164 )
155165 })}
@@ -187,52 +197,52 @@
187197 <div className="w-full shrink-0 space-y-3 lg:w-[300px]">
188198 {pull.state === 'open' ? <MergeBox refObj={refObj} pull={pull} gate={gate} onChanged={refresh} onError={setError} /> : null}
189199
190 <SideSection title="操作">
200 <SideSection title={t('pull.detail.actions')}>
191201 <div className="flex flex-wrap gap-1.5">
192202 {pull.state === 'open' ? (
193 <Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => void run('关闭', () => closePull(refObj))}>
194 <X className="h-3 w-3" aria-hidden />关闭
203 <Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => void run(t('pull.detail.close'), () => closePull(refObj))}>
204 <X className="h-3 w-3" aria-hidden />{t('pull.detail.close')}
195205 </Button>
196206 ) : pull.state === 'closed' ? (
197 <Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => void run('重开', () => reopenPull(refObj))}>
198 <RotateCcw className="h-3 w-3" aria-hidden />重新打开
207 <Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => void run(t('pull.detail.reopen'), () => reopenPull(refObj))}>
208 <RotateCcw className="h-3 w-3" aria-hidden />{t('pull.detail.reopen')}
199209 </Button>
200210 ) : null}
201211 <EditButton refObj={refObj} pull={pull} onChanged={refresh} onError={setError} />
202212 <Button
203213 variant="secondary"
204214 className="h-7 gap-1 text-[12px]"
205 onClick={() => void run(pull.locked ? '解锁' : '锁定', () => (pull.locked ? unlockPull(refObj) : lockPull(refObj)))}
215 onClick={() => void run(pull.locked ? t('pull.detail.unlock') : t('pull.detail.lock'), () => (pull.locked ? unlockPull(refObj) : lockPull(refObj)))}
206216 >
207217 {pull.locked ? <Unlock className="h-3 w-3" aria-hidden /> : <ShieldCheck className="h-3 w-3" aria-hidden />}
208 {pull.locked ? '解锁讨论' : '锁定讨论'}
218 {pull.locked ? t('pull.detail.unlockDiscussion') : t('pull.detail.lockDiscussion')}
209219 </Button>
210 <Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => void run('刷新摘要', () => refreshPullSummary(refObj))}>
211 <RotateCcw className="h-3 w-3" aria-hidden />刷新摘要
220 <Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => void run(t('pull.detail.refreshSummary'), () => refreshPullSummary(refObj))}>
221 <RotateCcw className="h-3 w-3" aria-hidden />{t('pull.detail.refreshSummary')}
212222 </Button>
213223 <Button
214224 variant="secondary"
215225 className="h-7 gap-1 text-[12px]"
216226 onClick={() => window.open(pullPatchUrl(refObj), '_blank', 'noopener')}
217227 >
218 <Download className="h-3 w-3" aria-hidden />补丁
228 <Download className="h-3 w-3" aria-hidden />{t('pull.detail.patch')}
219229 </Button>
220230 <Button
221231 variant="ghost"
222232 className="h-7 text-[12px] text-[#b42318]"
223233 onClick={() => {
224 if (!window.confirm(`删除 #${pull.number}?该操作不可恢复。`)) return
234 if (!window.confirm(t('pull.detail.deleteConfirm', { number: pull.number }))) return
225235 void (async () => {
226236 try {
227237 await deletePull(refObj)
228238 onBackToList()
229239 } catch (f) {
230 setError(`删除失败:${f instanceof Error ? f.message : '未知错误'}`)
240 setError(t('pull.detail.deleteFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
231241 }
232242 })()
233243 }}
234244 >
235 删除
245 {t('common.action.delete')}
236246 </Button>
237247 </div>
238248 </SideSection>
@@ -241,17 +251,17 @@
241251 <AssigneesPanel refObj={refObj} pull={pull} tick={tick} onChanged={refresh} onError={setError} />
242252 <LabelsPanel refObj={refObj} tick={tick} onChanged={refresh} onError={setError} />
243253
244 <SideSection title="统计">
254 <SideSection title={t('pull.detail.stats')}>
245255 <dl className="space-y-1 text-[12.5px] text-shell-ink-muted">
246 <div className="flex justify-between"><dt>领先 / 落后</dt><dd className="font-mono text-shell-ink">{pull.commits_ahead ?? '—'} / {pull.commits_behind ?? '—'}</dd></div>
247 <div className="flex justify-between"><dt>文件变更</dt><dd className="font-mono text-shell-ink">{pull.files_changed}</dd></div>
256 <div className="flex justify-between"><dt>{t('pull.detail.aheadBehind')}</dt><dd className="font-mono text-shell-ink">{pull.commits_ahead ?? '—'} / {pull.commits_behind ?? '—'}</dd></div>
257 <div className="flex justify-between"><dt>{t('pull.detail.filesChanged')}</dt><dd className="font-mono text-shell-ink">{pull.files_changed}</dd></div>
248258 <div className="flex justify-between">
249 <dt>增删行</dt>
259 <dt>{t('pull.detail.lines')}</dt>
250260 <dd className="font-mono"><span className="text-[#1a7f37]">+{pull.additions}</span> <span className="text-[#b42318]">−{pull.deletions}</span></dd>
251261 </div>
252 <div className="flex justify-between"><dt>创建</dt><dd title={fmtTime(pull.created_at)}>{fmtRelative(pull.created_at)}</dd></div>
253 {pull.merged_at ? <div className="flex justify-between"><dt>合并于</dt><dd>{fmtRelative(pull.merged_at)}</dd></div> : null}
254 {pull.merged_commit_sha ? <div className="flex justify-between"><dt>合并提交</dt><dd className="font-mono">{pull.merged_commit_sha.slice(0, 10)}</dd></div> : null}
262 <div className="flex justify-between"><dt>{t('pull.detail.created')}</dt><dd title={fmtTime(pull.created_at)}>{fmtRelative(pull.created_at)}</dd></div>
263 {pull.merged_at ? <div className="flex justify-between"><dt>{t('pull.detail.mergedAt')}</dt><dd>{fmtRelative(pull.merged_at)}</dd></div> : null}
264 {pull.merged_commit_sha ? <div className="flex justify-between"><dt>{t('pull.detail.mergeCommit')}</dt><dd className="font-mono">{pull.merged_commit_sha.slice(0, 10)}</dd></div> : null}
255265 </dl>
256266 </SideSection>
257267 </div>
@@ -268,6 +278,7 @@
268278 onChanged: () => void
269279 onError: (message: string) => void
270280}) {
281 const { t } = useT()
271282 const [method, setMethod] = useState<MergeMethod>(pull.merge_method || 'merge')
272283 const [title, setTitle] = useState('')
273284 const [message, setMessage] = useState('')
@@ -290,21 +301,21 @@
290301 expect_head_sha: pull.head.sha,
291302 })
292303 onChanged()
293 window.alert(`合并成功:${outcome.result.new_sha.slice(0, 10)}`)
304 window.alert(t('pull.detail.mergeSucceeded', { sha: outcome.result.new_sha.slice(0, 10) }))
294305 } catch (f) {
295 onError(`合并失败:${f instanceof Error ? f.message : '未知错误'}`)
306 onError(t('pull.detail.mergeFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
296307 } finally {
297308 setBusy(false)
298309 }
299310 }
300311
301312 return (
302 <SideSection title="合并">
313 <SideSection title={t('pull.detail.merge')}>
303314 {pull.is_draft ? (
304 <p className="text-[12.5px] text-[#8a6100]">这是草稿 PR——点击右上角「编辑」将其转为正式后再评审合并。</p>
315 <p className="text-[12.5px] text-[#8a6100]">{t('pull.detail.draftMergeHint')}</p>
305316 ) : conflicts.length ? (
306317 <div className="text-[12.5px] text-[#b42318]">
307 存在 {conflicts.length} 个冲突文件,无法直接合并:
318 {t('pull.detail.conflicts', { count: conflicts.length })}
308319 <ul className="mt-1 max-h-32 space-y-0.5 overflow-y-auto">
309320 {conflicts.map(path => (
310321 <li key={path}>
@@ -315,13 +326,13 @@
315326 </div>
316327 ) : g && !g.allowed ? (
317328 <p className="text-[12.5px] text-[#8a6100]">
318 被保护规则拦截:{g.reasons.join(';') || `需要 ${g.required_approvals} 个批准(当前 ${g.approvals})`}
319 {g.bypassed ? '(你可绕过)' : ''}
329 {t('pull.detail.blockedByRules', { reasons: g.reasons.join('; ') || t('pull.detail.needsApprovals', { required: g.required_approvals, current: g.approvals }) })}
330 {g.bypassed ? t('pull.detail.canBypass') : ''}
320331 </p>
321332 ) : stale ? (
322 <p className="text-[12.5px] text-[#8a6100]">源分支落后于目标分支 {pull.commits_behind} 个提交。</p>
333 <p className="text-[12.5px] text-[#8a6100]">{t('pull.detail.staleBranch', { count: pull.commits_behind ?? 0 })}</p>
323334 ) : (
324 <p className="text-[12.5px] text-[#1a7f37]">满足合并条件{g ? `(批准 ${g.approvals}/${g.required_approvals})` : ''}。</p>
335 <p className="text-[12.5px] text-[#1a7f37]">{t('pull.detail.readyToMerge')}{g ? t('pull.detail.approvals', { current: g.approvals, required: g.required_approvals }) : ''}</p>
325336 )}
326337
327338 {g?.outdated && stale ? (
@@ -335,36 +346,36 @@
335346 await updateBranchPull(refObj)
336347 onChanged()
337348 } catch (f) {
338 onError(`同步分支失败:${f instanceof Error ? f.message : '未知错误'}`)
349 onError(t('pull.detail.syncFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
339350 }
340351 })()
341352 }}
342353 >
343 同步目标分支到源分支
354 {t('pull.detail.syncBranch')}
344355 </Button>
345356 ) : null}
346357
347358 <div className="mt-2 space-y-2">
348359 <label className="block">
349 <span className={modalLabelClass}>合并方式</span>
360 <span className={modalLabelClass}>{t('pull.mergeMethod')}</span>
350361 <select className={modalInputClass} value={method} onChange={event => setMethod(event.target.value as MergeMethod)}>
351362 {(Object.keys(MERGE_METHOD_LABEL) as MergeMethod[]).map(value => (
352363 <option key={value} value={value}>{MERGE_METHOD_LABEL[value]}</option>
353364 ))}
354365 </select>
355366 </label>
356 <input className={modalInputClass} placeholder="合并标题(默认 PR 标题)" value={title} onChange={event => setTitle(event.target.value)} />
357 <textarea className={modalTextareaClass} rows={2} placeholder="合并说明(默认取提交信息)" value={message} onChange={event => setMessage(event.target.value)} />
367 <input className={modalInputClass} placeholder={t('pull.detail.mergeTitlePlaceholder')} value={title} onChange={event => setTitle(event.target.value)} />
368 <textarea className={modalTextareaClass} rows={2} placeholder={t('pull.detail.mergeMessagePlaceholder')} value={message} onChange={event => setMessage(event.target.value)} />
358369 <label className="flex items-center gap-2 text-[12.5px]">
359370 <input type="checkbox" checked={deleteAfter} onChange={event => setDeleteAfter(event.target.checked)} />
360 合并后删除源分支
371 {t('pull.detail.deleteBranchAfter')}
361372 </label>
362373 <Button
363374 className="h-8 w-full gap-1 text-[13px]"
364375 disabled={busy || pull.is_draft || conflicts.length > 0 || blocked || stale}
365376 onClick={() => void merge()}
366377 >
367 <GitMerge className="h-3.5 w-3.5" aria-hidden />{busy ? '合并中…' : '合并'}
378 <GitMerge className="h-3.5 w-3.5" aria-hidden />{busy ? t('pull.detail.merging') : t('pull.detail.merge')}
368379 </Button>
369380 <label className="flex items-center gap-2 text-[12.5px] text-shell-ink-muted">
370381 <input
@@ -376,12 +387,12 @@
376387 await updatePull(refObj, { auto_merge: event.target.checked })
377388 onChanged()
378389 } catch (f) {
379 onError(`自动合并设置失败:${f instanceof Error ? f.message : '未知错误'}`)
390 onError(t('pull.detail.autoMergeFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
380391 }
381392 })()
382393 }}
383394 />
384 满足条件后自动合并
395 {t('pull.detail.autoMerge')}
385396 </label>
386397 </div>
387398 </SideSection>
@@ -395,22 +406,22 @@
395406 <button
396407 type="button"
397408 className="block max-w-full truncate text-left font-mono text-[12px] underline underline-offset-2 hover:brightness-125"
398 title="点击查看冲突内容"
409 title={tr('pull.detail.viewConflict')}
399410 onClick={() => {
400411 setDetail('loading')
401412 void fetchConflictDetail(space, repo, base, head, path)
402 .then(d => setDetail(d.content ?? '(无冲突文本可展示)'))
413 .then(d => setDetail(d.content ?? tr('pull.detail.noConflictText')))
403414 .catch(() => setDetail(null))
404415 }}
405416 >
406417 {path}
407418 </button>
408419 {detail === null ? null : (
409 <Modal title={`冲突内容 · ${path}`} open width="max-w-[760px]" onClose={() => setDetail(null)}
410 footer={<Button variant="ghost" onClick={() => setDetail(null)}>关闭</Button>}
420 <Modal title={tr('pull.detail.conflictContent', { path })} open width="max-w-[760px]" onClose={() => setDetail(null)}
421 footer={<Button variant="ghost" onClick={() => setDetail(null)}>{tr('common.action.close')}</Button>}
411422 >
412423 {detail === 'loading' ? (
413 <p className="text-[12.5px] text-shell-ink-muted">加载冲突文本…</p>
424 <p className="text-[12.5px] text-shell-ink-muted">{tr('pull.detail.loadingConflict')}</p>
414425 ) : (
415426 <pre className="max-h-[60vh] overflow-auto rounded-lg border border-shell-hairline bg-shell-field p-3 font-mono text-[11.5px] leading-5">{detail}</pre>
416427 )}
@@ -423,6 +434,7 @@
423434/* ---------------------------------------------------------- edit dialog */
424435
425436function EditButton({ refObj, pull, onChanged, onError }: { refObj: PullRef; pull: Pull; onChanged: () => void; onError: (m: string) => void }) {
437 const { t } = useT()
426438 const [open, setOpen] = useState(false)
427439 const [title, setTitle] = useState(pull.title)
428440 const [body, setBody] = useState(pull.body)
@@ -436,7 +448,7 @@
436448 setOpen(false)
437449 onChanged()
438450 } catch (f) {
439 onError(`更新失败:${f instanceof Error ? f.message : '未知错误'}`)
451 onError(t('pull.detail.updateFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
440452 } finally {
441453 setBusy(false)
442454 }
@@ -445,31 +457,31 @@
445457 return (
446458 <>
447459 <Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => { setTitle(pull.title); setBody(pull.body); setDraft(pull.is_draft); setOpen(true) }}>
448 <Pencil className="h-3 w-3" aria-hidden />编辑
460 <Pencil className="h-3 w-3" aria-hidden />{t('issue.board.edit')}
449461 </Button>
450462 {open ? (
451463 <Modal
452 title={`编辑 #${pull.number}`}
464 title={t('pull.detail.editNumber', { number: pull.number })}
453465 open
454466 onClose={() => setOpen(false)}
455467 footer={
456468 <>
457 <Button variant="ghost" onClick={() => setOpen(false)}>取消</Button>
458 <Button disabled={busy} onClick={() => void save()}>保存</Button>
469 <Button variant="ghost" onClick={() => setOpen(false)}>{t('common.action.cancel')}</Button>
470 <Button disabled={busy} onClick={() => void save()}>{t('common.action.save')}</Button>
459471 </>
460472 }
461473 >
462474 <label className="block">
463 <span className={modalLabelClass}>标题</span>
475 <span className={modalLabelClass}>{t('issue.board.form.title')}</span>
464476 <input className={modalInputClass} value={title} onChange={event => setTitle(event.target.value)} />
465477 </label>
466478 <label className="block">
467 <span className={modalLabelClass}>描述</span>
479 <span className={modalLabelClass}>{t('pull.description')}</span>
468480 <textarea className={modalTextareaClass} rows={7} value={body} onChange={event => setBody(event.target.value)} />
469481 </label>
470482 <label className="flex items-center gap-2 text-[13px]">
471483 <input type="checkbox" checked={draft} onChange={event => setDraft(event.target.checked)} />
472 草稿(不接收评审与合并)
484 {t('pull.detail.draftCheckbox')}
473485 </label>
474486 </Modal>
475487 ) : null}
@@ -506,6 +518,7 @@
506518}
507519
508520function ReviewersPanel({ refObj, pull, tick, onChanged, onError }: { refObj: PullRef; pull: Pull; tick: number; onChanged: () => void; onError: (m: string) => void }) {
521 const { t } = useT()
509522 const [required, setRequired] = useState(true)
510523 const reviewers = useAsync(() => listRequestedReviewers(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
511524 const rows = reviewers.data ?? []
@@ -515,17 +528,17 @@
515528 await requestReviewers(refObj, [name], required)
516529 onChanged()
517530 } catch (f) {
518 onError(`请求评审失败:${f instanceof Error ? f.message : '未知错误'}`)
531 onError(t('pull.detail.requestReviewFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
519532 throw f
520533 }
521534 }
522535
523536 return (
524537 <SideSection
525 title={`评审人${rows.length ? `(${rows.length})` : ''}`}
538 title={`${t('pull.detail.reviewers')}${rows.length ? ` (${rows.length})` : ''}`}
526539 action={
527540 <label className="flex items-center gap-1 text-[11.5px] text-shell-ink-muted">
528 <input type="checkbox" checked={required} onChange={event => setRequired(event.target.checked)} />必需
541 <input type="checkbox" checked={required} onChange={event => setRequired(event.target.checked)} />{t('pull.detail.required')}
529542 </label>
530543 }
531544 >
@@ -540,7 +553,7 @@
540553 row.user ? (
541554 <button
542555 type="button"
543 aria-label={`移除 ${userName(row.user)}`}
556 aria-label={t('pull.detail.remove', { name: userName(row.user) })}
544557 className="text-shell-ink-muted hover:text-[#b42318]"
545558 onClick={() => void removeRequestedReviewer(refObj, row.user?.user_id ?? '').then(onChanged).catch(() => undefined)}
546559 >
@@ -552,9 +565,9 @@
552565 ))}
553566 </ul>
554567 ) : (
555 <p className="text-[12.5px] text-shell-ink-muted">尚未请求评审。</p>
568 <p className="text-[12.5px] text-shell-ink-muted">{t('pull.detail.noReviewers')}</p>
556569 )}
557 {pull.state === 'open' ? <AddLine placeholder="用户名或 UUID,请求评审" onAdd={add} /> : null}
570 {pull.state === 'open' ? <AddLine placeholder={t('pull.detail.reviewRequestPlaceholder')} onAdd={add} /> : null}
558571 </SideSection>
559572 )
560573}
@@ -564,10 +577,11 @@
564577}
565578
566579function AssigneesPanel({ refObj, pull, tick, onChanged, onError }: { refObj: PullRef; pull: Pull; tick: number; onChanged: () => void; onError: (m: string) => void }) {
580 const { t } = useT()
567581 const assignees = useAsync(() => listAssignees(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
568582 const rows = assignees.data ?? []
569583 return (
570 <SideSection title={`受理人${rows.length ? `(${rows.length})` : ''}`}>
584 <SideSection title={`${t('pull.detail.assignees')}${rows.length ? ` (${rows.length})` : ''}`}>
571585 {rows.length ? (
572586 <ul className="space-y-1.5">
573587 {rows.map(row => (
@@ -577,7 +591,7 @@
577591 {pull.state === 'open' ? (
578592 <button
579593 type="button"
580 aria-label={`移除 ${userName(row.user)}`}
594 aria-label={t('pull.detail.remove', { name: userName(row.user) })}
581595 className="text-shell-ink-muted hover:text-[#b42318]"
582596 onClick={() => void removeAssignee(refObj, row.user?.username ?? row.user?.user_id ?? '').then(onChanged).catch(() => undefined)}
583597 >
@@ -588,17 +602,17 @@
588602 ))}
589603 </ul>
590604 ) : (
591 <p className="text-[12.5px] text-shell-ink-muted">还没有受理人。</p>
605 <p className="text-[12.5px] text-shell-ink-muted">{t('pull.detail.noAssignees')}</p>
592606 )}
593607 {pull.state === 'open' ? (
594608 <AddLine
595 placeholder="用户名,添加受理人"
609 placeholder={t('pull.detail.assigneePlaceholder')}
596610 onAdd={async name => {
597611 try {
598612 await addAssignee(refObj, name)
599613 onChanged()
600614 } catch (f) {
601 onError(`添加受理人失败:${f instanceof Error ? f.message : '未知错误'}`)
615 onError(t('pull.detail.addAssigneeFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
602616 throw f
603617 }
604618 }}
@@ -609,10 +623,11 @@
609623}
610624
611625function LabelsPanel({ refObj, tick, onChanged, onError }: { refObj: PullRef; tick: number; onChanged: () => void; onError: (m: string) => void }) {
626 const { t } = useT()
612627 const labels = useAsync(() => listPullLabels(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
613628 const rows = labels.data ?? []
614629 return (
615 <SideSection title="标签">
630 <SideSection title={t('issue.board.labelsField')}>
616631 {rows.length ? (
617632 <div className="flex flex-wrap gap-1.5">
618633 {rows.map(label => (
@@ -620,16 +635,16 @@
620635 ))}
621636 </div>
622637 ) : (
623 <p className="text-[12.5px] text-shell-ink-muted">没有标签。</p>
638 <p className="text-[12.5px] text-shell-ink-muted">{t('pull.detail.noLabels')}</p>
624639 )}
625640 <AddLine
626 placeholder="标签名或 UUID,附加标签"
641 placeholder={t('pull.detail.labelPlaceholder')}
627642 onAdd={async name => {
628643 try {
629644 await attachLabel(refObj, name)
630645 onChanged()
631646 } catch (f) {
632 onError(`附加标签失败:${f instanceof Error ? f.message : '未知错误'}`)
647 onError(t('pull.detail.attachLabelFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
633648 throw f
634649 }
635650 }}
@@ -639,13 +654,14 @@
639654}
640655
641656function LabelPillInline({ name, color, onRemove }: { name: string; color?: string | null; onRemove: () => void }) {
657 const { t } = useT()
642658 return (
643659 <span
644660 className="inline-flex items-center gap-1 rounded-full border border-shell-hairline px-2 py-0.5 text-[11.5px]"
645661 style={color ? { backgroundColor: `${color}22`, borderColor: color } : undefined}
646662 >
647663 {name}
648 <button type="button" aria-label={`移除标签 ${name}`} onClick={onRemove} className="text-shell-ink-muted hover:text-shell-ink">×</button>
664 <button type="button" aria-label={t('issue.board.removeLabel', { name })} onClick={onRemove} className="text-shell-ink-muted hover:text-shell-ink">×</button>
649665 </span>
650666 )
651667}
Modified web/src/features/pull-request/ui/pull-form-modal.tsx View web/src/features/pull-request/ui/pull-form-modal.tsx
@@ -5,6 +5,7 @@
55import { checkMergeStatus, createPull } from '@/entities/pull-request'
66import type { MergeStatus, Pull } from '@/entities/pull-request'
77import { Modal, modalInputClass, modalLabelClass, modalTextareaClass } from '@/shared/ui'
8import { useT } from '@/shared/i18n'
89
910export interface PullFormModalProps {
1011 space: string
@@ -16,6 +17,7 @@
1617
1718/** 新建合并请求:选分支、填标题描述,并实时探测 base…head 的可合并性。 */
1819export function PullFormModal({ space, repo, defaultBase, onClose, onCreated }: PullFormModalProps) {
20 const { t } = useT()
1921 const [branches, setBranches] = useState<string[]>([])
2022 const [head, setHead] = useState('')
2123 const [base, setBase] = useState(defaultBase)
@@ -64,11 +66,11 @@
6466
6567 async function submit() {
6668 if (!title.trim() || !head || !base) {
67 setError('标题、源分支和目标分支都不能为空。')
69 setError(t('pull.form.missingFields'))
6870 return
6971 }
7072 if (head === base) {
71 setError('源分支和目标分支不能相同。')
73 setError(t('pull.form.sameBranch'))
7274 return
7375 }
7476 setBusy(true)
@@ -83,35 +85,35 @@
8385 })
8486 onCreated(pull)
8587 } catch (failure) {
86 setError(failure instanceof Error ? failure.message : '创建失败')
88 setError(failure instanceof Error ? failure.message : t('pull.form.createFailed'))
8789 setBusy(false)
8890 }
8991 }
9092
9193 return (
9294 <Modal
93 title="新建合并请求"
94 description={`${repo} · 将源分支合入目标分支`}
95 title={t('pull.form.title')}
96 description={t('pull.form.description', { repo })}
9597 open
9698 onClose={onClose}
9799 footer={
98100 <>
99 <Button variant="ghost" onClick={onClose}>取消</Button>
101 <Button variant="ghost" onClick={onClose}>{t('common.action.cancel')}</Button>
100102 <Button disabled={busy} onClick={() => void submit()}>
101 {busy ? '创建中…' : draft ? '创建草稿' : '创建合并请求'}
103 {busy ? t('pull.form.creating') : draft ? t('pull.form.createDraft') : t('pull.form.create')}
102104 </Button>
103105 </>
104106 }
105107 >
106108 <div className="grid gap-3 sm:grid-cols-2">
107109 <label>
108 <span className={modalLabelClass}>源分支(head)</span>
110 <span className={modalLabelClass}>{t('pull.form.head')}</span>
109111 <select className={modalInputClass} value={head} onChange={event => setHead(event.target.value)}>
110112 {branches.map(name => <option key={name} value={name}>{name}</option>)}
111113 </select>
112114 </label>
113115 <label>
114 <span className={modalLabelClass}>目标分支(base)</span>
116 <span className={modalLabelClass}>{t('pull.form.base')}</span>
115117 <select className={modalInputClass} value={base} onChange={event => setBase(event.target.value)}>
116118 {branches.map(name => <option key={name} value={name}>{name}</option>)}
117119 </select>
@@ -120,35 +122,35 @@
120122
121123 {head && base && head !== base ? (
122124 status === 'pending' ? (
123 <p className="text-[12.5px] text-shell-ink-muted">正在比较 {base} … {head}…</p>
125 <p className="text-[12.5px] text-shell-ink-muted">{t('pull.form.comparing', { base, head })}</p>
124126 ) : status?.mergeable ? (
125127 <p className="flex items-center gap-1.5 text-[12.5px] text-[#1a7f37]">
126128 <CheckCircle2 className="h-3.5 w-3.5" aria-hidden />
127 可以快进合并,无冲突{status.fast_forwardable ? '' : '(将生成合并提交)'}
129 {t('pull.form.fastForward')}{status.fast_forwardable ? '' : t('pull.form.willMergeCommit')}
128130 </p>
129131 ) : status ? (
130132 <p className="flex items-start gap-1.5 text-[12.5px] text-[#b42318]">
131133 <AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
132134 <span>
133 存在 {status.conflicts.length} 处冲突
134 {status.conflicts.length ? <span className="block break-all font-mono text-[11px]">{status.conflicts.slice(0, 5).join('、')}{status.conflicts.length > 5 ? ' …' : ''}</span> : null}
135 ,仍可创建 PR,但需要先解决冲突才能合并。
135 {t('pull.form.conflicts', { count: status.conflicts.length })}
136 {status.conflicts.length ? <span className="block break-all font-mono text-[11px]">{status.conflicts.slice(0, 5).join(', ')}{status.conflicts.length > 5 ? ' …' : ''}</span> : null}
137 {t('pull.form.conflictsHint')}
136138 </span>
137139 </p>
138140 ) : null
139141 ) : null}
140142
141143 <label>
142 <span className={modalLabelClass}>标题</span>
143 <input className={modalInputClass} value={title} maxLength={200} placeholder={head ? `合并 ${head} 到 ${base}` : '简短描述这次改动'} onChange={event => setTitle(event.target.value)} />
144 <span className={modalLabelClass}>{t('pull.title')}</span>
145 <input className={modalInputClass} value={title} maxLength={200} placeholder={head ? t('pull.form.titlePlaceholder', { head, base }) : t('pull.form.titlePlaceholderFallback')} onChange={event => setTitle(event.target.value)} />
144146 </label>
145147 <label>
146 <span className={modalLabelClass}>描述(支持换行,纯文本渲染)</span>
147 <textarea className={modalTextareaClass} rows={6} value={body} placeholder="说明改动动机、验证方式与风险点…" onChange={event => setBody(event.target.value)} />
148 <span className={modalLabelClass}>{t('pull.form.descriptionField')}</span>
149 <textarea className={modalTextareaClass} rows={6} value={body} placeholder={t('pull.form.bodyPlaceholder')} onChange={event => setBody(event.target.value)} />
148150 </label>
149151 <label className="flex items-center gap-2 text-[13px]">
150152 <input type="checkbox" checked={draft} onChange={event => setDraft(event.target.checked)} />
151 以草稿创建(标记为 Draft,ready 后再请求评审)
153 {t('pull.form.draftCheckbox')}
152154 </label>
153155 {error ? <p className="text-[12.5px] text-[#b42318]">{error}</p> : null}
154156 </Modal>
Modified web/src/features/pull-request/ui/pulls-tab.tsx View web/src/features/pull-request/ui/pulls-tab.tsx
@@ -3,6 +3,7 @@
33import { Button, ErrorPanel, PanelNote } from '@/shared/ui'
44import { fmtRelative, repoKey, useAsync } from '@/shared/lib'
55import { listMyPulls, listPulls } from '@/entities/pull-request'
6import { useT } from '@/shared/i18n'
67import { MY_PULL_SCOPES } from '@/entities/pull-request'
78import type { MyPullScope } from '@/entities/pull-request'
89import { PullFilter, type PullFilterValue } from '../model/filters'
@@ -21,14 +22,15 @@
2122 onOpenPull: (pullRef: string) => void
2223}
2324
24const SCOPE_LABEL: Record<MyPullScope, string> = {
25 created: '我发起的',
26 'review-requested': '待我评审',
27 reviewed: '我评审过的',
25const SCOPE_KEYS: Record<MyPullScope, 'pull.list.scope.created' | 'pull.list.scope.reviewRequested' | 'pull.list.scope.reviewed'> = {
26 created: 'pull.list.scope.created',
27 'review-requested': 'pull.list.scope.reviewRequested',
28 reviewed: 'pull.list.scope.reviewed',
2829}
2930
3031/** 合并请求 页签:本仓库列表 + “我的 PR” 收件箱(限定本仓库,可深链)。 */
3132export function PullsTab({ space, repo, repoId, defaultBranch, onOpenPull }: PullsTabProps) {
33 const { t, tc } = useT()
3234 const [source, setSource] = useState<'repo' | 'mine'>('repo')
3335 const [scope, setScope] = useState<MyPullScope>('review-requested')
3436 const [filter, setFilter] = useState<PullFilterValue>({ state: 'open', search: '' })
@@ -87,7 +89,7 @@
8789 source === value ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:text-shell-ink'
8890 }`}
8991 >
90 {value === 'repo' ? '本仓库' : <><Inbox className="h-3.5 w-3.5" aria-hidden />我的 PR</>}
92 {value === 'repo' ? t('pull.list.repo') : <><Inbox className="h-3.5 w-3.5" aria-hidden />{t('pull.list.mine')}</>}
9193 </button>
9294 ))}
9395 </div>
@@ -103,7 +105,7 @@
103105 scope === value ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:text-shell-ink'
104106 }`}
105107 >
106 {SCOPE_LABEL[value]}
108 {t(SCOPE_KEYS[value])}
107109 </button>
108110 ))}
109111 </div>
@@ -113,24 +115,24 @@
113115 <input
114116 value={filter.search}
115117 onChange={event => withReset(() => setFilter(prev => ({ ...prev, search: event.target.value })))()}
116 placeholder="搜索标题/描述"
118 placeholder={t('pull.list.searchPlaceholder')}
117119 className="h-8 w-52 rounded-md border border-shell-hairline bg-shell-field pl-8 pr-2 text-[12.5px] outline-none focus:border-shell-tile"
118120 />
119121 </label>
120122 )}
121123 <Button className="ml-auto h-8 gap-1 text-[12.5px]" onClick={() => setCreating(true)}>
122 <Plus className="h-3.5 w-3.5" aria-hidden /> 新建合并请求
124 <Plus className="h-3.5 w-3.5" aria-hidden /> {t('pull.list.new')}
123125 </Button>
124126 </div>
125127
126128 <PullFilter value={filter} onChange={next => withReset(() => setFilter(next))()} />
127129
128 {state.loading && !page ? <PanelNote text="加载合并请求…" /> : null}
130 {state.loading && !page ? <PanelNote text={t('pull.list.loading')} /> : null}
129131 {!state.loading && failure ? (
130 <ErrorPanel status={failure.code ?? 0} what="合并请求列表" note={failure.message} onRetry={() => setTick(t => t + 1)} />
132 <ErrorPanel status={failure.code ?? 0} what={t('pull.list.errorList')} note={failure.message} onRetry={() => setTick(tick => tick + 1)} />
131133 ) : null}
132134 {!state.loading && !failure && rows.length === 0 ? (
133 <PanelNote text={source === 'mine' ? '这个收件箱是空的。' : '没有符合条件的合并请求。'} />
135 <PanelNote text={source === 'mine' ? t('pull.list.emptyInbox') : t('pull.list.empty')} />
134136 ) : null}
135137
136138 {rows.length > 0 ? (
@@ -158,15 +160,15 @@
158160 {page && page.total > PAGE_SIZE ? (
159161 <div className="flex items-center gap-3 text-[12.5px] text-shell-ink-muted">
160162 <Button variant="secondary" className="h-7 text-[12px]" disabled={offset <= 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>
161 上一页
163 {t('issue.board.prev')}
162164 </Button>
163165 <span className="rounded bg-shell-active px-2 py-0.5">
164166 {Math.floor(offset / PAGE_SIZE) + 1} / {Math.max(1, Math.ceil(page.total / PAGE_SIZE))}
165167 </span>
166168 <Button variant="secondary" className="h-7 text-[12px]" disabled={offset + PAGE_SIZE >= page.total} onClick={() => setOffset(offset + PAGE_SIZE)}>
167 下一页
169 {t('issue.board.next')}
168170 </Button>
169 <span>共 {page.total} 条</span>
171 <span>{tc('pull.list.total', page.total)}</span>
170172 </div>
171173 ) : null}
172174
Modified web/src/features/repo-ai/ui/ai-summary-tab.tsx View web/src/features/repo-ai/ui/ai-summary-tab.tsx
@@ -1,5 +1,6 @@
11import { useState } from 'react'
22import { AlertTriangle, Loader2, RefreshCw, Sparkles } from 'lucide-react'
3import { useT } from '@/shared/i18n'
34import { Button, ErrorPanel, Markdown, PanelNote } from '@/shared/ui'
45import { fmtRelative, useAsync } from '@/shared/lib'
56import { fetchRepoAiSummary, triggerRepoAiSummary } from '@/entities/repo'
@@ -13,6 +14,7 @@
1314
1415/** 仓库 AI 总结页签:读取按 head 缓存的总结,可手动刷新。 */
1516export function AiSummaryTab({ space, repo, rev }: AiSummaryTabProps) {
17 const { t, tc } = useT()
1618 const [tick, setTick] = useState(0)
1719 const [busy, setBusy] = useState(false)
1820 const [notice, setNotice] = useState<string | null>(null)
@@ -30,51 +32,51 @@
3032 setNotice(null)
3133 try {
3234 await triggerRepoAiSummary(space, repo, language ? { language } : {})
33 setNotice('已提交总结任务;完成后点击“载入结果”查看。')
35 setNotice(t('repo.ai.queued'))
3436 } catch (f) {
35 setError(f instanceof Error ? f.message : '触发 AI 总结失败')
37 setError(f instanceof Error ? f.message : t('repo.ai.triggerSummaryFailed'))
3638 } finally {
3739 setBusy(false)
3840 }
3941 }
4042
41 if (summary.loading && !data) return <PanelNote text="加载 AI 总结…" />
42 if (failure) return <ErrorPanel status={failure.code ?? 0} what="AI 总结" note={failure.message} onRetry={() => setTick(t => t + 1)} />
43 if (summary.loading && !data) return <PanelNote text={t('repo.ai.loading')} />
44 if (failure) return <ErrorPanel status={failure.code ?? 0} what={t('repo.ai.what')} note={failure.message} onRetry={() => setTick(n => n + 1)} />
4345
4446 return (
4547 <div className="space-y-3">
4648 <div className="flex flex-wrap items-center gap-2">
4749 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => void refresh()}>
4850 {busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Sparkles className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
49 {data ? '重新生成' : '生成总结'}
51 {data ? t('repo.ai.regenerate') : t('repo.ai.generateSummary')}
5052 </Button>
5153 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => setTick(t => t + 1)}>
5254 <RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
53 载入结果
55 {t('repo.ai.loadResults')}
5456 </Button>
5557 <LanguagePicker value={language} onChange={setLanguage} disabled={busy} />
56 {rev ? <span className="text-[12px] text-shell-ink-muted">分支 {rev}</span> : null}
58 {rev ? <span className="text-[12px] text-shell-ink-muted">{t('repo.ai.branch', { branch: rev })}</span> : null}
5759 </div>
5860
5961 {notice ? <p className="rounded-lg border border-shell-hairline bg-shell-field px-3 py-2 text-[12.5px] text-shell-ink-muted">{notice}</p> : null}
6062 {error ? <p className="text-[12.5px] text-[#b42318]" role="alert">{error}</p> : null}
6163
6264 {!data ? (
63 <PanelNote text="该分支还没有生成 AI 总结。总结基于最近提交生成并按 head 缓存,同一版本不会重复调用模型。" />
65 <PanelNote text={t('repo.ai.emptyNote')} />
6466 ) : (
6567 <>
6668 <section className="rounded-xl border border-shell-hairline bg-shell-surface">
6769 <header className="flex flex-wrap items-center gap-2 border-b border-shell-hairline bg-shell-field px-4 py-2.5 text-[12px] text-shell-ink-muted">
68 <span>版本 {data.head_sha.slice(0, 8)}</span>
69 <span>· {data.commit_count} 个提交</span>
70 <span>{t('repo.ai.version', { version: data.head_sha.slice(0, 8) })}</span>
71 <span>· {tc('repo.ai.commitCount', data.commit_count)}</span>
7072 <span>· {fmtRelative(data.updated_at)}</span>
7173 {data.current ? (
72 <span className="text-[#1a7f37]">对应当前分支</span>
74 <span className="text-[#1a7f37]">{t('repo.ai.currentBranch')}</span>
7375 ) : (
74 <span className="text-[#8a6100]">基于旧版本</span>
76 <span className="text-[#8a6100]">{t('repo.ai.stale')}</span>
7577 )}
7678 {data.language ? <span>{data.language}</span> : null}
77 {data.pending_job_id ? <span className="text-[#8a6100]">有任务进行中</span> : null}
79 {data.pending_job_id ? <span className="text-[#8a6100]">{t('repo.ai.pending')}</span> : null}
7880 </header>
7981 <div className="space-y-3 px-4 py-3">
8082 <Markdown>{data.content}</Markdown>
@@ -83,7 +85,7 @@
8385
8486 {data.highlights.length > 0 ? (
8587 <section className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
86 <h4 className="mb-2 text-[13px] font-semibold">亮点</h4>
88 <h4 className="mb-2 text-[13px] font-semibold">{t('repo.ai.highlights')}</h4>
8789 <ul className="list-disc space-y-1 pl-5 text-[12.5px] leading-5">
8890 {data.highlights.map((item, index) => <li key={index}>{item}</li>)}
8991 </ul>
@@ -94,7 +96,7 @@
9496 <section className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
9597 <h4 className="mb-2 flex items-center gap-1.5 text-[13px] font-semibold">
9698 <AlertTriangle className="h-3.5 w-3.5 text-[#8a6100]" aria-hidden />
97 风险
99 {t('repo.ai.risks')}
98100 </h4>
99101 <ul className="list-disc space-y-1 pl-5 text-[12.5px] leading-5">
100102 {data.risks.map((item, index) => <li key={index}>{item}</li>)}
Modified web/src/features/repo-branches/model/branch.ts View web/src/features/repo-branches/model/branch.ts
@@ -3,19 +3,21 @@
33 * can explain the problem before a round trip. The server stays authoritative —
44 * this only rejects names it would certainly refuse.
55 */
6import { t } from '@/shared/i18n'
7
68export const MAX_BRANCH_NAME = 250
79
810export function validateBranchName(raw: string): string | null {
911 const name = raw.trim()
10 if (!name) return '请填写分支名。'
11 if ([...name].length > MAX_BRANCH_NAME) return `分支名最多 ${MAX_BRANCH_NAME} 个字符。`
12 if (name.startsWith('-')) return '分支名不能以 - 开头。'
13 if (name.endsWith('/')) return '分支名不能以 / 结尾。'
14 if (name.endsWith('.lock')) return '分支名不能以 .lock 结尾。'
15 if (name.includes('..')) return '分支名不能包含连续两个点 ..。'
16 if (name.includes('@{')) return '分支名不能包含 @{。'
17 if (name.includes('//')) return '分支名不能包含连续两个斜杠 //。'
18 if (name.includes('\\')) return '分支名不能包含反斜杠。'
19 if (Array.from(name).some(c => /\s/.test(c) || c < ' ')) return '分支名不能包含空格或控制字符。'
12 if (!name) return t('repo.branch.validate.empty')
13 if ([...name].length > MAX_BRANCH_NAME) return t('repo.branch.validate.tooLong', { count: MAX_BRANCH_NAME })
14 if (name.startsWith('-')) return t('repo.branch.validate.startsWithDash')
15 if (name.endsWith('/')) return t('repo.branch.validate.endsWithSlash')
16 if (name.endsWith('.lock')) return t('repo.branch.validate.endsWithLock')
17 if (name.includes('..')) return t('repo.branch.validate.doubleDot')
18 if (name.includes('@{')) return t('repo.branch.validate.atBrace')
19 if (name.includes('//')) return t('repo.branch.validate.doubleSlash')
20 if (name.includes('\\')) return t('repo.branch.validate.backslash')
21 if (Array.from(name).some(c => /\s/.test(c) || c < ' ')) return t('repo.branch.validate.whitespace')
2022 return null
2123}
Modified web/src/features/repo-branches/ui/branches-tab.tsx View web/src/features/repo-branches/ui/branches-tab.tsx
@@ -1,6 +1,7 @@
11import { useState } from 'react'
22import { Eye, GitBranch, Plus, Search, Star } from 'lucide-react'
33import { Alert, Button, CopyChip, ErrorPanel, FormModal, PanelNote, type FormValues } from '@/shared/ui'
4import { useT } from '@/shared/i18n'
45import { ApiFailure, fmtRelative, fmtTime, repoKey, shortSha, useAsync } from '@/shared/lib'
56import { createBranch, deleteBranch, listBranches, renameBranch, setDefaultBranch } from '@/entities/repo'
67import { validateBranchName } from '../model/branch'
@@ -34,6 +35,7 @@
3435 onBrowse,
3536 onChanged,
3637}: BranchesTabProps) {
38 const { t } = useT()
3739 const [tick, setTick] = useState(0)
3840 const [query, setQuery] = useState('')
3941 const [creating, setCreating] = useState(false)
@@ -66,7 +68,7 @@
6668 setNotice(done)
6769 refresh()
6870 } catch (err) {
69 setActionError(messageOf(err, '操作失败,请稍后重试。'))
71 setActionError(messageOf(err, t('repo.error.actionFailed')))
7072 } finally {
7173 setBusy(false)
7274 }
@@ -76,20 +78,20 @@
7678 <div className="repo-branches pb-6">
7779 <div className="repo-section-toolbar mb-5 flex flex-wrap items-center gap-3">
7880 <h3 className="mr-2 text-[17px] font-semibold">
79 分支 <span className="text-[12.5px] font-normal text-shell-ink-muted">共 {total}</span>
81 {t('repo.branches.title')} <span className="text-[12.5px] font-normal text-shell-ink-muted">{t('repo.branches.total', { count: total })}</span>
8082 </h3>
8183 <label className="repo-branch-search relative">
8284 <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-shell-ink-muted" aria-hidden />
8385 <input
84 aria-label="筛选分支"
86 aria-label={t('repo.branches.filter')}
8587 value={query}
8688 onChange={e => setQuery(e.target.value)}
87 placeholder="筛选分支…"
89 placeholder={t('repo.branches.filterPlaceholder')}
8890 className="h-10 w-56 rounded-lg border border-shell-hairline bg-shell-surface pl-9 pr-3 text-[13px] outline-none transition-colors focus:border-shell-tile"
8991 />
9092 </label>
9193 <Button className="ml-auto gap-1.5 text-[13px]" disabled={busy} onClick={() => setCreating(true)}>
92 <Plus className="h-4 w-4" aria-hidden /> 新建分支
94 <Plus className="h-4 w-4" aria-hidden /> {t('repo.branch.create')}
9395 </Button>
9496 </div>
9597 {notice || actionError ? (
@@ -100,11 +102,11 @@
100102 ) : null}
101103
102104 {data.loading && !data.data ? (
103 <PanelNote text="加载分支…" />
105 <PanelNote text={t('repo.branches.loading')} />
104106 ) : failure ? (
105 <ErrorPanel status={failure.code ?? 0} what="分支列表" note={failure.message} onRetry={() => setTick(t => t + 1)} />
107 <ErrorPanel status={failure.code ?? 0} what={t('repo.branches.listName')} note={failure.message} onRetry={() => setTick(n => n + 1)} />
106108 ) : rows.length === 0 ? (
107 <PanelNote text={query ? '没有匹配的分支。' : '该仓库还没有分支。'} />
109 <PanelNote text={query ? t('repo.branches.noMatch') : t('repo.branches.none')} />
108110 ) : (
109111 <ul className="repo-branch-list divide-y divide-shell-hairline overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
110112 {rows.map(branch => {
@@ -117,18 +119,18 @@
117119 <span className="min-w-0 break-all font-mono text-[13px] font-semibold">{branch.name}</span>
118120 {isDefault ? (
119121 <span className="flex items-center gap-1 rounded-full bg-shell-active px-2 py-0.5 text-[11px]">
120 <Star className="h-3 w-3" aria-hidden /> 默认
122 <Star className="h-3 w-3" aria-hidden /> {t('repo.branch.defaultBadge')}
121123 </span>
122124 ) : null}
123 {isCurrent ? <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px] text-shell-ink">当前浏览</span> : null}
125 {isCurrent ? <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px] text-shell-ink">{t('repo.branch.current')}</span> : null}
124126 {branch.is_protected ? (
125 <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]" title="受保护分支不可删除,改名也需管理员">
126 受保护
127 <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]" title={t('repo.branch.protectedNote')}>
128 {t('repo.branch.protected')}
127129 </span>
128130 ) : null}
129131 <CopyChip text={branch.commit_sha} label={shortSha(branch.commit_sha, 8)} />
130 <span className="text-[12px] text-shell-ink-muted" title={`更新 ${fmtTime(branch.updated_at)}`}>
131 更新于 {fmtRelative(branch.updated_at)}
132 <span className="text-[12px] text-shell-ink-muted" title={t('repo.branch.updatedAt', { time: fmtTime(branch.updated_at) })}>
133 {t('repo.branch.updated', { time: fmtRelative(branch.updated_at) })}
132134 </span>
133135 <div className="repo-branch-actions ml-auto flex flex-wrap items-center gap-1.5">
134136 <Button
@@ -137,31 +139,31 @@
137139 disabled={busy}
138140 onClick={() => onBrowse(branch.name)}
139141 >
140 <Eye className="h-3.5 w-3.5" aria-hidden /> 浏览代码
142 <Eye className="h-3.5 w-3.5" aria-hidden /> {t('repo.action.browseCode')}
141143 </Button>
142144 <Button
143145 variant="secondary"
144146 className="h-8 text-[12.5px]"
145147 disabled={isDefault || busy}
146 title={isDefault ? '已经是默认分支' : '把仓库默认分支设为该分支'}
147 onClick={() => void run(() => setDefaultBranch(space, repo, branch.name), `已将「${branch.name}」设为默认分支。`)}
148 title={isDefault ? t('repo.branch.alreadyDefault') : t('repo.branch.makeDefaultHint')}
149 onClick={() => void run(() => setDefaultBranch(space, repo, branch.name), t('repo.branch.defaultSet', { name: branch.name }))}
148150 >
149 设为默认
151 {t('repo.action.setDefault')}
150152 </Button>
151153 <Button
152154 variant="secondary"
153155 className="h-8 text-[12.5px]"
154156 disabled={branch.is_protected || busy}
155 title={branch.is_protected ? '受保护分支不可重命名' : undefined}
157 title={branch.is_protected ? t('repo.branch.protectedRenameBlocked') : undefined}
156158 onClick={() => setRenaming(branch.name)}
157159 >
158 重命名
160 {t('repo.action.rename')}
159161 </Button>
160162 <Button
161163 variant="secondary"
162164 className="repo-delete-button h-8 text-[12.5px]"
163165 disabled={locked || busy}
164 title={locked ? '默认分支与受保护分支不可删除' : undefined}
166 title={locked ? t('repo.branch.deleteBlocked') : undefined}
165167 onClick={() => {
166168 if (pendingDelete !== branch.name) {
167169 setPendingDelete(branch.name)
@@ -173,15 +175,15 @@
173175 await deleteBranch(space, repo, branch.name)
174176 if (isCurrent) onCheckout(defaultBranch)
175177 },
176 `已删除分支「${branch.name}」。`,
178 t('repo.branch.deletedName', { name: branch.name }),
177179 )
178180 }}
179181 >
180 {pendingDelete === branch.name ? '确认删除' : '删除'}
182 {pendingDelete === branch.name ? t('repo.action.confirmDelete') : t('common.action.delete')}
181183 </Button>
182184 {pendingDelete === branch.name ? (
183185 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={() => setPendingDelete(null)}>
184 取消
186 {t('common.action.cancel')}
185187 </Button>
186188 ) : null}
187189 </div>
@@ -192,7 +194,7 @@
192194 )}
193195 {data.data && data.data.total > data.data.items.length ? (
194196 <p className="pt-2 text-[12px] text-shell-ink-muted">
195 已载入前 {PAGE_LIMIT} 个分支,筛选仅针对已载入的分支。
197 {t('repo.branches.limitNote', { count: PAGE_LIMIT })}
196198 </p>
197199 ) : null}
198200
@@ -200,19 +202,19 @@
200202 <SubmodulesSection space={space} repo={repo} rev={rev} />
201203
202204 <FormModal
203 title="新建分支"
204 description="分支名允许字母、数字与 . - _ /(不能出现 .. // 空格等),最长 250 字符。"
205 title={t('repo.branch.create')}
206 description={t('repo.branch.createHint')}
205207 open={creating}
206208 wide
207 confirmLabel="创建分支"
209 confirmLabel={t('repo.branch.createSubmit')}
208210 fields={[
209 { key: 'name', kind: 'text', label: '分支名', placeholder: 'feature/login' },
211 { key: 'name', kind: 'text', label: t('repo.branch.name'), placeholder: 'feature/login' },
210212 {
211213 key: 'start_point',
212214 kind: 'text',
213 label: '起点',
215 label: t('repo.branch.startPoint'),
214216 initialValue: rev,
215 hint: '可以是分支名、标签或提交号,默认从当前修订创建。',
217 hint: t('repo.branch.startPointHint'),
216218 },
217219 ]}
218220 onClose={() => setCreating(false)}
@@ -223,22 +225,22 @@
223225 const start = String(values.start_point ?? '').trim() || rev
224226 // Throwing keeps the dialog open with the message inline.
225227 await createBranch(space, repo, name, start)
226 setNotice(`已创建分支「${name}」。`)
228 setNotice(t('repo.branch.createdName', { name }))
227229 refresh()
228230 }}
229231 />
230232 <FormModal
231 title={`重命名分支${renaming ? ` · ${renaming}` : ''}`}
232 description="改名只影响该仓库的分支引用,已克隆到本地的分支需要重新 fetch。"
233 title={`${t('repo.branch.rename')}${renaming ? ` · ${renaming}` : ''}`}
234 description={t('repo.branch.renameHint')}
233235 open={Boolean(renaming)}
234236 wide
235237 danger
236 confirmLabel="确认重命名"
238 confirmLabel={t('repo.branch.renameSubmit')}
237239 fields={[
238240 {
239241 key: 'new_name',
240242 kind: 'text',
241 label: '新的分支名',
243 label: t('repo.branch.newName'),
242244 initialValue: renaming ?? '',
243245 placeholder: 'release/1.0',
244246 },
@@ -250,9 +252,9 @@
250252 if (!from) return
251253 const invalid = validateBranchName(next)
252254 if (invalid) throw new ApiFailure(invalid, 400)
253 if (next === from) throw new ApiFailure('新名称与原名称相同。', 400)
255 if (next === from) throw new ApiFailure(t('repo.branch.sameName'), 400)
254256 await renameBranch(space, repo, from, next)
255 setNotice(`分支「${from}」已改名为「${next}」。`)
257 setNotice(t('repo.branch.renamed', { from, next }))
256258 setRenaming(null)
257259 refresh()
258260 if (from === rev) onCheckout(next)
Modified web/src/features/repo-branches/ui/submodules-section.tsx View web/src/features/repo-branches/ui/submodules-section.tsx
@@ -1,11 +1,13 @@
11import { useState } from 'react'
22import { Boxes } from 'lucide-react'
3import { useT } from '@/shared/i18n'
34import { Button, CopyChip, PanelNote } from '@/shared/ui'
45import { shortSha, useAsync } from '@/shared/lib'
56import { listSubmodules, resolveSubmodule } from '@/entities/repo'
67
78/** Read-only `.gitmodules` listing at the current revision. */
89export function SubmodulesSection({ space, repo, rev }: { space: string; repo: string; rev: string }) {
10 const { t } = useT()
911 const [resolved, setResolved] = useState<Record<string, string>>({})
1012 const [busyPath, setBusyPath] = useState<string | null>(null)
1113 const data = useAsync(
@@ -19,7 +21,7 @@
1921 <div className="mt-10">
2022 <h3 className="mb-3 flex items-center gap-2 text-[17px] font-semibold">
2123 <Boxes className="h-4.5 w-4.5 text-shell-ink-muted" aria-hidden />
22 子模块
24 {t('repo.submodules.title')}
2325 </h3>
2426 <ul className="divide-y divide-shell-hairline overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
2527 {rows.map(sub => (
@@ -42,17 +44,17 @@
4244 setBusyPath(sub.path)
4345 void resolveSubmodule(space, repo, rev, sub.path)
4446 .then(target => setResolved(prev => ({ ...prev, [sub.path]: target })))
45 .catch(() => setResolved(prev => ({ ...prev, [sub.path]: '无法解析(子仓库可能不存在或不可见)' })))
47 .catch(() => setResolved(prev => ({ ...prev, [sub.path]: t('repo.submodules.unresolved') })))
4648 .finally(() => setBusyPath(null))
4749 }}
4850 >
49 解析仓库
51 {t('repo.submodules.resolve')}
5052 </Button>
5153 </span>
5254 </li>
5355 ))}
5456 </ul>
55 {data.error ? <PanelNote text="子模块列表加载失败。" /> : null}
57 {data.error ? <PanelNote text={t('repo.submodules.loadFailed')} /> : null}
5658 </div>
5759 )
5860}
Modified web/src/features/repo-branches/ui/tags-section.tsx View web/src/features/repo-branches/ui/tags-section.tsx
@@ -1,6 +1,7 @@
11import { useState } from 'react'
22import { Eye, GitCommitHorizontal, Plus, Tag } from 'lucide-react'
33import { Alert, Button, CopyChip, ErrorPanel, FormModal, Modal, PanelNote, type FormValues } from '@/shared/ui'
4import { useT } from '@/shared/i18n'
45import { ApiFailure, fmtRelative, fmtTime, repoKey, shortSha, useAsync } from '@/shared/lib'
56import { createTag, deleteTag, fetchTag, listTags, type TagRow } from '@/entities/repo'
67
@@ -17,6 +18,7 @@
1718
1819/** Tag management: list, inspect annotated payload, create, delete. */
1920export function TagsSection({ space, repo, rev, onBrowse, onChanged }: TagsSectionProps) {
21 const { t } = useT()
2022 const [tick, setTick] = useState(0)
2123 const [creating, setCreating] = useState(false)
2224 const [pendingDelete, setPendingDelete] = useState<string | null>(null)
@@ -44,7 +46,7 @@
4446 setNotice(done)
4547 refresh()
4648 } catch (err) {
47 setError(err instanceof ApiFailure ? err.message : '操作失败,请稍后重试。')
49 setError(err instanceof ApiFailure ? err.message : t('repo.error.actionFailed'))
4850 } finally {
4951 setBusy(false)
5052 }
@@ -54,10 +56,10 @@
5456 <div className="mt-10">
5557 <div className="repo-section-toolbar mb-5 flex flex-wrap items-center gap-3">
5658 <h3 className="mr-2 text-[17px] font-semibold">
57 标签 <span className="text-[12.5px] font-normal text-shell-ink-muted">共 {data.data?.total ?? 0}</span>
59 {t('repo.tags.title')} <span className="text-[12.5px] font-normal text-shell-ink-muted">{t('repo.tags.total', { count: data.data?.total ?? 0 })}</span>
5860 </h3>
5961 <Button className="ml-auto gap-1.5 text-[13px]" disabled={busy} onClick={() => setCreating(true)}>
60 <Plus className="h-4 w-4" aria-hidden /> 新建标签
62 <Plus className="h-4 w-4" aria-hidden /> {t('repo.tag.create')}
6163 </Button>
6264 </div>
6365 {notice || error ? (
@@ -68,11 +70,11 @@
6870 ) : null}
6971
7072 {data.loading && !data.data ? (
71 <PanelNote text="加载标签…" />
73 <PanelNote text={t('repo.tags.loading')} />
7274 ) : failure ? (
73 <ErrorPanel status={failure.code ?? 0} what="标签列表" note={failure.message} onRetry={() => setTick(t => t + 1)} />
75 <ErrorPanel status={failure.code ?? 0} what={t('repo.tags.listName')} note={failure.message} onRetry={() => setTick(n => n + 1)} />
7476 ) : rows.length === 0 ? (
75 <PanelNote text="该仓库还没有标签。" />
77 <PanelNote text={t('repo.tags.none')} />
7678 ) : (
7779 <ul className="repo-branch-list divide-y divide-shell-hairline overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
7880 {rows.map(tag => (
@@ -81,10 +83,10 @@
8183 <span className="min-w-0 break-all font-mono text-[13px] font-semibold">{tag.name}</span>
8284 {tag.tag_type === 'Annotated' ? (
8385 <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]" title={tag.message ?? undefined}>
84 附注标签
86 {t('repo.tag.annotated')}
8587 </span>
8688 ) : (
87 <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]">轻量标签</span>
89 <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]">{t('repo.tag.lightweight')}</span>
8890 )}
8991 <CopyChip text={tag.target_sha} label={shortSha(tag.target_sha, 8)} />
9092 {tag.tagger_name ? <span className="text-[12px] text-shell-ink-muted">{tag.tagger_name}</span> : null}
@@ -93,14 +95,14 @@
9395 ) : null}
9496 <div className="repo-branch-actions ml-auto flex flex-wrap items-center gap-1.5">
9597 <Button variant="secondary" className="h-8 gap-1 text-[12.5px]" onClick={() => onBrowse(tag.name)}>
96 <Eye className="h-3.5 w-3.5" aria-hidden /> 浏览代码
98 <Eye className="h-3.5 w-3.5" aria-hidden /> {t('repo.action.browseCode')}
9799 </Button>
98100 <Button
99101 variant="secondary"
100102 className="h-8 gap-1 text-[12.5px]"
101103 onClick={() => void run(async () => setDetail(await fetchTag(space, repo, tag.name)), '')}
102104 >
103 <GitCommitHorizontal className="h-3.5 w-3.5" aria-hidden /> 详情
105 <GitCommitHorizontal className="h-3.5 w-3.5" aria-hidden /> {t('repo.action.details')}
104106 </Button>
105107 <Button
106108 variant="secondary"
@@ -112,14 +114,14 @@
112114 return
113115 }
114116 setPendingDelete(null)
115 void run(() => deleteTag(space, repo, tag.name), `已删除标签「${tag.name}」。`)
117 void run(() => deleteTag(space, repo, tag.name), t('repo.tag.deletedName', { name: tag.name }))
116118 }}
117119 >
118 {pendingDelete === tag.name ? '确认删除' : '删除'}
120 {pendingDelete === tag.name ? t('repo.action.confirmDelete') : t('common.action.delete')}
119121 </Button>
120122 {pendingDelete === tag.name ? (
121123 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={() => setPendingDelete(null)}>
122 取消
124 {t('common.action.cancel')}
123125 </Button>
124126 ) : null}
125127 </div>
@@ -129,43 +131,43 @@
129131 )}
130132
131133 <FormModal
132 title="新建标签"
133 description="附注标签会记录打标人与说明(annotated tag);轻量标签仅指向修订。"
134 title={t('repo.tag.create')}
135 description={t('repo.tag.createHint')}
134136 open={creating}
135137 wide
136 confirmLabel="创建标签"
138 confirmLabel={t('repo.tag.createSubmit')}
137139 fields={[
138 { key: 'name', kind: 'text', label: '标签名', placeholder: 'v1.0.0' },
139 { key: 'target', kind: 'text', label: '目标修订', initialValue: rev, hint: '分支名、提交号或其它标签。' },
140 { key: 'annotated', kind: 'toggle', label: '附注标签' },
141 { key: 'message', kind: 'textarea', label: '说明(附注标签)', rows: 3, placeholder: '本次发布的主要内容…' },
140 { key: 'name', kind: 'text', label: t('repo.tag.name'), placeholder: 'v1.0.0' },
141 { key: 'target', kind: 'text', label: t('repo.tag.target'), initialValue: rev, hint: t('repo.tag.targetHint') },
142 { key: 'annotated', kind: 'toggle', label: t('repo.tag.annotated') },
143 { key: 'message', kind: 'textarea', label: t('repo.tag.messageLabel'), rows: 3, placeholder: t('repo.tag.messagePlaceholder') },
142144 ]}
143145 onClose={() => setCreating(false)}
144146 onSubmit={async (values: FormValues) => {
145147 const name = String(values.name ?? '').trim()
146 if (!name) throw new ApiFailure('标签名不能为空。', 400)
148 if (!name) throw new ApiFailure(t('repo.tag.nameRequired'), 400)
147149 await createTag(space, repo, {
148150 name,
149151 target: String(values.target ?? '').trim() || rev,
150152 annotated: Boolean(values.annotated),
151153 message: String(values.message ?? '').trim() || null,
152154 })
153 setNotice(`已创建标签「${name}」。`)
155 setNotice(t('repo.tag.createdName', { name }))
154156 setCreating(false)
155157 refresh()
156158 }}
157159 />
158160
159161 {detail ? (
160 <Modal title={`标签详情 · ${detail.name}`} open onClose={() => setDetail(null)}
161 footer={<Button variant="ghost" onClick={() => setDetail(null)}>关闭</Button>}
162 <Modal title={t('repo.tag.detailTitle', { name: detail.name })} open onClose={() => setDetail(null)}
163 footer={<Button variant="ghost" onClick={() => setDetail(null)}>{t('common.action.close')}</Button>}
162164 >
163165 <dl className="space-y-2 text-[13px]">
164 <div className="flex gap-3"><dt className="w-20 shrink-0 text-shell-ink-muted">类型</dt><dd>{detail.tag_type === 'Annotated' ? '附注标签' : '轻量标签'}</dd></div>
165 <div className="flex gap-3"><dt className="w-20 shrink-0 text-shell-ink-muted">指向提交</dt><dd className="min-w-0 break-all font-mono">{detail.target_sha}</dd></div>
166 <div className="flex gap-3"><dt className="w-20 shrink-0 text-shell-ink-muted">打标人</dt><dd>{detail.tagger_name ? `${detail.tagger_name}${detail.tagger_email ? ` <${detail.tagger_email}>` : ''}` : '—'}</dd></div>
167 {detail.created_at ? <div className="flex gap-3"><dt className="w-20 shrink-0 text-shell-ink-muted">创建时间</dt><dd>{fmtTime(detail.created_at)}</dd></div> : null}
168 {detail.message ? <div><dt className="mb-1 text-shell-ink-muted">说明</dt><dd className="whitespace-pre-wrap rounded-lg border border-shell-hairline bg-shell-field px-3 py-2">{detail.message}</dd></div> : null}
166 <div className="flex gap-3"><dt className="w-20 shrink-0 text-shell-ink-muted">{t('repo.tag.type')}</dt><dd>{detail.tag_type === 'Annotated' ? t('repo.tag.annotated') : t('repo.tag.lightweight')}</dd></div>
167 <div className="flex gap-3"><dt className="w-20 shrink-0 text-shell-ink-muted">{t('repo.tag.targetCommit')}</dt><dd className="min-w-0 break-all font-mono">{detail.target_sha}</dd></div>
168 <div className="flex gap-3"><dt className="w-20 shrink-0 text-shell-ink-muted">{t('repo.tag.tagger')}</dt><dd>{detail.tagger_name ? `${detail.tagger_name}${detail.tagger_email ? ` <${detail.tagger_email}>` : ''}` : '—'}</dd></div>
169 {detail.created_at ? <div className="flex gap-3"><dt className="w-20 shrink-0 text-shell-ink-muted">{t('repo.tag.createdAt')}</dt><dd>{fmtTime(detail.created_at)}</dd></div> : null}
170 {detail.message ? <div><dt className="mb-1 text-shell-ink-muted">{t('repo.tag.message')}</dt><dd className="whitespace-pre-wrap rounded-lg border border-shell-hairline bg-shell-field px-3 py-2">{detail.message}</dd></div> : null}
169171 </dl>
170172 </Modal>
171173 ) : null}
Modified web/src/features/repo-browse/ui/blame-view.tsx View web/src/features/repo-browse/ui/blame-view.tsx
@@ -1,3 +1,4 @@
1import { useT } from '@/shared/i18n'
12import { CopyChip, ErrorPanel, PanelNote } from '@/shared/ui'
23import { fmtRelative, repoKey, shortSha, useAsync } from '@/shared/lib'
34import { useState } from 'react'
@@ -18,6 +19,7 @@
1819 * 相邻同范围只画一次 gutter,避免长文件里每行重复。
1920 */
2021export function BlameView({ space, repo, rev, path, oid, onOpenCommit }: BlameViewProps) {
22 const { t } = useT()
2123 const [retry, setRetry] = useState(0)
2224 const state = useAsync(
2325 async () => {
@@ -37,11 +39,11 @@
3739 { cacheKey: repoKey(space, repo, `blame:${rev}:${path}:${retry ? 'r' : ''}`) },
3840 )
3941 const failure = state.error as { code?: number; message?: string } | undefined
40 if (state.loading && !state.data) return <PanelNote text="计算逐行归属…" />
41 if (failure) return <ErrorPanel status={failure.code ?? 0} what="blame" note={failure.message} onRetry={() => setRetry(r => r + 1)} />
42 if (state.loading && !state.data) return <PanelNote text={t('repo.blame.loading')} />
43 if (failure) return <ErrorPanel status={failure.code ?? 0} what={t('repo.blame.what')} note={failure.message} onRetry={() => setRetry(r => r + 1)} />
4244 const data = state.data
43 if (!data || data.binary) return <PanelNote text="该文件不支持 blame(二进制或暂不可读)。" />
44 if (!data.map.some(Boolean)) return <PanelNote text="后端未返回归属信息(文件可能为空或不在该修订下)。" />
45 if (!data || data.binary) return <PanelNote text={t('repo.blame.unsupported')} />
46 if (!data.map.some(Boolean)) return <PanelNote text={t('repo.blame.empty')} />
4547
4648 return (
4749 <div className="overflow-x-auto rounded-lg border-2 border-shell-hairline">
@@ -75,7 +77,7 @@
7577 </tbody>
7678 </table>
7779 <p className="border-t border-shell-hairline px-3 py-1.5 text-[11px] text-shell-ink-muted">
78 <CopyChip text={rev} label={`修订 ${shortSha(rev, 8)}`} /> 点击左侧归属跳转到提交详情。
80 <CopyChip text={rev} label={t('repo.revision', { rev: shortSha(rev, 8) })} /> {t('repo.blame.hint')}
7981 </p>
8082 </div>
8183 )
Modified web/src/features/repo-browse/ui/code-tab.tsx View web/src/features/repo-browse/ui/code-tab.tsx
@@ -1,5 +1,6 @@
11import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
22import { ArrowLeft, ArrowUp, ChevronRight, File as FileIcon, Folder, FolderGit2, History, Users } from 'lucide-react'
3import { useT } from '@/shared/i18n'
34import { formatBytes, repoKey, useAsync } from '@/shared/lib'
45import { ErrorPanel, PanelNote } from '@/shared/ui'
56import {
@@ -131,6 +132,7 @@
131132 cloneSlot?: ReactNode
132133 searchSlot?: ReactNode
133134}) {
135 const { t } = useT()
134136 const headerless = !cloneSlot && !searchSlot
135137 return (
136138 <div className="repo-code-view">
@@ -151,7 +153,7 @@
151153 onClick={() => routes.onShowHistory(path)}
152154 className="flex shrink-0 items-center gap-1.5 rounded-md bg-shell-field px-2.5 py-1 text-[12.5px] transition-colors hover:brightness-95"
153155 >
154 <History className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden /> 提交历史
156 <History className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden /> {t('repo.commit.history')}
155157 </button>
156158 </div>
157159 {mode === 'html' ? (
@@ -213,15 +215,16 @@
213215 onNavigate: (p: string) => void
214216 className?: string
215217}) {
218 const { t } = useT()
216219 const segments = path ? path.split('/') : []
217220 return (
218 <nav aria-label="仓库路径" className={`repo-breadcrumbs flex flex-wrap items-center gap-1 text-[13px] ${className}`}>
221 <nav aria-label={t('repo.breadcrumb.label')} className={`repo-breadcrumbs flex flex-wrap items-center gap-1 text-[13px] ${className}`}>
219222 <button
220223 type="button"
221224 className="font-semibold hover:underline"
222225 onClick={() => onNavigate('')}
223226 >
224 仓库根目录
227 {t('repo.breadcrumb.root')}
225228 </button>
226229 {segments.map((segment, index) => (
227230 <span key={index} className="flex items-center gap-1">
@@ -259,16 +262,17 @@
259262 path: string
260263 routes: FragmentRoutes
261264}) {
265 const { t } = useT()
262266 const [retry, setRetry] = useState(0)
263267 const frag = useHtmlFragment(
264268 htmlFragmentUrl(space, repo, 'tree', { revision: rev, path, _r: retry || undefined }),
265269 )
266270 const onClick = useFragmentNavigation(routes)
267271
268 if (!frag) return <PanelNote text="加载目录…" />
272 if (!frag) return <PanelNote text={t('repo.tree.loading')} />
269273 if (!frag.ok) {
270274 return (
271 <ErrorPanel status={frag.status} what="目录" onRetry={() => setRetry(r => r + 1)} />
275 <ErrorPanel status={frag.status} what={t('repo.tree.what')} onRetry={() => setRetry(r => r + 1)} />
272276 )
273277 }
274278 return <div className="repo-embed overflow-x-auto p-3" onClick={onClick} dangerouslySetInnerHTML={{ __html: frag.html }} />
@@ -289,27 +293,28 @@
289293 onNavigate: (p: string) => void
290294 onOpenFile: (f: { path: string; oid?: string }) => void
291295}) {
296 const { t } = useT()
292297 const [retry, setRetry] = useState(0)
293298 const state = useAsync(() => fetchTreeJson(space, repo, rev, path), [space, repo, rev, path, retry], {
294299 cacheKey: repoKey(space, repo, `tree:${rev}:${path}`),
295300 })
296 if (state.loading && !state.data) return <PanelNote text="加载目录…" />
301 if (state.loading && !state.data) return <PanelNote text={t('repo.tree.loading')} />
297302 if (state.error) {
298 return <ErrorPanel status={(state.error as { code?: number }).code ?? 0} what="目录" onRetry={() => setRetry(value => value + 1)} />
303 return <ErrorPanel status={(state.error as { code?: number }).code ?? 0} what={t('repo.tree.what')} onRetry={() => setRetry(value => value + 1)} />
299304 }
300305 const rows = [...(state.data ?? [])].sort((a, b) => {
301306 const aDir = a.mode === 'Tree' ? 0 : 1
302307 const bDir = b.mode === 'Tree' ? 0 : 1
303308 return aDir - bDir || a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })
304309 })
305 if (rows.length === 0) return <PanelNote text="此目录为空。" />
310 if (rows.length === 0) return <PanelNote text={t('repo.tree.empty')} />
306311 return (
307312 <div className="overflow-x-auto">
308 <table aria-label="仓库文件" className="repo-file-table w-full border-collapse text-[13px]">
313 <table aria-label={t('repo.tree.tableLabel')} className="repo-file-table w-full border-collapse text-[13px]">
309314 <thead>
310315 <tr className="text-left font-mono text-[10px] uppercase tracking-[1.2px] text-shell-ink-muted">
311 <th className="px-2 py-1.5">名称</th>
312 <th className="px-2 py-1.5 text-right">大小</th>
316 <th className="px-2 py-1.5">{t('repo.tree.name')}</th>
317 <th className="px-2 py-1.5 text-right">{t('repo.tree.size')}</th>
313318 </tr>
314319 </thead>
315320 <tbody>
@@ -321,7 +326,7 @@
321326 className="flex items-center gap-1.5 font-semibold hover:underline"
322327 onClick={() => onNavigate(parentPath(path))}
323328 >
324 <ArrowUp className="h-4 w-4" aria-hidden /> 返回上级目录
329 <ArrowUp className="h-4 w-4" aria-hidden /> {t('repo.tree.parent')}
325330 </button>
326331 </td>
327332 </tr>
@@ -635,6 +640,7 @@
635640 cloneSlot?: ReactNode
636641 searchSlot?: ReactNode
637642}) {
643 const { t } = useT()
638644 const [blame, setBlame] = useState(false)
639645 const dir = file.path.includes('/') ? file.path.slice(0, file.path.lastIndexOf('/')) : ''
640646 const leaf = file.path.includes('/') ? file.path.slice(file.path.lastIndexOf('/') + 1) : file.path
@@ -653,14 +659,14 @@
653659 onClick={onBack}
654660 className="flex items-center gap-1 rounded-md px-2 py-1 text-[13px] transition-colors hover:bg-shell-active"
655661 >
656 <ArrowLeft className="h-4 w-4" aria-hidden /> 返回文件列表
662 <ArrowLeft className="h-4 w-4" aria-hidden /> {t('repo.file.backToList')}
657663 </button>
658664 <span className="min-w-0 break-all font-mono text-[13px] font-semibold">{file.path}</span>
659665 <button
660666 type="button"
661667 aria-pressed={blame}
662668 disabled={!file.oid}
663 title={file.oid ? '显示每行的最后修改提交(blame)' : '私有仓库 JSON 模式下可用'}
669 title={file.oid ? t('repo.file.blameHint') : t('repo.file.blameJsonHint')}
664670 onClick={() => setBlame(value => !value)}
665671 className="flex items-center gap-1.5 rounded-md bg-shell-field px-2.5 py-1 text-[12.5px] transition-colors hover:brightness-95 aria-pressed:bg-shell-active aria-pressed:font-semibold disabled:opacity-40"
666672 >
@@ -671,7 +677,7 @@
671677 onClick={() => routes.onShowHistory(file.path)}
672678 className="ml-auto flex items-center gap-1.5 rounded-md bg-shell-field px-2.5 py-1 text-[12.5px] transition-colors hover:brightness-95"
673679 >
674 <History className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden /> 提交历史
680 <History className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden /> {t('repo.commit.history')}
675681 </button>
676682 </div>
677683 {blame && file.oid ? (
@@ -700,6 +706,7 @@
700706 line?: number
701707 routes: FragmentRoutes
702708}) {
709 const { t } = useT()
703710 const [retry, setRetry] = useState(0)
704711 const frag = useHtmlFragment(
705712 htmlFragmentUrl(space, repo, 'blob-content', {
@@ -718,36 +725,37 @@
718725 target.scrollIntoView({ block: 'center' })
719726 return () => target.classList.remove('bg-[#f0b232]/20')
720727 }, [line, frag?.html])
721 if (!frag) return <PanelNote text="加载文件…" />
722 if (!frag.ok) return <ErrorPanel status={frag.status} what="文件" onRetry={() => setRetry(r => r + 1)} />
728 if (!frag) return <PanelNote text={t('repo.file.loading')} />
729 if (!frag.ok) return <ErrorPanel status={frag.status} what={t('repo.file.what')} onRetry={() => setRetry(r => r + 1)} />
723730 return (
724731 <div ref={ref} className="repo-embed overflow-x-auto" onClick={onClick} dangerouslySetInnerHTML={{ __html: frag.html }} />
725732 )
726733}
727734
728735function JsonBlob({ space, repo, oid, line }: { space: string; repo: string; oid?: string; path: string; line?: number }) {
736 const { t } = useT()
729737 const [retry, setRetry] = useState(0)
730738 const state = useAsync(
731739 () =>
732740 oid
733741 ? fetchBlob(space, repo, oid)
734 : Promise.reject(new Error('缺少文件标识,无法在私有仓库模式下预览。')),
742 : Promise.reject(new Error(t('repo.file.missingOid'))),
735743 [space, repo, oid, retry],
736744 // A blob is content-addressed: the same oid can never return new data.
737745 { cacheKey: oid ? repoKey(space, repo, `blob:${oid}`) : null },
738746 )
739 if (state.error) return <ErrorPanel status={0} what="文件" onRetry={() => setRetry(value => value + 1)} note={String((state.error as Error).message)} />
740 if (!state.data) return <PanelNote text="加载文件…" />
747 if (state.error) return <ErrorPanel status={0} what={t('repo.file.what')} onRetry={() => setRetry(value => value + 1)} note={String((state.error as Error).message)} />
748 if (!state.data) return <PanelNote text={t('repo.file.loading')} />
741749 const blob = state.data
742750 if (blob.is_binary) {
743 return <PanelNote text={`二进制文件 · ${formatBytes(blob.size)} · 暂不支持预览`} />
751 return <PanelNote text={t('repo.file.binaryDetail', { size: formatBytes(blob.size) })} />
744752 }
745753 const text = decodeBlob(blob.data)
746754 return (
747755 <div>
748756 {blob.truncated ? (
749757 <div className="mb-2 rounded-md border-2 border-[#f0b232]/50 bg-[#f0b232]/10 px-3 py-2 text-[12.5px] text-[#8a6100]">
750 文件较大,服务端已截断返回。
758 {t('repo.file.truncated')}
751759 </div>
752760 ) : null}
753761 <ol className="overflow-x-auto rounded-lg border-2 border-shell-hairline bg-shell-field py-3 font-mono text-[12px] leading-5">
@@ -770,18 +778,19 @@
770778}
771779
772780function EmptyRepo({ repoPath, cloneUrl }: { repoPath: string; cloneUrl: string | null }) {
781 const { t } = useT()
773782 return (
774783 <div className="repo-empty rounded-2xl border border-shell-hairline bg-shell-surface px-6 py-12 text-center">
775784 <span className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-shell-field text-shell-ink-muted"><FolderGit2 className="h-7 w-7" aria-hidden /></span>
776 <p className="mb-2 text-[20px] font-semibold">从第一个提交开始</p>
777 <p className="mx-auto mb-6 max-w-sm text-[13px] leading-6 text-shell-ink-muted">仓库已经准备好了。添加项目文件,让想法在这里慢慢成形。</p>
785 <p className="mb-2 text-[20px] font-semibold">{t('repo.empty.startTitle')}</p>
786 <p className="mx-auto mb-6 max-w-sm text-[13px] leading-6 text-shell-ink-muted">{t('repo.empty.startNote')}</p>
778787 {cloneUrl ? (
779788 <pre className="mx-auto max-w-[560px] overflow-x-auto rounded-xl border border-shell-hairline bg-shell-field p-5 text-left font-mono text-[12px] leading-7">
780 {`git clone ${cloneUrl}\ncd ${repoPath.split('/').pop() ?? repoPath}\n# 添加文件\ngit add . && git commit -m "initial commit" && git push`}
789 {`git clone ${cloneUrl}\ncd ${repoPath.split('/').pop() ?? repoPath}\n${t('repo.empty.addFiles')}\ngit add . && git commit -m "initial commit" && git push`}
781790 </pre>
782791 ) : (
783792 <p className="text-[12.5px] text-shell-ink-muted">
784 克隆地址暂未配置。配置完成后,就可以在这里查看推送指引。
793 {t('repo.empty.cloneNotConfigured')}
785794 </p>
786795 )}
787796 </div>
Modified web/src/features/repo-browse/ui/repo-code-search.tsx View web/src/features/repo-browse/ui/repo-code-search.tsx
@@ -1,5 +1,6 @@
11import { useEffect, useMemo, useRef, useState } from 'react'
22import { CornerDownLeft, FileCode2, Loader2, Search, X } from 'lucide-react'
3import { t, useT } from '@/shared/i18n'
34import { search, type SearchHit } from '@/entities/search'
45
56/** 高亮命中片段里的查询词,命中为空或含正则字符时原样返回。 */
@@ -23,10 +24,10 @@
2324}
2425
2526const KIND_LABEL: Record<SearchHit['kind'], string> = {
26 code: '代码',
27 symbol: '符号',
28 issue: 'Issue',
29 pull: 'PR',
27 get code() { return t('repo.search.kind.code') },
28 get symbol() { return t('repo.search.kind.symbol') },
29 get issue() { return t('repo.search.kind.issue') },
30 get pull() { return t('repo.search.kind.pull') },
3031}
3132
3233export function RepoCodeSearch({
@@ -45,6 +46,7 @@
4546 /** block: full-width bar under the toolbar · toolbar: compact box inside the repo toolbar */
4647 variant?: 'block' | 'toolbar'
4748}) {
49 const { t: tr } = useT()
4850 const [query, setQuery] = useState('')
4951 const [hits, setHits] = useState<SearchHit[]>([])
5052 const [loading, setLoading] = useState(false)
@@ -75,7 +77,7 @@
7577 failure => {
7678 if (!active) return
7779 setHits([])
78 setError(failure instanceof Error ? failure.message : '搜索失败')
80 setError(failure instanceof Error ? failure.message : tr('repo.search.failed'))
7981 setLoading(false)
8082 },
8183 )
@@ -84,7 +86,7 @@
8486 active = false
8587 window.clearTimeout(timer)
8688 }
87 }, [query, repo, space])
89 }, [query, repo, space, tr])
8890
8991
9092 useEffect(() => {
@@ -169,22 +171,22 @@
169171 setOpen(false)
170172 }
171173 }}
172 placeholder="在仓库中搜索代码或符号…"
173 aria-label="搜索当前仓库代码"
174 placeholder={tr('repo.search.codePlaceholder')}
175 aria-label={tr('repo.search.codeLabel')}
174176 role="combobox"
175177 aria-expanded={panelOpen}
176178 aria-controls="repo-search-results"
177179 className={inline ? 'h-full min-w-0 flex-1 bg-transparent text-[12.5px] outline-none placeholder:text-shell-ink-muted' : 'h-10 min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-shell-ink-muted'}
178180 />
179181 {query ? (
180 <button type="button" onClick={() => { setQuery(''); setOpen(false) }} className="rounded p-1 text-shell-ink-muted hover:bg-shell-active hover:text-shell-ink" aria-label="清除仓库搜索">
182 <button type="button" onClick={() => { setQuery(''); setOpen(false) }} className="rounded p-1 text-shell-ink-muted hover:bg-shell-active hover:text-shell-ink" aria-label={tr('repo.search.clear')}>
181183 <X className="h-3.5 w-3.5" aria-hidden />
182184 </button>
183185 ) : (
184186 <kbd className="hidden shrink-0 rounded border border-shell-hairline bg-shell-surface px-1.5 py-0.5 font-mono text-[10px] text-shell-ink-muted sm:block">/</kbd>
185187 )}
186188 </div>
187 {!indexedBranch && !inline ? <p className="mt-1 px-1 text-[11px] text-shell-ink-muted">结果来自默认分支 {defaultBranch}</p> : null}
189 {!indexedBranch && !inline ? <p className="mt-1 px-1 text-[11px] text-shell-ink-muted">{tr('repo.search.defaultBranchNote', { branch: defaultBranch })}</p> : null}
188190 {panelOpen ? (
189191 <div
190192 id="repo-search-results"
@@ -193,10 +195,10 @@
193195 ? 'absolute inset-x-0 z-40 mt-1 max-h-[min(60vh,480px)] overflow-y-auto overscroll-contain rounded-lg border border-shell-hairline bg-shell-surface py-1 shadow-xl'
194196 : 'absolute inset-x-0 z-30 mt-1 max-h-[min(60vh,480px)] overflow-y-auto overscroll-contain rounded-lg border border-shell-hairline bg-shell-surface py-1 shadow-xl'}
195197 >
196 {loading ? <p className="px-3 py-4 text-[12px] text-shell-ink-muted">正在搜索…</p> : null}
198 {loading ? <p className="px-3 py-4 text-[12px] text-shell-ink-muted">{tr('repo.search.searching')}</p> : null}
197199 {error ? <p role="alert" className="px-3 py-4 text-[12px] text-[#b42318]">{error}</p> : null}
198200 {!loading && !error && hits.length === 0 ? (
199 <p className="px-3 py-6 text-center text-[12px] text-shell-ink-muted">没有匹配的代码或符号</p>
201 <p className="px-3 py-6 text-center text-[12px] text-shell-ink-muted">{tr('repo.search.noHits')}</p>
200202 ) : null}
201203 {groups.map(group => (
202204 <div key={group.kind}>
@@ -241,7 +243,7 @@
241243 ))}
242244 {hits.length > 0 ? (
243245 <p className="border-t border-shell-hairline px-3 py-2 text-[11px] text-shell-ink-muted">
244 ↑↓ 选择 · Enter 打开 · Esc 关闭
246 {tr('repo.search.keyboardHint')}
245247 </p>
246248 ) : null}
247249 </div>
Modified web/src/features/repo-history/ui/commit-insights.tsx View web/src/features/repo-history/ui/commit-insights.tsx
@@ -9,6 +9,7 @@
99 type CodeComment,
1010} from '@/entities/repo'
1111import { LanguagePicker, listCommitAiReviews, severityTone, triggerCommitAiReview, type CommitAiReview } from '@/entities/pull-request'
12import { useT } from '@/shared/i18n'
1213import { useAsync } from '@/shared/lib'
1314
1415export interface CommitInsightsProps {
@@ -21,6 +22,7 @@
2122
2223/** 提交页的 AI 评审面板。行级评论不在这里:它们由差异视图行内渲染。 */
2324export function CommitInsights({ space, repo, sha, onChanged }: CommitInsightsProps) {
25 const { t } = useT()
2426 const [tick, setTick] = useState(0)
2527 const [busy, setBusy] = useState(false)
2628 const [error, setError] = useState<string | null>(null)
@@ -38,7 +40,7 @@
3840 setTick(t => t + 1)
3941 onChanged?.()
4042 } catch (f) {
41 setError(f instanceof Error ? f.message : '触发 AI 评审失败')
43 setError(f instanceof Error ? f.message : t('repo.ai.triggerReviewFailed'))
4244 } finally {
4345 setBusy(false)
4446 }
@@ -52,41 +54,41 @@
5254 <section className="rounded-xl border border-shell-hairline bg-shell-surface px-4 py-3">
5355 <div className="flex items-center gap-2">
5456 <Sparkles className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
55 <span className="text-[13px] font-medium">AI 评审</span>
57 <span className="text-[13px] font-medium">{t('repo.ai.review')}</span>
5658 <LanguagePicker value={language} onChange={setLanguage} disabled={busy} />
5759 <Button className="ml-auto h-7 text-[12px]" disabled={busy} onClick={() => void rerunAi()}>
58 重新评审
60 {t('repo.ai.rerunReview')}
5961 </Button>
6062 </div>
61 <p className="mt-2 text-[12.5px] text-shell-ink-muted">该提交还没有 AI 评审结果。</p>
63 <p className="mt-2 text-[12.5px] text-shell-ink-muted">{t('repo.ai.noReview')}</p>
6264 </section>
6365 ) : (
6466 <section className="rounded-xl border border-shell-hairline bg-shell-surface">
6567 <header className="flex flex-wrap items-center gap-2 border-b border-shell-hairline bg-shell-field px-4 py-2.5 text-[12.5px]">
6668 <Sparkles className="h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
67 <span className="font-medium">AI 评审</span>
69 <span className="font-medium">{t('repo.ai.review')}</span>
6870 <span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
6971 aiRows[0].verdict === 'changes_requested'
7072 ? 'text-[#b42318] bg-[rgba(242,63,67,0.10)]'
7173 : 'text-shell-ink-muted bg-shell-active'
7274 }`}>
73 {aiRows[0].verdict === 'changes_requested' ? '请求修改' : '仅评论'}
75 {aiRows[0].verdict === 'changes_requested' ? t('repo.ai.verdictChangesRequested') : t('repo.ai.verdictComment')}
7476 </span>
7577 {aiRows[0].language ? <span className="text-[11.5px] text-shell-ink-muted">{aiRows[0].language}</span> : null}
76 {aiRows[0].truncated ? <span className="text-[11.5px] text-[#8a6100]">内容被截断</span> : null}
78 {aiRows[0].truncated ? <span className="text-[11.5px] text-[#8a6100]">{t('repo.ai.truncated')}</span> : null}
7779 <LanguagePicker value={language} onChange={setLanguage} disabled={busy} />
7880 <Button className="ml-auto h-7 text-[12px]" disabled={busy} onClick={() => void rerunAi()}>
79 重新评审
81 {t('repo.ai.rerunReview')}
8082 </Button>
8183 </header>
8284 <div className="space-y-2 px-4 py-3">
83 <Markdown>{aiRows[0].summary || '(无总结)'}</Markdown>
85 <Markdown>{aiRows[0].summary || t('repo.ai.noSummary')}</Markdown>
8486 {aiRows[0].findings.map((finding, index) => (
8587 <div key={`${finding.path}:${finding.line}:${index}`} className="rounded-lg border border-shell-hairline bg-shell-field px-3 py-2">
8688 <div className="mb-1 flex flex-wrap items-center gap-2 text-[12px]">
8789 <span className={`rounded px-1.5 py-0.5 font-medium ${severityTone(finding.severity)}`}>{finding.severity || 'minor'}</span>
8890 <span className="font-mono">{finding.path}:{finding.line}</span>
89 {finding.side === 'left' ? <span className="text-shell-ink-muted">旧行</span> : null}
91 {finding.side === 'left' ? <span className="text-shell-ink-muted">{t('repo.ai.oldLine')}</span> : null}
9092 </div>
9193 <Markdown compact>{finding.body}</Markdown>
9294 </div>
@@ -110,6 +112,7 @@
110112 comment: CodeComment
111113 onChanged: () => void
112114}) {
115 const { t } = useT()
113116 const [working, setWorking] = useState(false)
114117 const [error, setError] = useState<string | null>(null)
115118
@@ -129,10 +132,10 @@
129132 return (
130133 <div className="border-t border-shell-hairline bg-shell-field/60 px-3 py-2 font-sans">
131134 <div className="mb-1 flex flex-wrap items-center gap-2 font-sans text-[11.5px]">
132 <span className="font-medium text-shell-ink">{comment.author?.display_name || comment.author?.username || '未知用户'}</span>
135 <span className="font-medium text-shell-ink">{comment.author?.display_name || comment.author?.username || t('repo.comment.unknownUser')}</span>
133136 <span className="font-mono text-shell-ink-muted">{comment.path}:{comment.line}</span>
134 {comment.resolved ? <span className="text-[#1a7f37]">已解决</span> : null}
135 {comment.outdated ? <span className="text-[#8a6100]">已过期</span> : null}
137 {comment.resolved ? <span className="text-[#1a7f37]">{t('repo.comment.resolved')}</span> : null}
138 {comment.outdated ? <span className="text-[#8a6100]">{t('repo.comment.outdated')}</span> : null}
136139 <button
137140 type="button"
138141 disabled={working}
@@ -140,18 +143,18 @@
140143 () => (comment.resolved
141144 ? unresolveCodeComment(space, repo, comment.id)
142145 : resolveCodeComment(space, repo, comment.id)),
143 '更新评论状态失败',
146 t('repo.comment.updateFailed'),
144147 )}
145148 className="ml-auto text-shell-ink-muted hover:text-shell-ink"
146149 >
147 {comment.resolved ? '取消解决' : '标记解决'}
150 {comment.resolved ? t('repo.comment.unresolve') : t('repo.comment.resolve')}
148151 </button>
149152 <button
150153 type="button"
151154 disabled={working}
152 onClick={() => void run(() => deleteCodeComment(space, repo, comment.id), '删除评论失败')}
155 onClick={() => void run(() => deleteCodeComment(space, repo, comment.id), t('repo.comment.deleteFailed'))}
153156 className="text-shell-ink-muted hover:text-[#b42318]"
154 aria-label="删除评论"
157 aria-label={t('repo.comment.delete')}
155158 >
156159 <Trash2 className="h-3.5 w-3.5" aria-hidden />
157160 </button>
@@ -180,6 +183,7 @@
180183 onDone: () => void
181184 onCancel: () => void
182185}) {
186 const { t } = useT()
183187 const [body, setBody] = useState('')
184188 const [busy, setBusy] = useState(false)
185189 const [error, setError] = useState<string | null>(null)
@@ -197,7 +201,7 @@
197201 })
198202 onDone()
199203 } catch (f) {
200 setError(f instanceof Error ? f.message : '评论失败')
204 setError(f instanceof Error ? f.message : t('repo.comment.createFailed'))
201205 } finally {
202206 setBusy(false)
203207 }
@@ -211,13 +215,13 @@
211215 value={body}
212216 onChange={event => setBody(event.target.value)}
213217 rows={2}
214 placeholder="对该行发表评论…"
218 placeholder={t('repo.comment.placeholder')}
215219 className={modalTextareaClass}
216220 />
217221 {error ? <p className="text-[12px] text-[#b42318]">{error}</p> : null}
218222 <div className="flex items-center gap-2">
219 <Button className="h-7 text-[12px]" disabled={busy} onClick={() => void submit()}>发表评论</Button>
220 <Button variant="ghost" className="h-7 text-[12px]" onClick={onCancel}>取消</Button>
223 <Button className="h-7 text-[12px]" disabled={busy} onClick={() => void submit()}>{t('repo.comment.submit')}</Button>
224 <Button variant="ghost" className="h-7 text-[12px]" onClick={onCancel}>{t('common.action.cancel')}</Button>
221225 </div>
222226 </div>
223227 )
Modified web/src/features/repo-history/ui/commit-view.tsx View web/src/features/repo-history/ui/commit-view.tsx
@@ -1,5 +1,6 @@
11import { Fragment, useMemo, useState } from 'react'
22import { ArrowLeft, ChevronDown, ChevronRight } from 'lucide-react'
3import { t, useT } from '@/shared/i18n'
34import { Button, CopyChip, ErrorPanel, PanelNote } from '@/shared/ui'
45import { fmtRelative, fmtTime, repoKey, settingsKey, shortSha, useAsync } from '@/shared/lib'
56import { CodeLineCommentCard, CommitInsights, LineCommentComposer } from './commit-insights'
@@ -39,12 +40,12 @@
3940const COLLAPSE_ABOVE_BYTES = 200_000
4041
4142const STATUS_LABEL: Record<DiffFile['status'], string> = {
42 added: '新增',
43 deleted: '删除',
44 renamed: '重命名',
45 copied: '复制',
46 modified: '修改',
47 binary: '二进制',
43 get added() { return t('repo.commit.status.added') },
44 get deleted() { return t('repo.commit.status.deleted') },
45 get renamed() { return t('repo.commit.status.renamed') },
46 get copied() { return t('repo.commit.status.copied') },
47 get modified() { return t('repo.commit.status.modified') },
48 get binary() { return t('repo.commit.status.binary') },
4849}
4950
5051const STATUS_TONE: Record<DiffFile['status'], string> = {
@@ -68,6 +69,7 @@
6869 diffStyle,
6970 onDiffStyleChange,
7071}: CommitViewProps) {
72 const { t, tc } = useT()
7173 const [retry, setRetry] = useState(0)
7274 const [commentsVersion, setCommentsVersion] = useState(0)
7375 const [expanded, setExpanded] = useState<Record<string, boolean>>({})
@@ -120,7 +122,7 @@
120122 <div className="repo-commit-detail pb-6">
121123 <div className="mb-3 flex flex-wrap items-center gap-2">
122124 <Button variant="secondary" className="h-8 gap-1 text-[12.5px]" onClick={onBack}>
123 <ArrowLeft className="h-3.5 w-3.5" aria-hidden /> 返回提交历史
125 <ArrowLeft className="h-3.5 w-3.5" aria-hidden /> {t('repo.commit.backToHistory')}
124126 </Button>
125127 {data ? (
126128 <Button
@@ -128,26 +130,26 @@
128130 className="ml-auto h-8 text-[12.5px]"
129131 onClick={() => onBrowseRevision(data.commit.id)}
130132 >
131 浏览该修订
133 {t('repo.commit.browseRevision')}
132134 </Button>
133135 ) : null}
134136 </div>
135137
136 {state.loading && !data ? <PanelNote text="加载提交内容…" /> : null}
138 {state.loading && !data ? <PanelNote text={t('repo.commit.loading')} /> : null}
137139 {!state.loading && failure ? (
138140 <ErrorPanel
139141 status={failure.code ?? 0}
140 what="提交"
142 what={t('repo.commit.what')}
141143 note={failure.message}
142144 onRetry={() => setRetry(r => r + 1)}
143145 />
144146 ) : null}
145 {!state.loading && !failure && !data ? <PanelNote text="提交不存在或已被改写。" /> : null}
147 {!state.loading && !failure && !data ? <PanelNote text={t('repo.commit.missing')} /> : null}
146148
147149 {data ? (
148150 <>
149151 <section className="repo-commit-summary mb-5 rounded-xl border border-shell-hairline bg-shell-surface p-5">
150 <h3 className="break-words text-[18px] font-semibold leading-7">{data.commit.message.split('\n')[0] || '(无提交信息)'}</h3>
152 <h3 className="break-words text-[18px] font-semibold leading-7">{data.commit.message.split('\n')[0] || t('repo.commit.noMessage')}</h3>
151153 {data.commit.message.split('\n').slice(1).join('\n').trim() ? (
152154 <pre className="mt-2 whitespace-pre-wrap font-sans text-[13px] leading-6 text-shell-ink-muted">
153155 {data.commit.message.split('\n').slice(1).join('\n').trim()}
@@ -155,14 +157,14 @@
155157 ) : null}
156158 <dl className="mt-3 grid gap-1 text-[12.5px] text-shell-ink-muted sm:grid-cols-2">
157159 <div className="flex flex-wrap items-center gap-1.5">
158 <dt className="font-mono text-[10px] uppercase tracking-[1.2px]">作者</dt>
159 <dd className="text-shell-ink">{data.commit.author_name || '未知'}</dd>
160 <dt className="font-mono text-[10px] uppercase tracking-[1.2px]">{t('repo.commit.author')}</dt>
161 <dd className="text-shell-ink">{data.commit.author_name || t('common.state.unknown')}</dd>
160162 {data.commit.author_email ? <dd>&lt;{data.commit.author_email}&gt;</dd> : null}
161163 <dd title={fmtTime(data.commit.author_time)}>{fmtRelative(data.commit.author_time)}</dd>
162164 </div>
163165 <div className="flex flex-wrap items-center gap-1.5">
164 <dt className="font-mono text-[10px] uppercase tracking-[1.2px]">提交者</dt>
165 <dd className="text-shell-ink">{data.commit.committer_name || '未知'}</dd>
166 <dt className="font-mono text-[10px] uppercase tracking-[1.2px]">{t('repo.commit.committer')}</dt>
167 <dd className="text-shell-ink">{data.commit.committer_name || t('common.state.unknown')}</dd>
166168 <dd title={fmtTime(data.commit.committer_time)}>{fmtRelative(data.commit.committer_time)}</dd>
167169 </div>
168170 </dl>
@@ -170,8 +172,8 @@
170172 <CopyChip text={data.commit.id} label={shortSha(data.commit.id, 10)} />
171173 <span className="text-shell-ink-muted">
172174 {data.commit.parents.length === 0
173 ? '首个提交(对比空树)'
174 : `对比 ${shortSha(data.base, 10)}`}
175 ? t('repo.commit.root')
176 : t('repo.commit.compareTo', { sha: shortSha(data.base, 10) })}
175177 </span>
176178 {data.commit.parents.map(parent => (
177179 <button
@@ -181,7 +183,7 @@
181183 className="rounded bg-shell-active/70 px-1.5 py-0.5 font-mono text-[11px] text-shell-ink-muted transition-colors hover:text-shell-ink"
182184 title={parent}
183185 >
184 父 {shortSha(parent, 8)}
186 {t('repo.commit.parentChip', { sha: shortSha(parent, 8) })}
185187 </button>
186188 ))}
187189 </div>
@@ -189,12 +191,12 @@
189191
190192 <div className="mb-2 flex flex-wrap items-center gap-3 text-[13px]">
191193 <span className="font-semibold">
192 {files.length} 个文件改动
194 {tc('repo.commit.filesChanged', files.length)}
193195 </span>
194196 <span className="text-[#1a7f37]">+{additions}</span>
195197 <span className="text-[#b42318]">−{deletions}</span>
196198 {!autoExpand && files.length > 0 ? (
197 <span className="text-[12px] text-shell-ink-muted">改动较多,默认折叠,点击文件展开。</span>
199 <span className="text-[12px] text-shell-ink-muted">{t('repo.commit.collapsedHint')}</span>
198200 ) : null}
199201 <div className="ml-auto flex items-center gap-0.5 rounded-md bg-shell-field p-0.5">
200202 {(['unified', 'split'] as const).map(value => (
@@ -202,13 +204,13 @@
202204 key={value}
203205 type="button"
204206 aria-pressed={style === value}
205 title={diffStyle === '' ? '当前跟随「用户设置 · 外观」' : undefined}
207 title={diffStyle === '' ? t('repo.diff.followAppearance') : undefined}
206208 onClick={() => onDiffStyleChange(value)}
207209 className={`rounded px-2 py-0.5 text-[12px] transition-colors ${
208210 style === value ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:text-shell-ink'
209211 }`}
210212 >
211 {value === 'unified' ? '统一' : '分栏'}
213 {value === 'unified' ? t('repo.diff.unified') : t('repo.diff.split')}
212214 </button>
213215 ))}
214216 </div>
@@ -221,7 +223,7 @@
221223 onChanged={() => setCommentsVersion(v => v + 1)}
222224 />
223225
224 {files.length === 0 ? <PanelNote text="该提交没有文件改动(可能是空提交或合并提交)。" /> : null}
226 {files.length === 0 ? <PanelNote text={t('repo.commit.noFiles')} /> : null}
225227 <div className="space-y-3">
226228 {files.map(file => {
227229 const open = expanded[file.path] ?? autoExpand
@@ -294,8 +296,8 @@
294296 const [draftLine, setDraftLine] = useState<number | null>(null)
295297 // Parsed only while the card is mounted, so a 500-file commit stays cheap.
296298 const { hunks, truncated } = useMemo(() => parseHunks(file.raw), [file.raw])
297 if (file.status === 'binary') return <PanelNote text="二进制文件,不显示差异内容。" />
298 if (hunks.length === 0) return <PanelNote text="仅权限或元数据变更。" />
299 if (file.status === 'binary') return <PanelNote text={t('repo.diff.binary')} />
300 if (hunks.length === 0) return <PanelNote text={t('repo.diff.metaOnly')} />
299301
300302 const threadsAt = (line: number) => commentsByLine.get(`${file.path}#${line}`) ?? []
301303 const hasThread = (line: number | null | undefined) =>
@@ -380,7 +382,7 @@
380382 </table>
381383 {truncated ? (
382384 <p className="border-t border-shell-hairline px-3 py-2 text-[12px] text-[#8a6100]">
383 差异过长,已截断显示前 1500 行。
385 {t('repo.diff.truncated', { count: 1500 })}
384386 </p>
385387 ) : null}
386388 </div>
@@ -396,6 +398,7 @@
396398 onRequestComment?: (line: number) => void
397399 commentCount?: number
398400}) {
401 const { t, tc } = useT()
399402 const tone =
400403 line.kind === 'add'
401404 ? 'bg-[rgba(35,165,90,0.12)]'
@@ -416,7 +419,7 @@
416419 <span className="inline-flex items-center gap-0.5">
417420 <button
418421 type="button"
419 title={commentCount > 0 ? `该行有 ${commentCount} 条评论` : '对该行发表评论'}
422 title={commentCount > 0 ? tc('repo.diff.lineComments', commentCount) : t('repo.diff.commentOnLine')}
420423 onClick={() => onRequestComment(lineNo)}
421424 className={`rounded px-0.5 transition-opacity hover:text-shell-ink focus:opacity-100 ${
422425 commentCount > 0
@@ -464,7 +467,7 @@
464467 {lineNo != null && onRequestComment ? (
465468 <button
466469 type="button"
467 title="对该行发表评论"
470 title={t('repo.diff.commentOnLine')}
468471 onClick={() => onRequestComment(lineNo)}
469472 className="ml-1 inline-block rounded bg-shell-active px-0.5 text-[11px] text-shell-ink-muted opacity-0 transition-opacity hover:text-shell-ink focus:opacity-100 group-hover/row:opacity-100"
470473 >
@@ -512,7 +515,7 @@
512515 </table>
513516 {truncated ? (
514517 <p className="border-t border-shell-hairline px-3 py-2 text-[12px] text-[#8a6100]">
515 差异过长,已截断显示前 1500 行。
518 {t('repo.diff.truncated', { count: 1500 })}
516519 </p>
517520 ) : null}
518521 </div>
Modified web/src/features/repo-history/ui/commits-tab.tsx View web/src/features/repo-history/ui/commits-tab.tsx
@@ -1,6 +1,7 @@
11import { useState } from 'react'
22import { ChevronLeft, ChevronRight, GitCommitHorizontal, GitMerge } from 'lucide-react'
33import { Button, Checkbox, CopyChip, ErrorPanel, PanelNote } from '@/shared/ui'
4import { useT } from '@/shared/i18n'
45import { fmtRelative, fmtTime, repoKey, shortSha, useAsync } from '@/shared/lib'
56import { listCommitsPage } from '@/entities/repo'
67
@@ -35,6 +36,7 @@
3536 firstParent,
3637 onFirstParentChange,
3738}: CommitsTabProps) {
39 const { t, tc } = useT()
3840 // Only paging and the manual retry live here; the revision, path filter and
3941 // first-parent toggle are route state, so this view is rebuildable from a URL.
4042 const [skip, setSkip] = useState(0)
@@ -55,15 +57,15 @@
5557 <div className="repo-history">
5658 <div className="repo-section-toolbar mb-5 flex flex-wrap items-center gap-3">
5759 <h3 className="min-w-0 break-all text-[17px] font-semibold">
58 提交历史 <span className="font-mono text-[12.5px] font-normal text-shell-ink-muted">· {rev}</span>
60 {t('repo.commit.history')} <span className="font-mono text-[12.5px] font-normal text-shell-ink-muted">· {rev}</span>
5961 {path ? (
6062 <span className="font-mono text-[12.5px] font-normal text-shell-ink-muted">· {path}</span>
6163 ) : null}
6264 </h3>
63 {page ? <span className="text-[12.5px] text-shell-ink-muted">共 {total} 次</span> : null}
65 {page ? <span className="text-[12.5px] text-shell-ink-muted">{t('repo.commits.total', { count: total })}</span> : null}
6466 {path ? (
6567 <Button variant="secondary" className="h-7 text-[12px]" onClick={() => onShowHistory('')}>
66 查看全部历史
68 {t('repo.commits.viewAll')}
6769 </Button>
6870 ) : null}
6971 <label className="ml-auto flex items-center gap-2 text-[12.5px] text-shell-ink-muted">
@@ -73,25 +75,25 @@
7375 onFirstParentChange(value)
7476 setSkip(0)
7577 }}
76 aria-label="只看第一父提交"
78 aria-label={t('repo.commits.firstParent')}
7779 />
7880 <span className="cursor-pointer">
79 只看第一父提交
81 {t('repo.commits.firstParent')}
8082 </span>
8183 </label>
8284 </div>
8385
8486 {state.loading && !page ? (
85 <PanelNote text="加载提交历史…" />
87 <PanelNote text={t('repo.commits.loading')} />
8688 ) : failure ? (
8789 <ErrorPanel
8890 status={failure.code ?? 0}
89 what="提交历史"
90 note="该修订可能已被删除或改写。"
91 what={t('repo.commit.history')}
92 note={t('repo.commits.missing')}
9193 onRetry={() => setRetry(r => r + 1)}
9294 />
9395 ) : page && page.commits.length === 0 ? (
94 <PanelNote text={total === 0 ? '该修订没有提交。' : '这一页没有内容,请翻回上一页。'} />
96 <PanelNote text={total === 0 ? t('repo.commits.none') : t('repo.commits.pageEmpty')} />
9597 ) : (
9698 <ul className="repo-commit-list divide-y divide-shell-hairline overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
9799 {page?.commits.map((commit, index) => {
@@ -113,7 +115,7 @@
113115 title={commit.message}
114116 className="repo-commit-subject block max-w-full text-left text-[14px] font-medium leading-6 hover:underline hover:decoration-shell-hairline"
115117 >
116 {subject || '(无提交信息)'}
118 {subject || t('repo.commit.noMessage')}
117119 </button>
118120 {body ? (
119121 <p className="mt-1 line-clamp-2 text-[12px] leading-5 text-shell-ink-muted" title={body}>
@@ -121,11 +123,11 @@
121123 </p>
122124 ) : null}
123125 <p className="mt-1 flex flex-wrap items-center gap-2 text-[12px] text-shell-ink-muted">
124 <span className="font-medium text-shell-ink">{commit.author_name || '未知作者'}</span>
126 <span className="font-medium text-shell-ink">{commit.author_name || t('repo.commit.unknownAuthor')}</span>
125127 <span title={fmtTime(commit.author_time)}>{fmtRelative(commit.author_time)}</span>
126 {isMerge ? <span>· {commit.parents.length} 个父提交</span> : null}
128 {isMerge ? <span>· {tc('repo.commit.parents', commit.parents.length)}</span> : null}
127129 {isTip ? (
128 <span className="rounded-full bg-shell-field px-2 py-0.5 text-[11px] text-shell-ink-muted">最新提交</span>
130 <span className="rounded-full bg-shell-field px-2 py-0.5 text-[11px] text-shell-ink-muted">{t('repo.commit.latest')}</span>
129131 ) : null}
130132 </p>
131133 </div>
@@ -136,7 +138,7 @@
136138 className="h-8 text-[12.5px]"
137139 onClick={() => onOpenCommit(commit.id)}
138140 >
139 查看改动
141 {t('repo.action.viewChanges')}
140142 </Button>
141143 </div>
142144 </li>
@@ -153,10 +155,10 @@
153155 disabled={skip === 0 || state.loading}
154156 onClick={() => setSkip(Math.max(0, skip - PAGE_SIZE))}
155157 >
156 <ChevronLeft className="h-3.5 w-3.5" aria-hidden /> 上一页
158 <ChevronLeft className="h-3.5 w-3.5" aria-hidden /> {t('repo.pager.prev')}
157159 </Button>
158160 <span className="text-[12.5px] text-shell-ink-muted">
159 第 {current} / {pages} 页
161 {t('repo.pager.page', { current, pages })}
160162 </span>
161163 <Button
162164 variant="secondary"
@@ -164,7 +166,7 @@
164166 disabled={!page.hasMore || state.loading}
165167 onClick={() => setSkip(skip + PAGE_SIZE)}
166168 >
167 下一页 <ChevronRight className="h-3.5 w-3.5" aria-hidden />
169 {t('repo.pager.next')} <ChevronRight className="h-3.5 w-3.5" aria-hidden />
168170 </Button>
169171 </div>
170172 ) : null}
Modified web/src/features/repo-pipelines/ui/pipelines-view.tsx View web/src/features/repo-pipelines/ui/pipelines-view.tsx
@@ -1,7 +1,8 @@
11import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22import { ChevronLeft, RefreshCw, RotateCw, Play, XCircle, Download } from 'lucide-react'
3import { t, useT } from '@/shared/i18n'
34import { Alert, Button, PanelNote, modalInputClass, modalLabelClass } from '@/shared/ui'
4import { useAsync } from '@/shared/lib'
5import { fmtTime, useAsync } from '@/shared/lib'
56import {
67 cancelRun,
78 fetchLogs,
@@ -16,19 +17,19 @@
1617} from '@/entities/pipeline'
1718
1819const STATE_LABEL: Record<string, string> = {
19 queued: '排队中',
20 pending: '等待依赖',
21 claimed: '已领取',
22 running: '运行中',
23 success: '成功',
24 failure: '失败',
25 timed_out: '超时',
26 cancelled: '已取消',
27 cancel_requested: '取消中',
28 skipped: '已跳过',
29 ready: '就绪',
30 uploading: '上传中',
31 failed: '失败',
20 get queued() { return t('label.runStatus.queued') },
21 get pending() { return t('repo.pipeline.state.pending') },
22 get claimed() { return t('repo.pipeline.state.claimed') },
23 get running() { return t('label.runStatus.running') },
24 get success() { return t('label.runStatus.success') },
25 get failure() { return t('label.runStatus.failed') },
26 get timed_out() { return t('repo.pipeline.state.timedOut') },
27 get cancelled() { return t('label.runStatus.cancelled') },
28 get cancel_requested() { return t('repo.pipeline.state.cancelRequested') },
29 get skipped() { return t('label.runStatus.skipped') },
30 get ready() { return t('repo.pipeline.state.ready') },
31 get uploading() { return t('repo.pipeline.state.uploading') },
32 get failed() { return t('label.runStatus.failed') },
3233}
3334
3435const ACTIVE_STATES = new Set(['queued', 'running', 'cancel_requested', 'pending', 'claimed'])
@@ -77,6 +78,7 @@
7778 runId: string
7879 onSelectRun: (runId: string) => void
7980}) {
81 const { t } = useT()
8082 const [triggering, setTriggering] = useState(false)
8183 const [tick, setTick] = useState(0)
8284 const runs = useAsync(
@@ -101,24 +103,24 @@
101103 <div className="space-y-4 pt-2">
102104 <div className="flex items-center justify-between gap-3">
103105 <div>
104 <h3 className="text-[16px] font-semibold">流水线</h3>
106 <h3 className="text-[16px] font-semibold">{t('repo.pipeline.heading')}</h3>
105107 <p className="mt-1 text-[13px] text-shell-ink-muted">
106 由 <code className="text-shell-ink">.workflows/</code> 中的定义驱动,runner 通过调度中心领取作业。
108 {t('repo.pipeline.subtitle')}
107109 </p>
108110 </div>
109111 <div className="flex items-center gap-2">
110112 <Button size="default" variant="secondary" onClick={refresh}>
111 <RefreshCw className="mr-1 h-3.5 w-3.5" /> 刷新
113 <RefreshCw className="mr-1 h-3.5 w-3.5" /> {t('common.action.refresh')}
112114 </Button>
113115 <Button size="default" onClick={() => setTriggering(true)}>
114 <Play className="mr-1 h-3.5 w-3.5" /> 手动触发
116 <Play className="mr-1 h-3.5 w-3.5" /> {t('repo.pipeline.trigger')}
115117 </Button>
116118 </div>
117119 </div>
118120
119121 {runs.error ? (
120122 <Alert tone="error">
121 {runs.error instanceof Error ? runs.error.message : '加载失败'}
123 {runs.error instanceof Error ? runs.error.message : t('repo.error.loadFailed')}
122124 </Alert>
123125 ) : null}
124126
@@ -137,11 +139,11 @@
137139 ) : null}
138140
139141 {runs.loading && !runs.data ? (
140 <PanelNote text="正在加载运行记录…" />
142 <PanelNote text={t('repo.pipeline.runsLoading')} />
141143 ) : null}
142144
143145 {runs.data && runs.data.runs.length === 0 ? (
144 <PanelNote text="还没有运行记录。推送包含 .workflows/*.yml 的提交,或在此手动触发。" />
146 <PanelNote text={t('repo.pipeline.runsEmpty')} />
145147 ) : null}
146148
147149 {runs.data && runs.data.runs.length > 0 ? (
@@ -160,7 +162,7 @@
160162 {run.event} · {run.branch ?? run.tag ?? run.git_ref} · {shortSha(run.commit_sha)}
161163 </span>
162164 <span className="ml-auto text-[12px] text-shell-ink-muted">
163 {new Date(run.created_at).toLocaleString()}
165 {fmtTime(run.created_at)}
164166 </span>
165167 </div>
166168 </button>
@@ -185,6 +187,7 @@
185187 onClose: () => void
186188 onTriggered: (run: RunSummary | null) => void
187189}) {
190 const { t } = useT()
188191 const [gitRef, setGitRef] = useState(defaultRef)
189192 const [workflowInput, setWorkflowInput] = useState('')
190193 const [busy, setBusy] = useState(false)
@@ -208,7 +211,7 @@
208211 const detail = await triggerRun(space, repo, { workflow, git_ref: gitRef.trim() })
209212 onTriggered(detail.run)
210213 } catch (err) {
211 setError(err instanceof Error ? err.message : '触发失败')
214 setError(err instanceof Error ? err.message : t('repo.pipeline.triggerFailed'))
212215 } finally {
213216 setBusy(false)
214217 }
@@ -216,9 +219,9 @@
216219
217220 return (
218221 <div className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
219 <h4 className="mb-3 text-[14px] font-semibold">手动触发 workflow_dispatch</h4>
222 <h4 className="mb-3 text-[14px] font-semibold">{t('repo.pipeline.triggerTitle')}</h4>
220223 <label className="mb-3 block">
221 <span className={modalLabelClass}>分支或标签</span>
224 <span className={modalLabelClass}>{t('repo.pipeline.ref')}</span>
222225 <input
223226 className={modalInputClass}
224227 value={gitRef}
@@ -227,7 +230,7 @@
227230 />
228231 </label>
229232 <label className="mb-3 block">
230 <span className={modalLabelClass}>工作流</span>
233 <span className={modalLabelClass}>{t('repo.pipeline.workflow')}</span>
231234 <select
232235 className={modalInputClass}
233236 value={workflow}
@@ -242,19 +245,19 @@
242245 </label>
243246 {workflows.error ? (
244247 <Alert tone="error">
245 {workflows.error instanceof Error ? workflows.error.message : '工作流加载失败'}
248 {workflows.error instanceof Error ? workflows.error.message : t('repo.pipeline.workflowsFailed')}
246249 </Alert>
247250 ) : null}
248251 {!workflows.loading && dispatchable.length === 0 ? (
249 <PanelNote text="该修订下没有可手动触发的工作流(需在 on.workflow_dispatch 中声明)。" />
252 <PanelNote text={t('repo.pipeline.noDispatchable')} />
250253 ) : null}
251254 {error ? <Alert tone="error">{error}</Alert> : null}
252255 <div className="mt-3 flex justify-end gap-2">
253256 <Button size="default" variant="secondary" onClick={onClose}>
254 取消
257 {t('common.action.cancel')}
255258 </Button>
256259 <Button size="default" disabled={busy || !workflow || !gitRef.trim()} onClick={submit}>
257 {busy ? '触发中…' : '触发'}
260 {busy ? t('repo.pipeline.triggering') : t('repo.pipeline.triggerConfirm')}
258261 </Button>
259262 </div>
260263 </div>
@@ -272,6 +275,7 @@
272275 runId: string
273276 onBack: () => void
274277}) {
278 const { t } = useT()
275279 const [detail, setDetail] = useState<RunDetail | null>(null)
276280 const [error, setError] = useState<string | null>(null)
277281 const [selectedJob, setSelectedJob] = useState<string>('')
@@ -283,9 +287,9 @@
283287 setError(null)
284288 setSelectedJob((current) => current || next.jobs[0]?.id || '')
285289 } catch (err) {
286 setError(err instanceof Error ? err.message : '加载失败')
290 setError(err instanceof Error ? err.message : t('repo.error.loadFailed'))
287291 }
288 }, [space, repo, runId])
292 }, [space, repo, runId, t])
289293
290294 useEffect(() => {
291295 void load()
@@ -305,7 +309,7 @@
305309 setDetail(await fn())
306310 setError(null)
307311 } catch (err) {
308 setError(err instanceof Error ? err.message : '操作失败')
312 setError(err instanceof Error ? err.message : t('repo.error.actionFailedShort'))
309313 }
310314 }
311315
@@ -318,7 +322,7 @@
318322 <div className="space-y-4 pt-2">
319323 <div className="flex flex-wrap items-center gap-3">
320324 <Button size="default" variant="secondary" onClick={onBack}>
321 <ChevronLeft className="mr-1 h-3.5 w-3.5" /> 返回
325 <ChevronLeft className="mr-1 h-3.5 w-3.5" /> {t('common.action.back')}
322326 </Button>
323327 {detail ? (
324328 <>
@@ -333,27 +337,27 @@
333337 <div className="ml-auto flex items-center gap-2">
334338 {active ? (
335339 <Button size="default" variant="secondary" onClick={() => void action(() => cancelRun(space, repo, runId))}>
336 <XCircle className="mr-1 h-3.5 w-3.5" /> 取消
340 <XCircle className="mr-1 h-3.5 w-3.5" /> {t('common.action.cancel')}
337341 </Button>
338342 ) : null}
339343 {finished ? (
340344 <Button size="default" variant="secondary" onClick={() => void action(() => rerun(space, repo, runId))}>
341 <RotateCw className="mr-1 h-3.5 w-3.5" /> 重跑
345 <RotateCw className="mr-1 h-3.5 w-3.5" /> {t('repo.pipeline.rerun')}
342346 </Button>
343347 ) : null}
344348 <Button size="default" variant="secondary" onClick={() => void load()}>
345 <RefreshCw className="mr-1 h-3.5 w-3.5" /> 刷新
349 <RefreshCw className="mr-1 h-3.5 w-3.5" /> {t('common.action.refresh')}
346350 </Button>
347351 </div>
348352 </div>
349353
350354 {error ? <Alert tone="error">{error}</Alert> : null}
351 {!detail ? <PanelNote text="正在加载运行详情…" /> : null}
355 {!detail ? <PanelNote text={t('repo.pipeline.detailLoading')} /> : null}
352356
353357 {detail ? (
354358 <div className="grid gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
355359 <div className="space-y-2">
356 <h4 className="text-[13px] font-semibold text-shell-ink-muted">作业</h4>
360 <h4 className="text-[13px] font-semibold text-shell-ink-muted">{t('repo.pipeline.jobs')}</h4>
357361 <ul className="space-y-1.5">
358362 {detail.jobs.map((job) => (
359363 <li key={job.id}>
@@ -381,7 +385,7 @@
381385
382386 {detail.artifacts.length > 0 ? (
383387 <>
384 <h4 className="pt-2 text-[13px] font-semibold text-shell-ink-muted">产物</h4>
388 <h4 className="pt-2 text-[13px] font-semibold text-shell-ink-muted">{t('repo.pipeline.artifacts')}</h4>
385389 <ul className="space-y-1.5">
386390 {detail.artifacts.map((artifact) => (
387391 <li
@@ -401,7 +405,7 @@
401405 )
402406 }
403407 >
404 <Download className="h-3.5 w-3.5" /> 下载
408 <Download className="h-3.5 w-3.5" /> {t('common.action.download')}
405409 </button>
406410 ) : (
407411 <span className="text-shell-ink-muted">{STATE_LABEL[artifact.state] ?? artifact.state}</span>
@@ -437,6 +441,7 @@
437441 runId: string
438442 job: RunJob
439443}) {
444 const { t } = useT()
440445 const [log, setLog] = useState('')
441446 const seqRef = useRef(0)
442447 const completeRef = useRef(false)
@@ -471,7 +476,7 @@
471476 <h4 className="text-[14px] font-semibold">{job.name}</h4>
472477 <StateBadge state={job.state} />
473478 {job.attempt > 1 ? (
474 <span className="text-[12px] text-shell-ink-muted">第 {job.attempt} 次尝试</span>
479 <span className="text-[12px] text-shell-ink-muted">{t('repo.pipeline.attempt', { count: job.attempt })}</span>
475480 ) : null}
476481 {job.error ? <span className="text-[12px] text-red-400">{job.error}</span> : null}
477482 </div>
@@ -490,9 +495,9 @@
490495 </ol>
491496
492497 <div>
493 <h5 className="mb-1.5 text-[13px] font-semibold text-shell-ink-muted">日志</h5>
498 <h5 className="mb-1.5 text-[13px] font-semibold text-shell-ink-muted">{t('repo.pipeline.logs')}</h5>
494499 <pre className="max-h-[420px] overflow-auto rounded-xl border border-shell-hairline bg-black/40 px-3 py-3 text-[12px] leading-5 text-shell-ink">
495 {log || '(暂无日志输出)'}
500 {log || t('repo.pipeline.noLogs')}
496501 </pre>
497502 </div>
498503 </div>
Modified web/src/features/repo-releases/ui/releases-tab.tsx View web/src/features/repo-releases/ui/releases-tab.tsx
@@ -1,6 +1,7 @@
11import { useMemo, useState } from 'react'
22import { Loader2, Pencil, Plus, Sparkles, Tag, Trash2 } from 'lucide-react'
33import { Alert, Button, ErrorPanel, Markdown, Modal, PanelNote, modalInputClass, modalLabelClass, modalTextareaClass } from '@/shared/ui'
4import { useT } from '@/shared/i18n'
45import { fmtRelative, useAsync } from '@/shared/lib'
56import {
67 createRelease,
@@ -25,6 +26,7 @@
2526
2627/** 发布页签:草稿与已发布 release 的列表 / 详情 / AI 起草。 */
2728export function ReleasesTab({ space, repo, selected, onSelect, canWrite }: ReleasesTabProps) {
29 const { t } = useT()
2830 const [tick, setTick] = useState(0)
2931 const [busy, setBusy] = useState(false)
3032 const [error, setError] = useState<string | null>(null)
@@ -47,14 +49,14 @@
4749 setNotice(ok)
4850 setTick(t => t + 1)
4951 } catch (f) {
50 setError(f instanceof Error ? f.message : '操作失败')
52 setError(f instanceof Error ? f.message : t('repo.error.actionFailedShort'))
5153 } finally {
5254 setBusy(false)
5355 }
5456 }
5557
56 if (list.loading && !list.data) return <PanelNote text="加载发布记录…" />
57 if (failure) return <ErrorPanel status={failure.code ?? 0} what="发布记录" note={failure.message} onRetry={() => setTick(t => t + 1)} />
58 if (list.loading && !list.data) return <PanelNote text={t('repo.release.loading')} />
59 if (failure) return <ErrorPanel status={failure.code ?? 0} what={t('repo.release.what')} note={failure.message} onRetry={() => setTick(n => n + 1)} />
5860
5961 return (
6062 <div className="space-y-3">
@@ -62,12 +64,12 @@
6264 {canWrite ? (
6365 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => setCreating(true)}>
6466 <Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
65 新建发布
67 {t('repo.release.create')}
6668 </Button>
6769 ) : null}
6870 {selected ? (
6971 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={() => onSelect('')}>
70 返回列表
72 {t('repo.release.backToList')}
7173 </Button>
7274 ) : null}
7375 </div>
@@ -81,23 +83,23 @@
8183 release={detail.data}
8284 canWrite={canWrite}
8385 busy={busy}
84 onDraft={() => void run(() => redraftRelease(space, repo, selected), '已提交 AI 起草任务;完成后刷新查看。')}
86 onDraft={() => void run(() => redraftRelease(space, repo, selected), t('repo.release.redraftQueued'))}
8587 onSave={(title, content) =>
86 void run(() => updateRelease(space, repo, selected, { title, content }), '已保存草稿。')
88 void run(() => updateRelease(space, repo, selected, { title, content }), t('repo.release.savedDraft'))
8789 }
8890 onPublish={() =>
89 void run(() => updateRelease(space, repo, selected, { is_draft: false }), '已发布。')
91 void run(() => updateRelease(space, repo, selected, { is_draft: false }), t('repo.release.publishedNow'))
9092 }
9193 onTogglePrerelease={next =>
92 void run(() => updateRelease(space, repo, selected, { is_prerelease: next }), '已更新预发布标记。')
94 void run(() => updateRelease(space, repo, selected, { is_prerelease: next }), t('repo.release.prereleaseUpdated'))
9395 }
9496 onDelete={() => void run(async () => {
9597 await deleteRelease(space, repo, selected)
9698 onSelect('')
97 }, '已删除发布记录。')}
99 }, t('repo.release.deletedNow'))}
98100 />
99101 ) : (
100 <PanelNote text="发布记录不存在。" />
102 <PanelNote text={t('repo.release.notFound')} />
101103 )
102104 ) : (
103105 <ReleaseList releases={list.data?.items ?? []} onSelect={onSelect} />
@@ -115,7 +117,7 @@
115117 await createRelease(space, repo, { tag_name: tag, title, is_draft: draft })
116118 setCreating(false)
117119 onSelect(tag)
118 }, '已创建发布记录。')
120 }, t('repo.release.created'))
119121 }
120122 />
121123 ) : null}
@@ -124,8 +126,9 @@
124126}
125127
126128function ReleaseList({ releases, onSelect }: { releases: Release[]; onSelect: (tag: string) => void }) {
129 const { t, tc } = useT()
127130 if (!releases.length) {
128 return <PanelNote text="还没有发布记录。推送标签会自动创建草稿,也可以手动新建。" />
131 return <PanelNote text={t('repo.release.none')} />
129132 }
130133 return (
131134 <ul className="space-y-2">
@@ -139,10 +142,10 @@
139142 <Tag className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
140143 <span className="font-medium">{release.title || release.tag_name}</span>
141144 <span className="font-mono text-[12px] text-shell-ink-muted">{release.tag_name}</span>
142 {release.is_draft ? <Badge tone="amber">草稿</Badge> : <Badge tone="green">已发布</Badge>}
143 {release.is_prerelease ? <Badge tone="violet">预发布</Badge> : null}
145 {release.is_draft ? <Badge tone="amber">{t('repo.release.draft')}</Badge> : <Badge tone="green">{t('repo.release.publishedBadge')}</Badge>}
146 {release.is_prerelease ? <Badge tone="violet">{t('repo.release.prerelease')}</Badge> : null}
144147 <span className="ml-auto text-[11.5px] text-shell-ink-muted">
145 {release.num_commits} 个提交 · {fmtRelative(release.created_at)}
148 {tc('repo.release.commitCount', release.num_commits)} · {fmtRelative(release.created_at)}
146149 </span>
147150 </button>
148151 </li>
@@ -170,6 +173,7 @@
170173 onTogglePrerelease: (next: boolean) => void
171174 onDelete: () => void
172175}) {
176 const { t } = useT()
173177 const [title, setTitle] = useState(release.title)
174178 const [content, setContent] = useState(release.content ?? '')
175179 const editable = canWrite && release.is_draft
@@ -179,36 +183,36 @@
179183 <header className="flex flex-wrap items-center gap-2">
180184 <Tag className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
181185 <span className="font-mono text-[13px]">{release.tag_name}</span>
182 {release.is_draft ? <Badge tone="amber">草稿</Badge> : <Badge tone="green">已发布</Badge>}
183 {release.is_prerelease ? <Badge tone="violet">预发布</Badge> : null}
186 {release.is_draft ? <Badge tone="amber">{t('repo.release.draft')}</Badge> : <Badge tone="green">{t('repo.release.publishedBadge')}</Badge>}
187 {release.is_prerelease ? <Badge tone="violet">{t('repo.release.prerelease')}</Badge> : null}
184188 <span className="text-[11.5px] text-shell-ink-muted">
185 {release.publisher_name || release.publisher?.display_name || '系统'} · {fmtRelative(release.updated_at)}
189 {release.publisher_name || release.publisher?.display_name || t('repo.release.system')} · {fmtRelative(release.updated_at)}
186190 </span>
187191 </header>
188192
189193 {editable ? (
190194 <>
191195 <label className={modalLabelClass}>
192 标题
196 {t('repo.release.titleField')}
193197 <input value={title} onChange={event => setTitle(event.target.value)} className={modalInputClass} />
194198 </label>
195199 <label className={modalLabelClass}>
196 正文
200 {t('repo.release.body')}
197201 <textarea value={content} onChange={event => setContent(event.target.value)} rows={12} className={modalTextareaClass} />
198202 </label>
199203 <div className="flex flex-wrap items-center gap-2">
200 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => onSave(title, content)}>保存草稿</Button>
204 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => onSave(title, content)}>{t('repo.release.saveDraft')}</Button>
201205 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={onDraft}>
202206 <Sparkles className="mr-1.5 h-3.5 w-3.5" aria-hidden />
203 AI 重新起草
207 {t('repo.release.redraft')}
204208 </Button>
205 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={onPublish}>发布</Button>
209 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={onPublish}>{t('repo.release.publish')}</Button>
206210 <Button variant="ghost" className="h-8 text-[12px]" disabled={busy} onClick={() => onTogglePrerelease(!release.is_prerelease)}>
207 {release.is_prerelease ? '取消预发布' : '标记预发布'}
211 {release.is_prerelease ? t('repo.release.unmarkPrerelease') : t('repo.release.markPrerelease')}
208212 </Button>
209213 <Button variant="ghost" className="ml-auto h-8 text-[12px] text-[#b42318]" disabled={busy} onClick={onDelete}>
210214 <Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
211 删除
215 {t('common.action.delete')}
212216 </Button>
213217 </div>
214218 </>
@@ -218,10 +222,10 @@
218222 {release.content ? (
219223 <Markdown>{release.content}</Markdown>
220224 ) : (
221 <p className="text-[13px] text-shell-ink-muted">(没有正文)</p>
225 <p className="text-[13px] text-shell-ink-muted">{t('repo.release.noBody')}</p>
222226 )}
223227 {canWrite ? (
224 <p className="text-[12px] text-shell-ink-muted">已发布的发布记录正文不可再修改。</p>
228 <p className="text-[12px] text-shell-ink-muted">{t('repo.release.publishedImmutable')}</p>
225229 ) : null}
226230 </>
227231 )}
@@ -245,6 +249,7 @@
245249 onClose: () => void
246250 onSubmit: (tag: string, title: string, draft: boolean) => void
247251}) {
252 const { t } = useT()
248253 const [tag, setTag] = useState('')
249254 const [title, setTitle] = useState('')
250255 const [draft, setDraft] = useState(true)
@@ -258,16 +263,16 @@
258263 )
259264 const empty = Boolean(tags.data) && rows.length === 0
260265 return (
261 <Modal open title="新建发布记录" onClose={onClose}>
266 <Modal open title={t('repo.release.new')} onClose={onClose}>
262267 <div className="space-y-3">
263 <Alert tone="info">选择一个已存在的标签,发布记录会指向该标签对应的提交。</Alert>
268 <Alert tone="info">{t('repo.release.pickTagNote')}</Alert>
264269 <label className={modalLabelClass}>
265 标签
270 {t('repo.tag.name')}
266271 {tags.loading && !tags.data ? (
267 <span className="mt-1 block text-[12.5px] text-shell-ink-muted">加载标签…</span>
272 <span className="mt-1 block text-[12.5px] text-shell-ink-muted">{t('repo.tags.loading')}</span>
268273 ) : empty ? (
269274 <span className="mt-1 block text-[12.5px] text-shell-ink-muted">
270 没有可用的标签。请先在「分支与标签」中创建标签,或推送一个新标签。
275 {t('repo.release.noTags')}
271276 </span>
272277 ) : (
273278 <select
@@ -279,32 +284,32 @@
279284 }}
280285 className={modalInputClass}
281286 >
282 <option value="">请选择标签…</option>
287 <option value="">{t('repo.release.selectTag')}</option>
283288 {rows.map(item => (
284289 <option key={item.name} value={item.name}>
285290 {item.name}
286 {item.created_at ? `(${fmtRelative(item.created_at)})` : ''}
291 {item.created_at ? t('repo.release.tagAge', { age: fmtRelative(item.created_at) }) : ''}
287292 </option>
288293 ))}
289294 </select>
290295 )}
291296 </label>
292297 {tags.error ? (
293 <p className="text-[12.5px] text-[#b42318]" role="alert">标签列表加载失败,请稍后重试。</p>
298 <p className="text-[12.5px] text-[#b42318]" role="alert">{t('repo.release.tagsFailed')}</p>
294299 ) : null}
295300 <label className={modalLabelClass}>
296 标题(可选)
301 {t('repo.release.titleOptional')}
297302 <input value={title} onChange={event => setTitle(event.target.value)} className={modalInputClass} />
298303 </label>
299304 <label className="flex items-center gap-2 text-[13px]">
300305 <input type="checkbox" checked={draft} onChange={event => setDraft(event.target.checked)} />
301 先保存为草稿
306 {t('repo.release.saveAsDraft')}
302307 </label>
303308 <div className="flex justify-end gap-2">
304 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={onClose}>取消</Button>
309 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={onClose}>{t('common.action.cancel')}</Button>
305310 <Button className="h-8 text-[12.5px]" disabled={busy || !tag} onClick={() => onSubmit(tag, title.trim() || tag, draft)}>
306311 {busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Pencil className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />}
307 创建
312 {t('common.action.create')}
308313 </Button>
309314 </div>
310315 </div>
Modified web/src/features/repo-settings/model/lfs.ts View web/src/features/repo-settings/model/lfs.ts
@@ -13,6 +13,7 @@
1313 * be explained from the code alone — otherwise a rejected write looks identical
1414 * to a dead button.
1515 */
16import { t } from '@/shared/i18n'
1617import { ApiFailure } from '@/shared/lib'
1718
1819/** `MAX_LOCK_PATH_LEN` in `lib/git/src/lfs/validate.rs`. */
@@ -31,11 +32,11 @@
3132/** Mirrors `validate_lock_path`; returns the problem in Chinese, or null. */
3233export function lockPathProblem(raw: string): string | null {
3334 const path = normalizeLockPath(raw)
34 if (!path) return '需要先指定要锁定的文件路径(相对仓库根目录)。'
35 if (path.includes('\0')) return '路径中不能包含空字符。'
36 if (path.length > MAX_LOCK_PATH_LEN) return `路径最长 ${MAX_LOCK_PATH_LEN} 个字符。`
37 if (path.endsWith('/')) return '锁针对单个文件,路径不应以 / 结尾。'
38 if (path.split('/').includes('..')) return '路径不能包含 .. 目录跳转。'
35 if (!path) return t('repo.locks.validate.required')
36 if (path.includes('\0')) return t('repo.locks.validate.nul')
37 if (path.length > MAX_LOCK_PATH_LEN) return t('repo.locks.validate.tooLong', { count: MAX_LOCK_PATH_LEN })
38 if (path.endsWith('/')) return t('repo.locks.validate.trailingSlash')
39 if (path.split('/').includes('..')) return t('repo.locks.validate.dotdot')
3940 return null
4041}
4142
@@ -54,19 +55,19 @@
5455/** Server-side `GitError::InvalidArgument` texts worth phrasing in Chinese. */
5556function explainBadRequest(detail: string): string | null {
5657 const text = detail.toLowerCase()
57 if (text.includes('must not be empty')) return '路径不能为空。'
58 if (text.includes('exceeds')) return `路径过长:${detail}。`
59 if (text.includes('nul')) return '路径中不能包含空字符。'
60 if (text.includes('invalid oid')) return 'oid 需为 `sha256:<64 位十六进制>`。'
58 if (text.includes('must not be empty')) return t('repo.locks.validate.empty')
59 if (text.includes('exceeds')) return t('repo.locks.validate.tooLongDetail', { detail })
60 if (text.includes('nul')) return t('repo.locks.validate.nul')
61 if (text.includes('invalid oid')) return t('repo.locks.validate.badOid')
6162 return null
6263}
6364
6465const STATUS_EXPLANATION: Record<number, string> = {
65 401: '登录状态已失效,请重新登录后再试。',
66 403: '当前账号没有该仓库的写入权限(需要 Write/Admin 或空间所有者)。',
67 404: '仓库不存在,或未启用 Git LFS。启用后才允许加锁。',
68 409: '该路径已存在锁,或与其他锁冲突。',
69 500: '后端处理失败,请稍后重试或换一条路径。',
66 401: t('repo.locks.explanation.401'),
67 403: t('repo.locks.explanation.403'),
68 404: t('repo.locks.explanation.404'),
69 409: t('repo.locks.explanation.409'),
70 500: t('repo.locks.explanation.500'),
7071}
7172
7273/** Turn any thrown value into a sentence that says *why* the write failed. */
@@ -76,7 +77,7 @@
7677 const detail = err.message && err.message !== 'request failed' ? err.message : ''
7778 const translated = detail ? explainBadRequest(detail) : null
7879 if (translated) return translated
79 return detail ? `服务端拒绝了该路径:${detail}` : '提交的内容不符合服务端校验。'
80 return detail ? t('repo.locks.rejected', { detail }) : t('repo.locks.invalid')
8081 }
8182 const explanation = STATUS_EXPLANATION[err.code]
8283 const detail = err.message && err.message !== 'request failed' ? err.message : ''
@@ -84,9 +85,9 @@
8485 // fixed English constant, so repeat it only when it carries real detail —
8586 // which `GitApiError::BadRequest`/`Conflict(msg)` do.
8687 const informative = detail && !GENERIC_ENVELOPE_MESSAGES.has(detail.toLowerCase()) ? detail : ''
87 if (explanation && informative) return `${explanation}(${informative})`
88 return explanation ?? informative ?? `操作失败(${err.code})。`
88 if (explanation && informative) return t('repo.locks.explainedWithDetail', { explanation, detail: informative })
89 return explanation ?? informative ?? t('repo.locks.failedWithCode', { code: err.code })
8990 }
9091 const text = (err as Error | undefined)?.message
91 return text && text !== 'request failed' ? text : '操作失败,请稍后重试。'
92 return text && text !== 'request failed' ? text : t('repo.error.actionFailed')
9293}
Modified web/src/features/repo-settings/model/sections.ts View web/src/features/repo-settings/model/sections.ts
@@ -6,6 +6,8 @@
66 * model instead of the component: the page reads and validates them, the
77 * settings UI renders them, and neither may drift from the other.
88 */
9import { t } from '@/shared/i18n'
10
911export type RepoSettingsSection =
1012 | 'general'
1113 | 'topics'
@@ -18,14 +20,14 @@
1820 | 'ci'
1921
2022export const REPO_SETTINGS_SECTIONS: ReadonlyArray<{ id: RepoSettingsSection; label: string }> = [
21 { id: 'general', label: '常规' },
22 { id: 'topics', label: '主题' },
23 { id: 'remotes', label: '远程' },
24 { id: 'import', label: '导入' },
25 { id: 'archives', label: '归档与下载' },
26 { id: 'insights', label: '分析与维护' },
27 { id: 'locks', label: 'LFS 锁' },
28 { id: 'protection', label: '保护规则' },
29 { id: 'ci', label: 'CI/CD' },
23 { id: 'general', get label() { return t('repo.settings.nav.general') } },
24 { id: 'topics', get label() { return t('repo.settings.nav.topics') } },
25 { id: 'remotes', get label() { return t('repo.settings.nav.remotes') } },
26 { id: 'import', get label() { return t('repo.settings.nav.import') } },
27 { id: 'archives', get label() { return t('repo.settings.nav.archives') } },
28 { id: 'insights', get label() { return t('repo.settings.nav.insights') } },
29 { id: 'locks', get label() { return t('repo.settings.nav.locks') } },
30 { id: 'protection', get label() { return t('repo.settings.nav.protection') } },
31 { id: 'ci', get label() { return t('repo.settings.nav.ci') } },
3032]
3133
Modified web/src/features/repo-settings/ui/sections/archives-settings.tsx View web/src/features/repo-settings/ui/sections/archives-settings.tsx
@@ -1,6 +1,7 @@
11import { useState } from 'react'
22import { Button } from '@/shared/ui'
33import { formatBytes } from '@/shared/lib'
4import { t, useT } from '@/shared/i18n'
45import {
56 archiveRepo,
67 createArchive,
@@ -12,11 +13,11 @@
1213import { useAction } from '../use-action'
1314import { Fact, Field, Notice, SectionHead, inputCls } from '../parts'
1415
15const FORMATS: Array<{ value: ArchiveFormat; label: string }> = [
16 { value: 'zip', label: 'zip' },
17 { value: 'tar_gz', label: 'tar.gz' },
18 { value: 'tar_bz2', label: 'tar.bz2' },
19 { value: 'tar', label: 'tar(不压缩)' },
16const FORMATS: Array<{ value: ArchiveFormat; get label(): string }> = [
17 { value: 'zip', get label() { return 'zip' } },
18 { value: 'tar_gz', get label() { return 'tar.gz' } },
19 { value: 'tar_bz2', get label() { return 'tar.bz2' } },
20 { value: 'tar', get label() { return t('repo.archives.formatTar') } },
2021]
2122
2223/**
@@ -35,6 +36,7 @@
3536 repoDto: { is_archived: boolean; default_branch: string; archived_at?: string | null }
3637 onChanged: () => void
3738}) {
39 const { t } = useT()
3840 const { busy, notice, error, run } = useAction()
3941 const [confirmArchive, setConfirmArchive] = useState(false)
4042 const [revision, setRevision] = useState(repoDto.default_branch)
@@ -46,49 +48,49 @@
4648 setConfirmArchive(false)
4749 const ok = await run(
4850 () => (next ? archiveRepo(space, repo) : unarchiveRepo(space, repo)),
49 next ? '仓库已归档,写入会被拒绝。' : '仓库已恢复为可写。',
51 next ? t('repo.archives.archivedNote') : t('repo.archives.restored'),
5052 )
5153 if (ok) onChanged()
5254 }
5355
5456 return (
5557 <div className="max-w-[720px]">
56 <SectionHead title="归档与下载" sub="归档状态、按需生成快照归档、全引用 bundle 打包。" />
58 <SectionHead title={t('repo.settings.nav.archives')} sub={t('repo.archives.sub')} />
5759 <Notice notice={notice} error={error} />
5860
5961 <div className="mb-8 rounded-lg border-2 border-shell-hairline bg-shell-field p-4">
6062 <h4 className="mb-1 text-[13.5px] font-bold">
61 归档状态:{repoDto.is_archived ? '已归档(只读)' : '正常(可写)'}
63 {t('repo.archives.status', { state: repoDto.is_archived ? t('repo.archives.statusArchived') : t('repo.archives.statusWritable') })}
6264 </h4>
6365 <p className="mb-3 text-[12.5px] text-shell-ink-muted">
64 归档后仓库不再是常规写入目标,推送会被拒绝;随时可以恢复。
66 {t('repo.archives.archiveNote')}
6567 </p>
6668 {repoDto.is_archived ? (
6769 <Button variant="secondary" disabled={busy} onClick={() => void toggleArchived(false)}>
68 恢复仓库
70 {t('repo.archives.restore')}
6971 </Button>
7072 ) : confirmArchive ? (
7173 <div className="flex gap-2">
7274 <Button className="bg-[#f23f43] hover:brightness-95" disabled={busy} onClick={() => void toggleArchived(true)}>
73 确认归档
75 {t('repo.archives.confirmArchive')}
7476 </Button>
7577 <Button variant="secondary" onClick={() => setConfirmArchive(false)}>
76 取消
78 {t('common.action.cancel')}
7779 </Button>
7880 </div>
7981 ) : (
8082 <Button variant="secondary" disabled={busy} onClick={() => setConfirmArchive(true)}>
81 归档仓库
83 {t('repo.archives.archive')}
8284 </Button>
8385 )}
8486 </div>
8587
86 <h4 className="mb-3 text-[13px] font-bold">生成快照归档</h4>
88 <h4 className="mb-3 text-[13px] font-bold">{t('repo.archives.snapshotTitle')}</h4>
8789 <div className="grid gap-3 sm:grid-cols-2">
88 <Field label="修订">
90 <Field label={t('repo.archives.revision')}>
8991 <input className={inputCls} value={revision} onChange={e => setRevision(e.target.value)} placeholder="main / v1.0 / sha" />
9092 </Field>
91 <Field label="格式">
93 <Field label={t('repo.archives.format')}>
9294 <select className={inputCls} value={format} onChange={e => setFormat(e.target.value as ArchiveFormat)}>
9395 {FORMATS.map(item => (
9496 <option key={item.value} value={item.value}>
@@ -98,7 +100,7 @@
98100 </select>
99101 </Field>
100102 </div>
101 <Field label="路径前缀(可选)" hint="只打包该目录下的内容,例如 app/。">
103 <Field label={t('repo.archives.pathPrefix')} hint={t('repo.archives.pathPrefixHint')}>
102104 <input className={inputCls} value={pathFilter} onChange={e => setPathFilter(e.target.value)} />
103105 </Field>
104106 <div className="mb-4 flex flex-wrap gap-2">
@@ -116,15 +118,15 @@
116118 ? { url: result.download_url, size: result.size, label: `${revision.trim()} (${format})` }
117119 : null,
118120 )
119 }, '归档已生成。')
121 }, t('repo.archives.created'))
120122 }
121123 >
122 生成并取下载地址
124 {t('repo.archives.createAndLink')}
123125 </Button>
124126 <Button
125127 variant="secondary"
126128 disabled={busy || !revision.trim()}
127 title="不重新打包,只为已存在的归档换一个新的短期链接;该修订尚未生成时会失败,此时请直接点「生成并取下载地址」"
129 title={t('repo.archives.reuseHint')}
128130 onClick={() =>
129131 void run(async () => {
130132 const result = await queryArchive(space, repo, revision.trim(), format, pathFilter.trim() || undefined)
@@ -133,10 +135,10 @@
133135 ? { url: result.download_url, size: result.size, label: `${revision.trim()} (${format})` }
134136 : null,
135137 )
136 }, '已换取新的下载链接。')
138 }, t('repo.archives.linkRefreshed'))
137139 }
138140 >
139 复用已有归档
141 {t('repo.archives.reuse')}
140142 </Button>
141143 <Button
142144 variant="secondary"
@@ -144,28 +146,28 @@
144146 onClick={() =>
145147 void run(async () => {
146148 const result = await createBundle(space, repo)
147 setLink(result.download_url ? { url: result.download_url, size: result.size, label: 'git bundle(全部引用)' } : null)
148 }, 'bundle 已打包。')
149 setLink(result.download_url ? { url: result.download_url, size: result.size, label: t('repo.archives.bundleLabel') } : null)
150 }, t('repo.archives.bundleCreated'))
149151 }
150152 >
151 打包全部引用为 bundle
153 {t('repo.archives.bundle')}
152154 </Button>
153155 </div>
154156 {link ? (
155157 <div className="mb-6 rounded-lg border-2 border-[#f0b232]/50 bg-shell-field p-3">
156158 <dl className="mb-2 space-y-1 text-[12.5px] text-shell-ink-muted">
157 <Fact label="内容">{link.label}</Fact>
158 <Fact label="大小">{formatBytes(link.size)}</Fact>
159 <Fact label={t('repo.archives.factContent')}>{link.label}</Fact>
160 <Fact label={t('repo.archives.factSize')}>{formatBytes(link.size)}</Fact>
159161 </dl>
160162 <button
161163 type="button"
162164 onClick={() => window.open(link.url, '_blank', 'noopener,noreferrer')}
163165 className="block max-w-full break-all text-left font-mono text-[12px] text-shell-ink underline decoration-shell-hairline underline-offset-2"
164 title="在新标签页下载"
166 title={t('repo.archives.openDownload')}
165167 >
166168 {link.url}
167169 </button>
168 <p className="mt-1.5 text-[11.5px] text-shell-ink-muted">链接为短期有效,失效后点「复用已有归档」重新获取。</p>
170 <p className="mt-1.5 text-[11.5px] text-shell-ink-muted">{t('repo.archives.linkNote')}</p>
169171 </div>
170172 ) : null}
171173 </div>
Modified web/src/features/repo-settings/ui/sections/ci-settings.tsx View web/src/features/repo-settings/ui/sections/ci-settings.tsx
@@ -1,7 +1,8 @@
11import { useCallback, useState } from 'react'
22import { Trash2 } from 'lucide-react'
33import { Alert, Button, PanelNote } from '@/shared/ui'
4import { useAsync } from '@/shared/lib'
4import { useT } from '@/shared/i18n'
5import { fmtTime, useAsync } from '@/shared/lib'
56import {
67 deleteRepoSecret,
78 fetchRepoSecrets,
@@ -16,6 +17,7 @@
1617 * secrets. Secret values are write-only — the API never returns them.
1718 */
1819export function CiSettings({ space, repo, rev }: { space: string; repo: string; rev: string }) {
20 const { t } = useT()
1921 const [tick, setTick] = useState(0)
2022 const [name, setName] = useState('')
2123 const [value, setValue] = useState('')
@@ -38,10 +40,10 @@
3840 await upsertRepoSecret(space, repo, name.trim(), value)
3941 setName('')
4042 setValue('')
41 setNotice('secret 已保存')
42 setTick(t => t + 1)
43 setNotice(t('repo.ci.secretSaved'))
44 setTick(n => n + 1)
4345 } catch (err) {
44 setError(err instanceof Error ? err.message : '保存失败')
46 setError(err instanceof Error ? err.message : t('repo.error.saveFailed'))
4547 }
4648 }
4749
@@ -50,10 +52,10 @@
5052 setError(null)
5153 try {
5254 await deleteRepoSecret(space, repo, target)
53 setNotice('secret 已删除')
54 setTick(t => t + 1)
55 setNotice(t('repo.ci.secretDeleted'))
56 setTick(n => n + 1)
5557 } catch (err) {
56 setError(err instanceof Error ? err.message : '删除失败')
58 setError(err instanceof Error ? err.message : t('repo.ci.deleteFailed'))
5759 }
5860 }
5961
@@ -62,20 +64,20 @@
6264 return (
6365 <div>
6466 <SectionHead
65 title="CI/CD"
66 sub="工作流定义来自默认分支(或当前修订)的 .workflows/ 目录;runner 通过调度中心领取作业。"
67 title={t('repo.settings.nav.ci')}
68 sub={t('repo.ci.sub')}
6769 />
6870 <Notice notice={notice} error={error} />
6971
70 <h4 className="mb-3 text-[14px] font-semibold">工作流</h4>
72 <h4 className="mb-3 text-[14px] font-semibold">{t('repo.ci.workflows')}</h4>
7173 {workflows.error ? (
7274 <Alert tone="error">
73 {workflows.error instanceof Error ? workflows.error.message : '工作流加载失败'}
75 {workflows.error instanceof Error ? workflows.error.message : t('repo.pipeline.workflowsFailed')}
7476 </Alert>
7577 ) : null}
76 {workflows.loading && !list ? <PanelNote text="正在读取 .workflows/…" /> : null}
78 {workflows.loading && !list ? <PanelNote text={t('repo.ci.workflowsLoading')} /> : null}
7779 {list && list.workflows.length === 0 ? (
78 <PanelNote text="该修订下没有 .workflows/*.yml 定义。" />
80 <PanelNote text={t('repo.ci.workflowsEmpty')} />
7981 ) : null}
8082 {list && list.workflows.length > 0 ? (
8183 <ul className="mb-6 space-y-2">
@@ -88,7 +90,7 @@
8890 item.valid ? 'bg-emerald-500/12 text-emerald-500' : 'bg-red-500/12 text-red-400'
8991 }`}
9092 >
91 {item.valid ? '有效' : '解析失败'}
93 {item.valid ? t('repo.ci.valid') : t('repo.ci.invalid')}
9294 </span>
9395 {!item.valid && item.errors.length > 0 ? (
9496 <span className="w-full text-[12px] text-red-400">{item.errors.join('; ')}</span>
@@ -101,17 +103,17 @@
101103 {item.triggers.pull_request_branches.length > 0
102104 ? `PR: ${item.triggers.pull_request_branches.join(', ')} `
103105 : ''}
104 {item.triggers.workflow_dispatch ? '手动触发可用' : ''}
106 {item.triggers.workflow_dispatch ? t('repo.ci.dispatchable') : ''}
105107 </span>
106108 </ListRow>
107109 ))}
108110 </ul>
109111 ) : null}
110112
111 <h4 className="mb-3 text-[14px] font-semibold">仓库 secrets</h4>
113 <h4 className="mb-3 text-[14px] font-semibold">{t('repo.ci.secretsTitle')}</h4>
112114 {secrets.error ? (
113115 <Alert tone="error">
114 {secrets.error instanceof Error ? secrets.error.message : 'secret 加载失败'}
116 {secrets.error instanceof Error ? secrets.error.message : t('repo.ci.secretsFailed')}
115117 </Alert>
116118 ) : null}
117119 {secrets.data ? (
@@ -120,7 +122,7 @@
120122 <ListRow key={item.id}>
121123 <span className="text-[13.5px] font-medium">{item.name}</span>
122124 <span className="text-[12px] text-shell-ink-muted">
123 更新于 {new Date(item.updated_at).toLocaleString()}
125 {t('repo.ci.updatedAt', { time: fmtTime(item.updated_at) })}
124126 </span>
125127 <Button
126128 variant="secondary"
@@ -132,30 +134,30 @@
132134 </ListRow>
133135 ))}
134136 {secrets.data.secrets.length === 0 ? (
135 <li className="text-[12.5px] text-shell-ink-muted">尚未配置 secret。</li>
137 <li className="text-[12.5px] text-shell-ink-muted">{t('repo.ci.secretsEmpty')}</li>
136138 ) : null}
137139 </ul>
138140 ) : null}
139141
140 <Field label="新增 / 覆盖 secret" hint="值只会写库(AES-GCM 加密),接口不会回显。">
142 <Field label={t('repo.ci.upsertSecret')} hint={t('repo.ci.upsertSecretHint')}>
141143 <input
142144 className={inputCls}
143145 value={name}
144146 onChange={event => setName(event.target.value)}
145 placeholder="名称(字母、数字、_、-)"
147 placeholder={t('repo.ci.secretNamePlaceholder')}
146148 />
147149 </Field>
148 <Field label="值">
150 <Field label={t('repo.ci.value')}>
149151 <input
150152 className={inputCls}
151153 type="password"
152154 value={value}
153155 onChange={event => setValue(event.target.value)}
154 placeholder="secret 值"
156 placeholder={t('repo.ci.secretValuePlaceholder')}
155157 />
156158 </Field>
157159 <Button disabled={!name.trim() || !value} onClick={() => void save()}>
158 保存 secret
160 {t('repo.ci.saveSecret')}
159161 </Button>
160162 </div>
161163 )
Modified web/src/features/repo-settings/ui/sections/general-settings.tsx View web/src/features/repo-settings/ui/sections/general-settings.tsx
@@ -1,5 +1,6 @@
11import { useState } from 'react'
22import { Button, Checkbox } from '@/shared/ui'
3import { useT } from '@/shared/i18n'
34import { formatBytes, fmtTime, repoKey, useAsync } from '@/shared/lib'
45import { listBranches, updateRepo, type RepoDetail } from '@/entities/repo'
56import { useAction } from '../use-action'
@@ -15,6 +16,7 @@
1516
1617/** `PATCH /repos/{s}/{r}` — description, homepage, visibility, LFS, default branch. */
1718export function GeneralSettings({ space, repo, repoDto, onChanged }: SettingsSectionProps) {
19 const { t } = useT()
1820 const branches = useAsync(() => listBranches(space, repo).catch(() => null), [space, repo], {
1921 cacheKey: repoKey(space, repo, 'refs:branches:100'),
2022 })
@@ -39,9 +41,9 @@
3941
4042 return (
4143 <div className="max-w-[640px]">
42 <SectionHead title="常规" sub="仓库描述、主页、可见性、Git LFS 与默认分支。仅空间所有者可修改。" />
44 <SectionHead title={t('repo.settings.nav.general')} sub={t('repo.settings.generalSub')} />
4345 <Notice notice={notice} error={error} />
44 <Field label="描述" hint={`${form.description.length} / 512`}>
46 <Field label={t('repo.settings.field.description')} hint={`${form.description.length} / 512`}>
4547 <textarea
4648 rows={2}
4749 maxLength={512}
@@ -50,7 +52,7 @@
5052 onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
5153 />
5254 </Field>
53 <Field label="网站" hint="对外主页,留空表示未设置。">
55 <Field label={t('repo.settings.field.website')} hint={t('repo.settings.field.websiteHint')}>
5456 <input
5557 className={inputCls}
5658 placeholder="https://example.com"
@@ -59,8 +61,8 @@
5961 />
6062 </Field>
6163 <Field
62 label="默认分支"
63 hint={branches.error ? '分支列表拉取失败,当前只显示已保存的默认分支。' : undefined}
64 label={t('repo.settings.defaultBranch')}
65 hint={branches.error ? t('repo.settings.field.defaultBranchLoadFailed') : undefined}
6466 >
6567 <select
6668 className={inputCls}
@@ -78,17 +80,17 @@
7880 <Checkbox
7981 checked={form.isPrivate}
8082 onChange={v => setForm(f => ({ ...f, isPrivate: v }))}
81 aria-label="私有仓库"
83 aria-label={t('repo.settings.field.privateLabel')}
8284 />
83 私有仓库(仅成员可读)
85 {t('repo.settings.field.private')}
8486 </label>
8587 <label className="mb-5 flex items-center gap-2.5 text-[13.5px]">
8688 <Checkbox
8789 checked={form.enableLfs}
8890 onChange={v => setForm(f => ({ ...f, enableLfs: v }))}
89 aria-label="启用 Git LFS"
91 aria-label={t('repo.settings.field.enableLfs')}
9092 />
91 启用 Git LFS
93 {t('repo.settings.field.enableLfs')}
9294 </label>
9395 <div className="mb-8 flex items-center gap-3">
9496 <Button
@@ -103,35 +105,35 @@
103105 enable_lfs: form.enableLfs,
104106 })
105107 onChanged()
106 }, '常规设置已保存。')
108 }, t('repo.settings.generalSaved'))
107109 }
108110 >
109 {busy ? '保存中…' : '保存设置'}
111 {busy ? t('repo.action.saving') : t('repo.settings.saveSettings')}
110112 </Button>
111 {dirty && !busy ? <span className="text-[12px] text-shell-ink-muted">有未保存的修改</span> : null}
113 {dirty && !busy ? <span className="text-[12px] text-shell-ink-muted">{t('repo.settings.unsaved')}</span> : null}
112114 </div>
113115
114 <h4 className="mb-2 text-[13px] font-bold">仓库信息(只读)</h4>
116 <h4 className="mb-2 text-[13px] font-bold">{t('repo.settings.readonlyInfo')}</h4>
115117 <dl className="space-y-1.5 text-[12.5px] text-shell-ink-muted">
116 <Fact label="仓库 ID">{repoDto.id}</Fact>
117 <Fact label="对象格式">{repoDto.object_format}</Fact>
118 <Fact label="创建时间">{fmtTime(repoDto.created_at)}</Fact>
119 <Fact label="最近更新">{fmtTime(repoDto.updated_at)}</Fact>
120 <Fact label="最近推送">{repoDto.last_pushed_at ? fmtTime(repoDto.last_pushed_at) : '从未'}</Fact>
121 <Fact label="Git / LFS 大小">
118 <Fact label={t('repo.settings.fact.id')}>{repoDto.id}</Fact>
119 <Fact label={t('repo.settings.fact.objectFormat')}>{repoDto.object_format}</Fact>
120 <Fact label={t('repo.settings.fact.createdAt')}>{fmtTime(repoDto.created_at)}</Fact>
121 <Fact label={t('repo.settings.fact.updatedAt')}>{fmtTime(repoDto.updated_at)}</Fact>
122 <Fact label={t('repo.settings.fact.lastPushed')}>{repoDto.last_pushed_at ? fmtTime(repoDto.last_pushed_at) : t('repo.settings.fact.never')}</Fact>
123 <Fact label={t('repo.settings.fact.sizes')}>
122124 {formatBytes(repoDto.git_size)} / {formatBytes(repoDto.lfs_size)}
123125 </Fact>
124 <Fact label="存储上限">
125 {repoDto.storage_limit_size ? formatBytes(repoDto.storage_limit_size) : '不限'}
126 <Fact label={t('repo.settings.fact.storageLimit')}>
127 {repoDto.storage_limit_size ? formatBytes(repoDto.storage_limit_size) : t('repo.settings.fact.unlimited')}
126128 </Fact>
127 <Fact label="分支 / 标签 / 提交">
129 <Fact label={t('repo.settings.fact.counts')}>
128130 {repoDto.branches_count} / {repoDto.tags_count} / {repoDto.commits_count}
129131 </Fact>
130 <Fact label="PR / 加星 / 关注">
132 <Fact label={t('repo.settings.fact.social')}>
131133 {repoDto.pulls_count} / {repoDto.stars_count} / {repoDto.watchers_count}
132134 </Fact>
133 <Fact label="许可证">{repoDto.license_name ?? '未识别'}</Fact>
134 <Fact label="主语言">{repoDto.primary_language ?? '未统计'}</Fact>
135 <Fact label={t('repo.settings.fact.license')}>{repoDto.license_name ?? t('repo.settings.fact.licenseUnknown')}</Fact>
136 <Fact label={t('repo.settings.fact.primaryLanguage')}>{repoDto.primary_language ?? t('repo.settings.fact.languageUnknown')}</Fact>
135137 </dl>
136138 </div>
137139 )
Modified web/src/features/repo-settings/ui/sections/import-settings.tsx View web/src/features/repo-settings/ui/sections/import-settings.tsx
@@ -1,41 +1,30 @@
11import { useEffect, useState } from 'react'
22import { Button } from '@/shared/ui'
3import { repoKey, useAsync } from '@/shared/lib'
3import { t } from '@/shared/i18n'
4import { formatBytes, repoKey, useAsync } from '@/shared/lib'
45import { cancelImport, listImports, retryImport, type ImportJob } from '@/entities/repo'
56import { useAction } from '../use-action'
67import { Field, ListRow, Notice, SectionHead, inputCls } from '../parts'
78
89const STATES: Record<string, string> = {
9 queued: '排队中',
10 processing: '进行中',
11 succeeded: '已完成',
12 failed: '失败',
13 canceled: '已取消',
10 get queued() { return t('label.runStatus.queued') },
11 get processing() { return t('repo.import.state.processing') },
12 get succeeded() { return t('repo.import.state.succeeded') },
13 get failed() { return t('label.runStatus.failed') },
14 get canceled() { return t('label.runStatus.cancelled') },
1415}
1516
1617const STAGES: Record<string, string> = {
17 probe: '解析源仓库',
18 clone: '镜像抓取引用',
19 sync: '同步分支与提交',
20 finalize: '统计与作者归属',
21 done: '完成',
18 get probe() { return t('repo.import.stage.probe') },
19 get clone() { return t('repo.import.stage.clone') },
20 get sync() { return t('repo.import.stage.sync') },
21 get finalize() { return t('repo.import.stage.finalize') },
22 get done() { return t('repo.import.stage.done') },
2223}
2324
2425const POLL_MS = 3000
2526
2627const isActive = (job: ImportJob) => job.state === 'queued' || job.state === 'processing'
27
28function formatBytes(bytes: number): string {
29 if (!bytes || bytes <= 0) return '—'
30 const units = ['B', 'KB', 'MB', 'GB', 'TB']
31 let value = bytes
32 let index = 0
33 while (value >= 1024 && index < units.length - 1) {
34 value /= 1024
35 index += 1
36 }
37 return `${index === 0 ? value : value.toFixed(1)} ${units[index]}`
38}
3928
4029/**
4130 * Import jobs for this repository: mirror-import state, stage, counts and the
@@ -63,12 +52,12 @@
6352 return (
6453 <div className="max-w-[720px]">
6554 <SectionHead
66 title="导入"
67 sub="从 GitHub / GitLab / Gitea 镜像导入本仓库的任务记录。新导入请在社区菜单的「导入仓库」中发起。"
55 title={t('repo.settings.nav.import')}
56 sub={t('repo.import.sub')}
6857 />
6958 <Notice notice={notice} error={error} />
7059 {rows.length === 0 && !jobs.loading ? (
71 <p className="mb-4 text-[13px] text-shell-ink-muted">本仓库没有导入任务。</p>
60 <p className="mb-4 text-[13px] text-shell-ink-muted">{t('repo.import.empty')}</p>
7261 ) : null}
7362 <ul className="mb-6 space-y-2">
7463 {rows.map(job => (
@@ -78,16 +67,15 @@
7867 <span>{STATES[job.state] ?? job.state}</span>
7968 <span className="font-normal text-shell-ink-muted">
8069 · {STAGES[job.stage] ?? job.stage} · {job.provider}
81 {job.attempts > 0 ? ` · 已尝试 ${job.attempts} 次` : ''}
70 {job.attempts > 0 ? t('repo.import.attempts', { count: job.attempts }) : ''}
8271 </span>
8372 </p>
8473 <p className="mt-1 truncate font-mono text-[12px] text-shell-ink-muted" title={job.clone_url}>
8574 {job.clone_url}
8675 </p>
8776 <p className="mt-1 text-[12px] text-shell-ink-muted">
88 分支 {job.stats.branches} · 标签 {job.stats.tags} · 提交 {job.stats.commits} · 体积{' '}
89 {formatBytes(job.stats.size_bytes)}
90 {job.stats.default_branch ? ` · 默认分支 ${job.stats.default_branch}` : ''}
77 {t('repo.import.stats', { branches: job.stats.branches, tags: job.stats.tags, commits: job.stats.commits, size: formatBytes(job.stats.size_bytes) })}
78 {job.stats.default_branch ? t('repo.import.statsDefault', { branch: job.stats.default_branch }) : ''}
9179 </p>
9280 {job.stats.warnings.length > 0 ? (
9381 <p className="mt-1 text-[12px] text-shell-ink-muted">{job.stats.warnings.join(' ')}</p>
@@ -108,12 +96,12 @@
10896 return
10997 }
11098 setPendingCancel(null)
111 void run(() => cancelImport(space, job.id), '已取消导入任务。').then(ok => {
99 void run(() => cancelImport(space, job.id), t('repo.import.canceled')).then(ok => {
112100 if (ok) refresh()
113101 })
114102 }}
115103 >
116 {pendingCancel === job.id ? '确认取消' : '取消'}
104 {pendingCancel === job.id ? t('repo.import.confirmCancel') : t('common.action.cancel')}
117105 </Button>
118106 ) : null}
119107 {job.state === 'failed' || job.state === 'canceled' ? (
@@ -124,7 +112,7 @@
124112 onClick={() =>
125113 void run(
126114 () => retryImport(space, job.id, token || undefined),
127 '已重新排队导入任务。',
115 t('repo.import.requeued'),
128116 ).then(ok => {
129117 if (ok) {
130118 setToken('')
@@ -133,19 +121,19 @@
133121 })
134122 }
135123 >
136 重试
124 {t('common.action.retry')}
137125 </Button>
138126 ) : null}
139127 </span>
140128 </ListRow>
141129 ))}
142130 </ul>
143 <Field label="重试令牌" hint="任务结束后服务端已清除令牌;私有仓库重试时需要重新提供。">
131 <Field label={t('repo.import.token')} hint={t('repo.import.tokenHint')}>
144132 <input
145133 className={`${inputCls} max-w-[320px]`}
146134 type="password"
147135 autoComplete="off"
148 placeholder="留空表示匿名重试"
136 placeholder={t('repo.import.tokenPlaceholder')}
149137 value={token}
150138 onChange={event => setToken(event.target.value)}
151139 />
Modified web/src/features/repo-settings/ui/sections/insights-settings.tsx View web/src/features/repo-settings/ui/sections/insights-settings.tsx
@@ -1,5 +1,6 @@
11import { useState } from 'react'
22import { Button } from '@/shared/ui'
3import { useT } from '@/shared/i18n'
34import { formatBytes, repoKey, useAsync } from '@/shared/lib'
45import { fetchCodeActivity, listContributors, readLanguageStats, rescanLanguages } from '@/entities/repo'
56import { useAction } from '../use-action'
@@ -11,6 +12,7 @@
1112 * The POST is the one that refreshes `primary_language` everywhere else.
1213 */
1314export function InsightsSettings({ space, repo, rev }: { space: string; repo: string; rev: string }) {
15 const { t, tc } = useT()
1416 const [tick, setTick] = useState(0)
1517 const { busy, notice, error, run } = useAction()
1618 const [branch, setBranch] = useState(rev)
@@ -33,8 +35,8 @@
3335 return (
3436 <div className="max-w-[720px]">
3537 <SectionHead
36 title="分析与维护"
37 sub="语言统计、贡献者排行与提交活动。统计是持久化的缓存,改动后需要重新扫描。"
38 title={t('repo.settings.nav.insights')}
39 sub={t('repo.insights.sub')}
3840 />
3941 <Notice notice={notice} error={error} />
4042 <div className="mb-6 flex flex-wrap items-center gap-2">
@@ -43,47 +45,47 @@
4345 onClick={() =>
4446 void run(async () => {
4547 await rescanLanguages(space, repo)
46 setTick(t => t + 1)
47 }, '已重新扫描语言统计。')
48 setTick(n => n + 1)
49 }, t('repo.insights.rescanned'))
4850 }
4951 >
50 {busy ? '扫描中…' : '重新扫描语言'}
52 {busy ? t('repo.insights.scanning') : t('repo.insights.rescan')}
5153 </Button>
52 <span className="text-[12px] text-shell-ink-muted">扫描会遍历当前修订的目录树,仓库较大时耗时较长。</span>
54 <span className="text-[12px] text-shell-ink-muted">{t('repo.insights.rescanNote')}</span>
5355 </div>
5456
55 <h4 className="mb-2 text-[13px] font-bold">语言构成({stats.length})</h4>
57 <h4 className="mb-2 text-[13px] font-bold">{t('repo.insights.languages', { count: stats.length })}</h4>
5658 {languages.loading && !languages.data ? (
57 <p className="mb-6 text-[13px] text-shell-ink-muted">加载…</p>
59 <p className="mb-6 text-[13px] text-shell-ink-muted">{t('common.action.loading')}</p>
5860 ) : stats.length === 0 ? (
59 <p className="mb-6 text-[13px] text-shell-ink-muted">还没有语言统计,点上方「重新扫描语言」生成。</p>
61 <p className="mb-6 text-[13px] text-shell-ink-muted">{t('repo.insights.languagesEmpty')}</p>
6062 ) : (
6163 <ul className="mb-6 space-y-2">
6264 {stats.map(row => (
6365 <ListRow key={row.language}>
6466 <span className="min-w-[140px] font-medium">
6567 {row.language}
66 {row.is_primary ? <span className="ml-1.5 rounded-full bg-shell-active px-1.5 py-0.5 text-[10px]">主</span> : null}
68 {row.is_primary ? <span className="ml-1.5 rounded-full bg-shell-active px-1.5 py-0.5 text-[10px]">{t('repo.insights.primary')}</span> : null}
6769 </span>
6870 <span className="font-mono text-[12px] text-shell-ink-muted">{(row.percentage_bp / 100).toFixed(2)}%</span>
6971 <span className="ml-auto font-mono text-[12px] text-shell-ink-muted">{formatBytes(row.size)}</span>
7072 </ListRow>
7173 ))}
72 <li className="px-3 pt-1 text-[11.5px] text-shell-ink-muted">合计 {formatBytes(totalSize)}</li>
74 <li className="px-3 pt-1 text-[11.5px] text-shell-ink-muted">{t('repo.insights.total', { size: formatBytes(totalSize) })}</li>
7375 </ul>
7476 )}
7577
76 <h4 className="mb-2 text-[13px] font-bold">贡献者({people.length})</h4>
78 <h4 className="mb-2 text-[13px] font-bold">{t('repo.insights.contributors', { count: people.length })}</h4>
7779 {people.length === 0 ? (
78 <p className="mb-6 text-[13px] text-shell-ink-muted">暂无贡献者统计。</p>
80 <p className="mb-6 text-[13px] text-shell-ink-muted">{t('repo.insights.contributorsEmpty')}</p>
7981 ) : (
8082 <ul className="mb-6 space-y-2">
8183 {people.map(person => (
8284 <ListRow key={`${person.name}:${person.email}`}>
83 <span className="min-w-[120px] font-medium">{person.name || '未知'}</span>
85 <span className="min-w-[120px] font-medium">{person.name || t('common.state.unknown')}</span>
8486 <span className="truncate font-mono text-[12px] text-shell-ink-muted">{person.email}</span>
8587 <span className="ml-auto shrink-0 font-mono text-[12px]">
86 {person.commits} 提交 · <span className="text-[#1a7f37]">+{person.additions}</span>{' '}
88 {tc('repo.insights.commits', person.commits)} · <span className="text-[#1a7f37]">+{person.additions}</span>{' '}
8789 <span className="text-[#b42318]">−{person.deletions}</span>
8890 </span>
8991 </ListRow>
@@ -91,29 +93,29 @@
9193 </ul>
9294 )}
9395
94 <h4 className="mb-2 text-[13px] font-bold">提交活动</h4>
96 <h4 className="mb-2 text-[13px] font-bold">{t('repo.insights.activity')}</h4>
9597 <div className="mb-3 flex gap-2">
9698 <input
9799 className={`${inputCls} max-w-[220px]`}
98100 value={branch}
99101 onChange={e => setBranch(e.target.value)}
100 placeholder="分支,留空为默认"
102 placeholder={t('repo.insights.branchPlaceholder')}
101103 />
102 <Button variant="secondary" disabled={busy} onClick={() => setTick(t => t + 1)}>
103 刷新
104 <Button variant="secondary" disabled={busy} onClick={() => setTick(n => n + 1)}>
105 {t('common.action.refresh')}
104106 </Button>
105107 </div>
106108 {activity.data ? (
107109 <div className="rounded-lg border-2 border-shell-hairline bg-shell-field p-3 text-[12.5px]">
108110 <p className="mb-2">
109 共 {activity.data.total_commits} 次提交 ·{' '}
111 {t('repo.insights.activityTotal', { count: activity.data.total_commits })} ·{' '}
110112 <span className="text-[#1a7f37]">+{activity.data.total_additions}</span>{' '}
111113 <span className="text-[#b42318]">−{activity.data.total_deletions}</span>
112114 </p>
113115 <ul className="space-y-1">
114116 {(activity.data.authors ?? []).slice(0, 12).map(author => (
115117 <li key={`${author.name}:${author.email}`} className="flex flex-wrap gap-2">
116 <span className="min-w-[120px]">{author.name || '未知'}</span>
118 <span className="min-w-[120px]">{author.name || t('common.state.unknown')}</span>
117119 <span className="font-mono text-shell-ink-muted">{author.commits}</span>
118120 <span className="ml-auto font-mono text-shell-ink-muted">
119121 +{author.additions} −{author.deletions}
@@ -123,7 +125,7 @@
123125 </ul>
124126 </div>
125127 ) : (
126 <p className="text-[13px] text-shell-ink-muted">{activity.loading ? '加载中…' : '该分支暂无活动统计。'}</p>
128 <p className="text-[13px] text-shell-ink-muted">{activity.loading ? t('common.action.loading') : t('repo.insights.activityEmpty')}</p>
127129 )}
128130 </div>
129131 )
Modified web/src/features/repo-settings/ui/sections/locks-settings.tsx View web/src/features/repo-settings/ui/sections/locks-settings.tsx
@@ -1,5 +1,6 @@
11import { useState } from 'react'
22import { Alert, Button } from '@/shared/ui'
3import { useT } from '@/shared/i18n'
34import { formatBytes, fmtTime, repoKey, useAsync } from '@/shared/lib'
45import {
56 createLfsLock,
@@ -38,6 +39,7 @@
3839 * sentence (`model/lfs.ts`) so a rejected write never looks like a dead button.
3940 */
4041export function LocksSettings({ space, repo, rev, enableLfs, onRepoChanged }: LocksSettingsProps) {
42 const { t } = useT()
4143 const [tick, setTick] = useState(0)
4244 const [cursor, setCursor] = useState<string | null>(null)
4345 const [raw, setRaw] = useState('')
@@ -72,7 +74,7 @@
7274 const ok = await run(() => explain(action()), done)
7375 if (ok) {
7476 setTouched(false)
75 setTick(t => t + 1)
77 setTick(n => n + 1)
7678 }
7779 return ok
7880 }
@@ -80,20 +82,20 @@
8082 function submit() {
8183 setTouched(true)
8284 if (problem || busy) return
83 void act(() => createLfsLock(space, repo, path), `已锁定 ${path}。`)
85 void act(() => createLfsLock(space, repo, path), t('repo.locks.locked', { path }))
8486 }
8587
8688 return (
8789 <div className="max-w-[720px]">
8890 <SectionHead
89 title="LFS 锁"
90 sub="锁定大文件路径避免并发覆盖。他人持有的锁需要管理员用 force 释放。"
91 title={t('repo.settings.nav.locks')}
92 sub={t('repo.locks.sub')}
9193 />
9294
9395 {!enableLfs ? (
9496 <div className="mb-4 flex flex-wrap items-center gap-3">
9597 <Alert tone="info">
96 该仓库当前未启用 Git LFS,服务端会拒绝任何加锁请求。启用后即可创建与释放锁。
98 {t('repo.locks.disabledNote')}
9799 </Alert>
98100 <Button
99101 variant="secondary"
@@ -102,13 +104,13 @@
102104 onClick={() =>
103105 void act(
104106 () => updateRepo(space, repo, { enable_lfs: true }),
105 '已启用 Git LFS,现在可以创建锁了。',
107 t('repo.locks.enabled'),
106108 ).then(ok => {
107109 if (ok) onRepoChanged()
108110 })
109111 }
110112 >
111 启用 Git LFS
113 {t('repo.settings.field.enableLfs')}
112114 </Button>
113115 </div>
114116 ) : null}
@@ -116,14 +118,14 @@
116118 <Notice notice={notice} error={error} />
117119 {verify.data ? (
118120 <p className="mb-4 text-[12.5px] text-shell-ink-muted">
119 我持有 {verify.data.ours?.length ?? 0} 个锁,他人持有 {theirs} 个。
121 {t('repo.locks.summary', { ours: verify.data.ours?.length ?? 0, theirs })}
120122 </p>
121123 ) : null}
122124
123125 <div className="mb-1.5 flex flex-wrap gap-2">
124126 <input
125127 className={inputCls}
126 placeholder="要锁定的路径,如 assets/video.mp4"
128 placeholder={t('repo.locks.pathPlaceholder')}
127129 aria-invalid={hint ? true : undefined}
128130 value={raw}
129131 onChange={e => {
@@ -138,10 +140,10 @@
138140 }}
139141 />
140142 <Button variant="secondary" disabled={busy} onClick={() => setPicked(p => !p)}>
141 选择文件
143 {t('repo.locks.pickFile')}
142144 </Button>
143145 <Button disabled={busy} onClick={submit}>
144 {busy ? '提交中…' : '新建锁'}
146 {busy ? t('repo.locks.submitting') : t('repo.locks.create')}
145147 </Button>
146148 {path && !problem ? (
147149 <Button
@@ -150,16 +152,16 @@
150152 onClick={() =>
151153 void act(
152154 () => unlockLfsPath(space, repo, path),
153 `已释放 ${path}(若此前未加锁则会提示失败)。`,
155 t('repo.locks.unlocked', { path }),
154156 )
155157 }
156158 >
157 释放该路径
159 {t('repo.locks.releasePath')}
158160 </Button>
159161 ) : null}
160162 </div>
161163 <p className={`mb-4 text-[11.5px] ${hint ? 'text-error-foreground' : 'text-shell-ink-muted'}`}>
162 {hint ?? '路径相对仓库根目录;按 Enter 或点击「新建锁」提交,锁按路径唯一。'}
164 {hint ?? t('repo.locks.pathHint')}
163165 </p>
164166
165167 {picked ? (
@@ -177,9 +179,9 @@
177179 ) : null}
178180
179181 {page.loading && !page.data ? (
180 <p className="text-[13px] text-shell-ink-muted">加载锁…</p>
182 <p className="text-[13px] text-shell-ink-muted">{t('repo.locks.loading')}</p>
181183 ) : locks.length === 0 ? (
182 <p className="text-[13px] text-shell-ink-muted">当前没有 LFS 锁。</p>
184 <p className="text-[13px] text-shell-ink-muted">{t('repo.locks.empty')}</p>
183185 ) : (
184186 <ul className="mb-4 space-y-2">
185187 {locks.map(lock => (
@@ -187,7 +189,7 @@
187189 <span className="min-w-0 flex-1 truncate font-mono text-[12.5px]" title={lock.path}>
188190 {lock.path}
189191 </span>
190 <span className="text-[12px] text-shell-ink-muted">{lock.owner?.name ?? '未知'}</span>
192 <span className="text-[12px] text-shell-ink-muted">{lock.owner?.name ?? t('common.state.unknown')}</span>
191193 <span className="text-[12px] text-shell-ink-muted">{fmtTime(lock.locked_at)}</span>
192194 <Button
193195 variant="secondary"
@@ -202,11 +204,11 @@
202204 const isMine = mine.size === 0 || mine.has(lock.id)
203205 void act(
204206 () => unlockLfsById(space, repo, lock.id, !isMine),
205 `${isMine ? '已释放' : '已强制释放'} ${lock.path}。`,
207 isMine ? t('repo.locks.released', { path: lock.path }) : t('repo.locks.forceReleased', { path: lock.path }),
206208 )
207209 }}
208210 >
209 {pendingDelete === lock.id ? '确认释放' : mine.size > 0 && !mine.has(lock.id) ? '强制释放' : '释放'}
211 {pendingDelete === lock.id ? t('repo.locks.confirmRelease') : mine.size > 0 && !mine.has(lock.id) ? t('repo.locks.forceRelease') : t('repo.locks.release')}
210212 </Button>
211213 </ListRow>
212214 ))}
@@ -221,20 +223,20 @@
221223 disabled={busy}
222224 onClick={() => setCursor(page.data!.next_cursor!)}
223225 >
224 加载更多
226 {t('repo.locks.loadMore')}
225227 </Button>
226228 ) : cursor ? (
227229 <Button variant="secondary" className="h-8 text-[12.5px]" onClick={() => setCursor(null)}>
228 回到第一页
230 {t('repo.locks.firstPage')}
229231 </Button>
230232 ) : null}
231233 <Button
232234 variant="ghost"
233235 className="h-8 text-[12.5px]"
234236 disabled={page.loading}
235 onClick={() => setTick(t => t + 1)}
237 onClick={() => setTick(n => n + 1)}
236238 >
237 {page.loading ? '刷新中…' : '刷新'}
239 {page.loading ? t('repo.action.refreshing') : t('common.action.refresh')}
238240 </Button>
239241 </div>
240242 </div>
@@ -255,6 +257,7 @@
255257 onPick: (path: string) => void
256258 onClose: () => void
257259}) {
260 const { t } = useT()
258261 const [dir, setDir] = useState('')
259262 const [tick, setTick] = useState(0)
260263 const state = useAsync(() => fetchTreeJson(space, repo, rev, dir), [space, repo, rev, dir, tick], {
@@ -265,7 +268,7 @@
265268 const dirs = rows.filter(row => row.mode === 'Tree')
266269 const files = rows.filter(row => row.mode !== 'Tree')
267270 const failed = state.error
268 ? `读取仓库目录失败:${lfsLockFailureText(state.error)} 也可以直接输入路径。`
271 ? t('repo.locks.treeFailed', { reason: lfsLockFailureText(state.error) })
269272 : null
270273
271274 return (
@@ -276,22 +279,22 @@
276279 </span>
277280 {dir ? (
278281 <Button variant="ghost" className="h-7 text-[12px]" onClick={() => setDir(parentPath(dir))}>
279 上一级
282 {t('repo.locks.parentDir')}
280283 </Button>
281284 ) : null}
282 <Button variant="ghost" className="h-7 text-[12px]" onClick={() => setTick(t => t + 1)}>
283 刷新
285 <Button variant="ghost" className="h-7 text-[12px]" onClick={() => setTick(n => n + 1)}>
286 {t('common.action.refresh')}
284287 </Button>
285288 <Button variant="ghost" className="h-7 text-[12px]" onClick={onClose}>
286 关闭
289 {t('common.action.close')}
287290 </Button>
288291 </div>
289292 {failed ? <p className="px-1 py-2 text-[12.5px] text-error-foreground">{failed}</p> : null}
290293 {!failed && state.loading && rows.length === 0 ? (
291 <p className="px-1 py-2 text-[12.5px] text-shell-ink-muted">加载目录…</p>
294 <p className="px-1 py-2 text-[12.5px] text-shell-ink-muted">{t('repo.tree.loading')}</p>
292295 ) : null}
293296 {!failed && !state.loading && rows.length === 0 ? (
294 <p className="px-1 py-2 text-[12.5px] text-shell-ink-muted">该目录为空。</p>
297 <p className="px-1 py-2 text-[12.5px] text-shell-ink-muted">{t('repo.tree.empty')}</p>
295298 ) : null}
296299 <ul className="max-h-64 overflow-y-auto">
297300 {[...dirs, ...files].map(row => (
Modified web/src/features/repo-settings/ui/sections/protection-settings.tsx View web/src/features/repo-settings/ui/sections/protection-settings.tsx
@@ -1,6 +1,7 @@
11import { useState } from 'react'
22import { Pencil, Plus, Trash2 } from 'lucide-react'
33import { Button, ErrorPanel, Modal, PanelNote, modalInputClass, modalLabelClass } from '@/shared/ui'
4import { t, useT } from '@/shared/i18n'
45import { useAsync } from '@/shared/lib'
56import {
67 addBranchWhitelist,
@@ -24,22 +25,22 @@
2425import { Field, Notice, SectionHead, areaCls, inputCls } from '../parts'
2526
2627const KIND_LABEL: Record<WhitelistKind, string> = {
27 Push: '推送',
28 Merge: '合并',
29 Approval: '审批绕过',
28 get Push() { return t('repo.protection.kind.push') },
29 get Merge() { return t('repo.protection.kind.merge') },
30 get Approval() { return t('repo.protection.kind.approval') },
3031}
3132
32const BOOL_FIELDS: Array<{ key: keyof CreatePolicyInput; label: string; hint?: string }> = [
33 { key: 'enable_push', label: '限制推送(默认允许)' },
34 { key: 'enable_force_push', label: '禁止强推(force push)' },
35 { key: 'enable_push_whitelist', label: '推送启用白名单' },
36 { key: 'enable_merge_whitelist', label: '合并启用白名单' },
37 { key: 'enable_approvals_whitelist', label: '审批启用白名单' },
38 { key: 'block_on_rejected_reviews', label: '被拒绝的评审阻止合并' },
39 { key: 'block_on_change_requested_reviews', label: '请求修改的评审阻止合并' },
40 { key: 'block_on_outdated_branch', label: '源分支落后时阻止合并' },
41 { key: 'dismiss_stale_approvals', label: '新提交后作废旧批准' },
42 { key: 'require_signed_commits', label: '要求签名提交' },
33const BOOL_FIELDS: Array<{ key: keyof CreatePolicyInput; get label(): string; hint?: string }> = [
34 { key: 'enable_push', get label() { return t('repo.protection.field.enablePush') } },
35 { key: 'enable_force_push', get label() { return t('repo.protection.field.enableForcePush') } },
36 { key: 'enable_push_whitelist', get label() { return t('repo.protection.field.enablePushWhitelist') } },
37 { key: 'enable_merge_whitelist', get label() { return t('repo.protection.field.enableMergeWhitelist') } },
38 { key: 'enable_approvals_whitelist', get label() { return t('repo.protection.field.enableApprovalsWhitelist') } },
39 { key: 'block_on_rejected_reviews', get label() { return t('repo.protection.field.blockOnRejected') } },
40 { key: 'block_on_change_requested_reviews', get label() { return t('repo.protection.field.blockOnChangeRequested') } },
41 { key: 'block_on_outdated_branch', get label() { return t('repo.protection.field.blockOnOutdated') } },
42 { key: 'dismiss_stale_approvals', get label() { return t('repo.protection.field.dismissStale') } },
43 { key: 'require_signed_commits', get label() { return t('repo.protection.field.requireSigned') } },
4344]
4445
4546export interface ProtectionSettingsProps {
@@ -49,6 +50,7 @@
4950
5051/** 设置 · 保护规则:分支保护策略 CRUD、策略白名单,以及标签保护白名单。 */
5152export function ProtectionSettings({ space, repo }: ProtectionSettingsProps) {
53 const { t } = useT()
5254 const [tick, setTick] = useState(0)
5355 const [notice, setNotice] = useState<string | null>(null)
5456 const [error, setError] = useState<string | null>(null)
@@ -58,36 +60,36 @@
5860 const policies = useAsync(() => listPolicies(space, repo), [space, repo, tick])
5961 const rows = policies.data ?? []
6062 const failure = policies.error as { code?: number; message?: string } | undefined
61 const refresh = () => setTick(t => t + 1)
63 const refresh = () => setTick(n => n + 1)
6264
6365 async function run(label: string, fn: () => Promise<unknown>) {
6466 setNotice(null)
6567 setError(null)
6668 try {
6769 await fn()
68 setNotice(`${label}完成。`)
70 setNotice(label + t('repo.protection.doneSuffix'))
6971 refresh()
7072 } catch (f) {
71 setError(`${label}失败:${f instanceof Error ? f.message : '未知错误'}`)
73 setError(label + t('repo.protection.failedSuffix') + (f instanceof Error ? f.message : t('repo.protection.unknownError')))
7274 }
7375 }
7476
7577 return (
7678 <div>
77 <SectionHead title="保护规则" sub="分支保护策略约束推送、强推与合并门禁;白名单成员可以绕过对应限制。" />
79 <SectionHead title={t('repo.settings.nav.protection')} sub={t('repo.protection.sub')} />
7880 <Notice notice={notice} error={error} />
7981 <div className="mb-4 flex items-center justify-between">
80 <p className="text-[12.5px] text-shell-ink-muted">匹配顺序:分支名命中多条策略时,服务端取更具体的模式。</p>
82 <p className="text-[12.5px] text-shell-ink-muted">{t('repo.protection.orderNote')}</p>
8183 <Button className="h-8 gap-1 text-[12.5px]" onClick={() => setCreating(true)}>
82 <Plus className="h-3.5 w-3.5" aria-hidden />新建分支保护策略
84 <Plus className="h-3.5 w-3.5" aria-hidden />{t('repo.protection.create')}
8385 </Button>
8486 </div>
8587
86 {policies.loading && !policies.data ? <PanelNote text="加载保护策略…" /> : null}
88 {policies.loading && !policies.data ? <PanelNote text={t('repo.protection.loading')} /> : null}
8789 {!policies.loading && failure ? (
88 <ErrorPanel status={failure.code ?? 0} what="保护策略" note={failure.message} onRetry={refresh} />
90 <ErrorPanel status={failure.code ?? 0} what={t('repo.protection.what')} note={failure.message} onRetry={refresh} />
8991 ) : null}
90 {!policies.loading && !failure && !rows.length ? <PanelNote text="还没有保护规则。默认分支建议至少配置一条。" /> : null}
92 {!policies.loading && !failure && !rows.length ? <PanelNote text={t('repo.protection.empty')} /> : null}
9193
9294 <div className="space-y-3">
9395 {rows.map(policy => (
@@ -98,8 +100,8 @@
98100 policy={policy}
99101 onEdit={() => void getPolicy(space, repo, policy.id).then(setEditing).catch(() => setEditing(policy))}
100102 onDelete={() => {
101 if (!window.confirm(`删除对 “${policy.branch_pattern}” 的保护?`)) return
102 void run('删除策略', () => deletePolicy(space, repo, policy.id))
103 if (!window.confirm(t('repo.protection.deleteConfirm', { pattern: policy.branch_pattern }))) return
104 void run(t('repo.protection.deleteLabel'), () => deletePolicy(space, repo, policy.id))
103105 }}
104106 />
105107 ))}
@@ -134,21 +136,22 @@
134136 onEdit: () => void
135137 onDelete: () => void
136138}) {
139 const { t } = useT()
137140 const [open, setOpen] = useState(false)
138141 const chips = [
139 policy.required_approvals > 0 ? `需 ${policy.required_approvals} 个批准` : '不要求批准',
140 policy.enable_push ? '限制推送' : null,
141 policy.enable_force_push ? '禁止强推' : null,
142 policy.enable_push_whitelist ? '推送白名单' : null,
143 policy.enable_merge_whitelist ? '合并白名单' : null,
144 policy.enable_approvals_whitelist ? '审批白名单' : null,
145 policy.dismiss_stale_approvals ? '新提交作废旧批准' : null,
146 policy.block_on_rejected_reviews ? '拒绝评审阻止合并' : null,
147 policy.block_on_change_requested_reviews ? '请求修改阻止合并' : null,
148 policy.block_on_outdated_branch ? '落后分支阻止合并' : null,
149 policy.require_signed_commits ? '要求签名提交' : null,
150 policy.protected_file_patterns ? `保护文件:${policy.protected_file_patterns}` : null,
151 policy.unprotected_file_patterns ? `豁免文件:${policy.unprotected_file_patterns}` : null,
142 policy.required_approvals > 0 ? t('repo.protection.chip.approvals', { count: policy.required_approvals }) : t('repo.protection.chip.noApprovals'),
143 policy.enable_push ? t('repo.protection.chip.restrictPush') : null,
144 policy.enable_force_push ? t('repo.protection.chip.noForcePush') : null,
145 policy.enable_push_whitelist ? t('repo.protection.chip.pushWhitelist') : null,
146 policy.enable_merge_whitelist ? t('repo.protection.chip.mergeWhitelist') : null,
147 policy.enable_approvals_whitelist ? t('repo.protection.chip.approvalWhitelist') : null,
148 policy.dismiss_stale_approvals ? t('repo.protection.chip.dismissStale') : null,
149 policy.block_on_rejected_reviews ? t('repo.protection.chip.blockRejected') : null,
150 policy.block_on_change_requested_reviews ? t('repo.protection.chip.blockChangeRequested') : null,
151 policy.block_on_outdated_branch ? t('repo.protection.chip.blockOutdated') : null,
152 policy.require_signed_commits ? t('repo.protection.chip.signedCommits') : null,
153 policy.protected_file_patterns ? t('repo.protection.chip.protectedFiles', { patterns: policy.protected_file_patterns }) : null,
154 policy.unprotected_file_patterns ? t('repo.protection.chip.unprotectedFiles', { patterns: policy.unprotected_file_patterns }) : null,
152155 ].filter(Boolean) as string[]
153156
154157 return (
@@ -156,8 +159,8 @@
156159 <div className="flex flex-wrap items-center gap-2">
157160 <code className="rounded bg-shell-active/70 px-2 py-0.5 font-mono text-[12.5px] font-semibold">{policy.branch_pattern}</code>
158161 <span className="ml-auto flex items-center gap-1.5">
159 <button type="button" onClick={onEdit} title="编辑策略" className="p-1 text-shell-ink-muted hover:text-shell-ink"><Pencil className="h-3.5 w-3.5" /></button>
160 <button type="button" onClick={onDelete} title="删除策略" className="p-1 text-shell-ink-muted hover:text-[#b42318]"><Trash2 className="h-3.5 w-3.5" /></button>
162 <button type="button" onClick={onEdit} title={t('repo.protection.edit')} className="p-1 text-shell-ink-muted hover:text-shell-ink"><Pencil className="h-3.5 w-3.5" /></button>
163 <button type="button" onClick={onDelete} title={t('repo.protection.deleteTitle')} className="p-1 text-shell-ink-muted hover:text-[#b42318]"><Trash2 className="h-3.5 w-3.5" /></button>
161164 </span>
162165 </div>
163166 <div className="mt-2 flex flex-wrap gap-1.5">
@@ -166,7 +169,7 @@
166169 ))}
167170 </div>
168171 <button type="button" onClick={() => setOpen(v => !v)} className="mt-2 text-[12px] text-shell-ink-muted underline underline-offset-2 hover:text-shell-ink">
169 {open ? '收起白名单' : '管理白名单'}
172 {open ? t('repo.protection.hideWhitelist') : t('repo.protection.manageWhitelist')}
170173 </button>
171174 {open ? <WhitelistManager space={space} repo={repo} policy={policy} /> : null}
172175 </div>
@@ -176,6 +179,7 @@
176179/* ------------------------------------------------------------- whitelists */
177180
178181function WhitelistManager({ space, repo, policy }: { space: string; repo: string; policy: BranchPolicy }) {
182 const { t } = useT()
179183 const [tick, setTick] = useState(0)
180184 const [kind, setKind] = useState<WhitelistKind>('Push')
181185 const [userId, setUserId] = useState('')
@@ -191,18 +195,18 @@
191195 try {
192196 await addBranchWhitelist(space, repo, policy.id, { kind, user_id: userId })
193197 setUserId('')
194 setTick(t => t + 1)
198 setTick(n => n + 1)
195199 } catch (f) {
196 setError(f instanceof Error ? f.message : '添加失败')
200 setError(f instanceof Error ? f.message : t('repo.protection.addFailed'))
197201 }
198202 }
199203
200204 async function remove(row: BranchWhitelistRow) {
201205 try {
202206 await removeBranchWhitelist(space, repo, policy.id, row.id)
203 setTick(t => t + 1)
207 setTick(n => n + 1)
204208 } catch (f) {
205 setError(f instanceof Error ? f.message : '移除失败')
209 setError(f instanceof Error ? f.message : t('repo.protection.removeFailed'))
206210 }
207211 }
208212
@@ -217,28 +221,28 @@
217221 </label>
218222 ))}
219223 <select value={userId} onChange={event => setUserId(event.target.value)} className={`h-8 flex-1 min-w-[140px] ${modalInputClass}`}>
220 <option value="">选择用户…</option>
224 <option value="">{t('repo.protection.selectUser')}</option>
221225 {options.map(user => (
222 <option key={user.user_id} value={user.user_id}>{user.display_name || user.username}(@{user.username})</option>
226 <option key={user.user_id} value={user.user_id}>{t('repo.protection.userOption', { name: user.display_name || user.username, username: user.username })}</option>
223227 ))}
224228 </select>
225 <Button className="h-8 text-[12.5px]" disabled={!userId} onClick={() => void add()}>添加</Button>
229 <Button className="h-8 text-[12.5px]" disabled={!userId} onClick={() => void add()}>{t('repo.action.add')}</Button>
226230 </div>
227231 {rows.length ? (
228232 <ul className="space-y-1">
229233 {rows.map(row => (
230234 <li key={row.id} className="flex items-center gap-2 rounded bg-shell-field px-2.5 py-1.5 text-[12.5px]">
231235 <span className="rounded bg-shell-active/70 px-1.5 py-0.5 text-[11px]">{KIND_LABEL[row.kind] ?? row.kind}</span>
232 <span className="min-w-0 flex-1 truncate">{row.user?.display_name || row.user?.username || row.user_id || '(组)'}</span>
233 <button type="button" aria-label="移除" onClick={() => void remove(row)} className="text-shell-ink-muted hover:text-[#b42318]">×</button>
236 <span className="min-w-0 flex-1 truncate">{row.user?.display_name || row.user?.username || row.user_id || t('repo.protection.groupLabel')}</span>
237 <button type="button" aria-label={t('common.action.remove')} onClick={() => void remove(row)} className="text-shell-ink-muted hover:text-[#b42318]">×</button>
234238 </li>
235239 ))}
236240 </ul>
237241 ) : (
238 <p className="text-[12px] text-shell-ink-muted">该策略暂无白名单成员。</p>
242 <p className="text-[12px] text-shell-ink-muted">{t('repo.protection.whitelistEmpty')}</p>
239243 )}
240244 {policy.enable_push_whitelist || policy.enable_merge_whitelist || policy.enable_approvals_whitelist ? null : (
241 <p className="mt-1.5 text-[11.5px] text-[#8a6100]">对应白名单开关未在策略中启用,添加后不会生效。</p>
245 <p className="mt-1.5 text-[11.5px] text-[#8a6100]">{t('repo.protection.whitelistDisabledNote')}</p>
242246 )}
243247 </div>
244248 )
@@ -247,6 +251,7 @@
247251/* --------------------------------------------------------- tag whitelists */
248252
249253function TagProtectionPanel({ space, repo }: { space: string; repo: string }) {
254 const { t } = useT()
250255 const [policyId, setPolicyId] = useState('')
251256 const [tick, setTick] = useState(0)
252257 const [userId, setUserId] = useState('')
@@ -263,22 +268,22 @@
263268 setError(null)
264269 try {
265270 await addTagWhitelist(space, repo, policyId, userId)
266 setTick(t => t + 1)
271 setTick(n => n + 1)
267272 } catch (f) {
268 setError(f instanceof Error ? f.message : '添加失败')
273 setError(f instanceof Error ? f.message : t('repo.protection.addFailed'))
269274 }
270275 }
271276
272277 return (
273278 <div className="mt-6 rounded-xl border border-shell-hairline bg-shell-field p-4">
274 <h4 className="text-[13.5px] font-semibold">标签保护白名单</h4>
279 <h4 className="text-[13.5px] font-semibold">{t('repo.protection.tagWhitelistTitle')}</h4>
275280 <p className="mt-1 text-[12px] leading-5 text-[#8a6100]">
276 后端尚未提供标签保护策略的列表/创建接口,此处按策略 UUID 维护其用户白名单。
281 {t('repo.protection.tagWhitelistNote')}
277282 </p>
278283 {error ? <p className="mt-2 text-[12px] text-[#b42318]">{error}</p> : null}
279284 <input
280285 className={`${modalInputClass} mt-2 h-8`}
281 placeholder="标签保护策略 UUID"
286 placeholder={t('repo.protection.tagPolicyIdPlaceholder')}
282287 value={policyId}
283288 onChange={event => setPolicyId(event.target.value.trim())}
284289 />
@@ -286,12 +291,12 @@
286291 <div className="mt-2">
287292 <div className="flex flex-wrap items-center gap-2 text-[12px]">
288293 <select value={userId} onChange={event => setUserId(event.target.value)} className={`h-8 flex-1 min-w-[140px] ${modalInputClass}`}>
289 <option value="">选择用户…</option>
294 <option value="">{t('repo.protection.selectUser')}</option>
290295 {(users.data ?? []).map(user => (
291 <option key={user.user_id} value={user.user_id}>{user.display_name || user.username}(@{user.username})</option>
296 <option key={user.user_id} value={user.user_id}>{t('repo.protection.userOption', { name: user.display_name || user.username, username: user.username })}</option>
292297 ))}
293298 </select>
294 <Button className="h-8 text-[12.5px]" disabled={!userId} onClick={() => void add()}>添加</Button>
299 <Button className="h-8 text-[12.5px]" disabled={!userId} onClick={() => void add()}>{t('repo.action.add')}</Button>
295300 </div>
296301 <ul className="mt-2 space-y-1">
297302 {rows.map(row => (
@@ -299,8 +304,8 @@
299304 <span className="min-w-0 flex-1 truncate">{row.user?.display_name || row.user?.username || row.user_id}</span>
300305 <button
301306 type="button"
302 aria-label="移除"
303 onClick={() => void removeTagWhitelist(space, repo, policyId, row.user_id).then(() => setTick(t => t + 1)).catch(() => undefined)}
307 aria-label={t('common.action.remove')}
308 onClick={() => void removeTagWhitelist(space, repo, policyId, row.user_id).then(() => setTick(n => n + 1)).catch(() => undefined)}
304309 className="text-shell-ink-muted hover:text-[#b42318]"
305310 >
306311 ×
@@ -323,6 +328,7 @@
323328 onClose: () => void
324329 onSaved: (message: string) => void
325330}) {
331 const { t } = useT()
326332 const [form, setForm] = useState<CreatePolicyInput>(() =>
327333 policy
328334 ? {
@@ -350,7 +356,7 @@
350356
351357 async function save() {
352358 if (!form.branch_pattern.trim()) {
353 setError('分支模式不能为空。')
359 setError(t('repo.protection.branchRequired'))
354360 return
355361 }
356362 setBusy(true)
@@ -358,39 +364,39 @@
358364 try {
359365 if (policy) {
360366 await updatePolicy(space, repo, policy.id, form)
361 onSaved('策略已更新。')
367 onSaved(t('repo.protection.updated'))
362368 } else {
363369 await createPolicy(space, repo, form)
364 onSaved('策略已创建。')
370 onSaved(t('repo.protection.created'))
365371 }
366372 } catch (f) {
367 setError(f instanceof Error ? f.message : '保存失败')
373 setError(f instanceof Error ? f.message : t('repo.error.saveFailed'))
368374 setBusy(false)
369375 }
370376 }
371377
372378 return (
373379 <Modal
374 title={policy ? '编辑保护策略' : '新建分支保护策略'}
375 description="分支模式支持 glob(如 main、release/*、*)。"
380 title={policy ? t('repo.protection.editTitle') : t('repo.protection.create')}
381 description={t('repo.protection.formDesc')}
376382 open
377383 onClose={onClose}
378384 width="max-w-[640px]"
379385 footer={
380386 <>
381 <Button variant="ghost" onClick={onClose}>取消</Button>
382 <Button disabled={busy} onClick={() => void save()}>{busy ? '保存中…' : '保存'}</Button>
387 <Button variant="ghost" onClick={onClose}>{t('common.action.cancel')}</Button>
388 <Button disabled={busy} onClick={() => void save()}>{busy ? t('repo.action.saving') : t('common.action.save')}</Button>
383389 </>
384390 }
385391 >
386392 {error ? <p className="text-[12.5px] text-[#b42318]">{error}</p> : null}
387393 <div className="grid gap-4 sm:grid-cols-2">
388394 <label>
389 <span className={modalLabelClass}>分支模式</span>
395 <span className={modalLabelClass}>{t('repo.protection.branchPattern')}</span>
390396 <input className={inputCls} value={form.branch_pattern} onChange={event => setForm(prev => ({ ...prev, branch_pattern: event.target.value }))} />
391397 </label>
392398 <label>
393 <span className={modalLabelClass}>所需批准数(0–10)</span>
399 <span className={modalLabelClass}>{t('repo.protection.requiredApprovals')}</span>
394400 <input
395401 className={inputCls}
396402 type="number"
@@ -413,10 +419,10 @@
413419 </label>
414420 ))}
415421 </div>
416 <Field label="受保护文件模式(逗号分隔 glob,可选)">
422 <Field label={t('repo.protection.protectedPatterns')}>
417423 <textarea className={areaCls} rows={2} value={form.protected_file_patterns ?? ''} onChange={event => setForm(prev => ({ ...prev, protected_file_patterns: event.target.value }))} />
418424 </Field>
419 <Field label="豁免文件模式(逗号分隔 glob,可选)">
425 <Field label={t('repo.protection.unprotectedPatterns')}>
420426 <textarea className={areaCls} rows={2} value={form.unprotected_file_patterns ?? ''} onChange={event => setForm(prev => ({ ...prev, unprotected_file_patterns: event.target.value }))} />
421427 </Field>
422428 </Modal>
Modified web/src/features/repo-settings/ui/sections/remotes-settings.tsx View web/src/features/repo-settings/ui/sections/remotes-settings.tsx
@@ -1,5 +1,6 @@
11import { useState } from 'react'
22import { Button } from '@/shared/ui'
3import { useT } from '@/shared/i18n'
34import { repoKey, useAsync } from '@/shared/lib'
45import {
56 fetchRemote,
@@ -21,6 +22,7 @@
2122 * and private address ranges (SSRF guard), so the form says so up front.
2223 */
2324export function RemotesSettings({ space, repo }: { space: string; repo: string }) {
25 const { t } = useT()
2426 const [tick, setTick] = useState(0)
2527 const remotes = useAsync(() => listRemotes(space, repo), [space, repo, tick], {
2628 cacheKey: repoKey(space, repo, 'remotes'),
@@ -34,7 +36,7 @@
3436 const [result, setResult] = useState<string | null>(null)
3537
3638 const rows = remotes.data ?? []
37 const refresh = () => setTick(t => t + 1)
39 const refresh = () => setTick(n => n + 1)
3840
3941 async function act(action: () => Promise<unknown>, done: string) {
4042 setResult(null)
@@ -46,15 +48,15 @@
4648 return (
4749 <div className="max-w-[720px]">
4850 <SectionHead
49 title="远程"
50 sub="仓库配置的 git remote。地址必须是 https:// / ssh:// / git://,且不能指向 localhost 或内网地址。"
51 title={t('repo.settings.nav.remotes')}
52 sub={t('repo.remotes.sub')}
5153 />
5254 <Notice notice={notice} error={error} />
5355 {result ? <p className="mb-4 rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-2 font-mono text-[12px]">{result}</p> : null}
5456
5557 <ul className="mb-6 space-y-2">
56 {remotes.loading && !remotes.data ? <p className="text-[13px] text-shell-ink-muted">加载远程…</p> : null}
57 {!remotes.loading && rows.length === 0 ? <p className="text-[13px] text-shell-ink-muted">尚未配置远程。</p> : null}
58 {remotes.loading && !remotes.data ? <p className="text-[13px] text-shell-ink-muted">{t('repo.remotes.loading')}</p> : null}
59 {!remotes.loading && rows.length === 0 ? <p className="text-[13px] text-shell-ink-muted">{t('repo.remotes.empty')}</p> : null}
5860 {rows.map(remote => (
5961 <ListRow key={remote.name}>
6062 <span className="min-w-[90px] font-mono text-[13px] font-semibold">{remote.name}</span>
@@ -71,13 +73,13 @@
7173 void act(
7274 async () => {
7375 const out = await fetchRemote(space, repo, remote.name, token || undefined)
74 setResult(`抓取:新增 ${(out as FetchResult).new?.length ?? 0} 个引用,更新 ${(out as FetchResult).updated?.length ?? 0} 个。`)
76 setResult(t('repo.remotes.fetchResult', { added: (out as FetchResult).new?.length ?? 0, updated: (out as FetchResult).updated?.length ?? 0 }))
7577 },
76 `已从「${remote.name}」抓取。`,
78 t('repo.remotes.fetched', { name: remote.name }),
7779 )
7880 }
7981 >
80 抓取
82 {t('repo.remotes.fetch')}
8183 </Button>
8284 <Button
8385 variant="secondary"
@@ -87,13 +89,13 @@
8789 void act(
8890 async () => {
8991 const out = await pushRemote(space, repo, remote.name, refspec || undefined, token || undefined)
90 setResult(`推送:成功 ${(out as PushResult).pushed?.length ?? 0},被拒 ${(out as PushResult).rejected?.length ?? 0}。`)
92 setResult(t('repo.remotes.pushResult', { pushed: (out as PushResult).pushed?.length ?? 0, rejected: (out as PushResult).rejected?.length ?? 0 }))
9193 },
92 `已推送到「${remote.name}」。`,
94 t('repo.remotes.pushed', { name: remote.name }),
9395 )
9496 }
9597 >
96 推送
98 {t('repo.remotes.push')}
9799 </Button>
98100 <Button
99101 variant="secondary"
@@ -105,21 +107,21 @@
105107 return
106108 }
107109 setPendingDelete(null)
108 void act(() => removeRemote(space, repo, remote.name), `已删除远程「${remote.name}」。`)
110 void act(() => removeRemote(space, repo, remote.name), t('repo.remotes.deleted', { name: remote.name }))
109111 }}
110112 >
111 {pendingDelete === remote.name ? '确认删除' : '删除'}
113 {pendingDelete === remote.name ? t('repo.action.confirmDelete') : t('common.action.delete')}
112114 </Button>
113115 </span>
114116 </ListRow>
115117 ))}
116118 </ul>
117119
118 <h4 className="mb-3 text-[13px] font-bold">新增或更新远程</h4>
119 <Field label="名称" hint="不能以 - 开头,不能含空格,最长 255 字符。">
120 <h4 className="mb-3 text-[13px] font-bold">{t('repo.remotes.upsertTitle')}</h4>
121 <Field label={t('repo.remotes.name')} hint={t('repo.remotes.nameHint')}>
120122 <input className={inputCls} placeholder="upstream" value={name} onChange={e => setName(e.target.value)} />
121123 </Field>
122 <Field label="地址">
124 <Field label={t('repo.remotes.url')}>
123125 <input
124126 className={inputCls}
125127 placeholder="https://github.com/owner/repo.git"
@@ -130,14 +132,14 @@
130132 <div className="mb-5 flex gap-2">
131133 <input
132134 className={`${inputCls} max-w-[220px]`}
133 placeholder="令牌(可选,仅本次请求)"
135 placeholder={t('repo.remotes.tokenPlaceholder')}
134136 type="password"
135137 value={token}
136138 onChange={e => setToken(e.target.value)}
137139 />
138140 <input
139141 className={`${inputCls} max-w-[220px]`}
140 placeholder="refspec(推送用,留空为默认)"
142 placeholder={t('repo.remotes.refspecPlaceholder')}
141143 value={refspec}
142144 onChange={e => setRefspec(e.target.value)}
143145 />
@@ -146,10 +148,10 @@
146148 <Button
147149 disabled={busy || !name.trim() || !url.trim()}
148150 onClick={() =>
149 void act(() => upsertRemote(space, repo, name.trim(), url.trim()), `已保存远程「${name.trim()}」。`)
151 void act(() => upsertRemote(space, repo, name.trim(), url.trim()), t('repo.remotes.saved', { name: name.trim() }))
150152 }
151153 >
152 保存远程
154 {t('repo.remotes.save')}
153155 </Button>
154156 <Button
155157 variant="secondary"
@@ -157,18 +159,15 @@
157159 onClick={() =>
158160 void act(async () => {
159161 const out = await importRemote(space, repo, url.trim(), token || undefined)
160 setResult(`镜像导入:${(out as ImportResult).commit_count} 个提交,默认分支 ${(out as ImportResult).default_branch}。`)
161 }, '镜像导入完成。')
162 setResult(t('repo.remotes.mirrorResult', { commits: (out as ImportResult).commit_count, branch: (out as ImportResult).default_branch }))
163 }, t('repo.remotes.mirrorDone'))
162164 }
163 title="以 mirror 方式把外部仓库的全部引用导入当前仓库"
165 title={t('repo.remotes.mirrorHint')}
164166 >
165 镜像导入此地址
167 {t('repo.remotes.mirror')}
166168 </Button>
167169 </div>
168 <p className="mt-3 text-[11.5px] text-shell-ink-muted">
169 令牌只在当次请求中使用,不会被保存;镜像导入会覆盖同名引用,请确认地址无误。
170 要从 GitHub / GitLab / Gitea 新建并迁移一个仓库,请用社区菜单的「导入仓库」,任务进度在设置的「导入」分区查看。
171 </p>
170 <p className="mt-3 text-[11.5px] text-shell-ink-muted">{t('repo.remotes.note')}</p>
172171 </div>
173172 )
174173}
Modified web/src/features/repo-settings/ui/sections/topics-settings.tsx View web/src/features/repo-settings/ui/sections/topics-settings.tsx
@@ -1,6 +1,7 @@
11import { useState } from 'react'
22import { X } from 'lucide-react'
33import { Button } from '@/shared/ui'
4import { useT } from '@/shared/i18n'
45import { repoKey, useAsync } from '@/shared/lib'
56import { addTopic, listTopics, removeTopic } from '@/entities/repo'
67import { useAction } from '../use-action'
@@ -8,6 +9,7 @@
89
910/** GitHub-style topics: `PUT|DELETE /repos/{s}/{r}/topics/{name}`. */
1011export function TopicsSettings({ space, repo }: { space: string; repo: string }) {
12 const { t } = useT()
1113 const [tick, setTick] = useState(0)
1214 const topics = useAsync(() => listTopics(space, repo), [space, repo, tick], {
1315 cacheKey: repoKey(space, repo, 'topics'),
@@ -27,29 +29,29 @@
2729
2830 async function submit() {
2931 if (!canAdd) return
30 const ok = await run(() => addTopic(space, repo, normalized), `已添加主题「${normalized}」。`)
32 const ok = await run(() => addTopic(space, repo, normalized), t('repo.settings.topicAdded', { name: normalized }))
3133 if (ok) {
3234 setDraft('')
33 setTick(t => t + 1)
35 setTick(n => n + 1)
3436 }
3537 }
3638
3739 async function remove(name: string) {
38 const ok = await run(() => removeTopic(space, repo, name), `已移除主题「${name}」。`)
39 if (ok) setTick(t => t + 1)
40 const ok = await run(() => removeTopic(space, repo, name), t('repo.settings.topicRemoved', { name }))
41 if (ok) setTick(n => n + 1)
4042 }
4143
4244 return (
4345 <div className="max-w-[640px]">
4446 <SectionHead
45 title="主题"
46 sub="给仓库打标签便于发现。仅允许小写字母、数字与连字符,服务端会在标签不存在时自动创建。"
47 title={t('repo.settings.nav.topics')}
48 sub={t('repo.settings.topicsSub')}
4749 />
4850 <Notice notice={notice} error={error} />
4951 <div className="mb-5 flex gap-2">
5052 <input
5153 className={inputCls}
52 placeholder="如 devops、cli-tool"
54 placeholder={t('repo.settings.topicPlaceholder')}
5355 value={draft}
5456 onChange={e => setDraft(e.target.value)}
5557 onKeyDown={e => {
@@ -60,17 +62,17 @@
6062 }}
6163 />
6264 <Button disabled={!canAdd} onClick={() => void submit()}>
63 添加
65 {t('repo.action.add')}
6466 </Button>
6567 </div>
6668 {normalized && normalized !== draft.trim() ? (
67 <p className="-mt-3 mb-4 text-[11.5px] text-shell-ink-muted">将保存为 <code className="font-mono">{normalized}</code></p>
69 <p className="-mt-3 mb-4 text-[11.5px] text-shell-ink-muted">{t('repo.settings.topicWillSavePrefix')} <code className="font-mono">{normalized}</code></p>
6870 ) : null}
6971
7072 {topics.loading && !topics.data ? (
71 <p className="text-[13px] text-shell-ink-muted">加载主题…</p>
73 <p className="text-[13px] text-shell-ink-muted">{t('repo.settings.topicsLoading')}</p>
7274 ) : names.length === 0 ? (
73 <p className="text-[13px] text-shell-ink-muted">该仓库还没有主题。</p>
75 <p className="text-[13px] text-shell-ink-muted">{t('repo.settings.topicsEmpty')}</p>
7476 ) : (
7577 <ul className="space-y-2">
7678 {names.map(topic => (
@@ -82,7 +84,7 @@
8284 disabled={busy}
8385 onClick={() => void remove(topic.name)}
8486 >
85 <X className="h-3 w-3" aria-hidden /> 移除
87 <X className="h-3 w-3" aria-hidden /> {t('common.action.remove')}
8688 </Button>
8789 </ListRow>
8890 ))}
Modified web/src/features/repo-settings/ui/settings-tab.tsx View web/src/features/repo-settings/ui/settings-tab.tsx
@@ -1,3 +1,4 @@
1import { useT } from '@/shared/i18n'
12import type { RepoDetail } from '@/entities/repo'
23import { REPO_SETTINGS_SECTIONS, type RepoSettingsSection } from '../model/sections'
34import { ArchivesSettings } from './sections/archives-settings'
@@ -36,9 +37,10 @@
3637 onSectionChange,
3738 onSaved,
3839}: SettingsTabProps) {
40 const { t } = useT()
3941 return (
4042 <div className="repo-settings min-h-0">
41 <nav aria-label="仓库设置分类" className="repo-settings-nav mb-5 flex flex-wrap gap-1 rounded-xl border border-shell-hairline bg-shell-surface p-1.5">
43 <nav aria-label={t('repo.settings.navLabel')} className="repo-settings-nav mb-5 flex flex-wrap gap-1 rounded-xl border border-shell-hairline bg-shell-surface p-1.5">
4244 {REPO_SETTINGS_SECTIONS.map(item => (
4345 <button
4446 key={item.id}
Modified web/src/features/repo-settings/ui/use-action.ts View web/src/features/repo-settings/ui/use-action.ts
@@ -1,10 +1,11 @@
11import { useState } from 'react'
2import { t } from '@/shared/i18n'
23import { ApiFailure } from '@/shared/lib'
34
45export function messageOf(err: unknown): string {
56 if (err instanceof ApiFailure) return err.message
67 const text = (err as Error | undefined)?.message
7 return text && text !== 'request failed' ? text : '操作失败,请稍后重试。'
8 return text && text !== 'request failed' ? text : t('repo.error.actionFailed')
89}
910
1011/**
Modified web/src/features/search/ui/global-search-command.tsx View web/src/features/search/ui/global-search-command.tsx
@@ -1,20 +1,25 @@
11import { useEffect, useMemo, useRef, useState } from 'react'
22import { useNavigate } from 'react-router-dom'
33import { FileCode2, GitPullRequest, Hash, Search, X } from 'lucide-react'
4import { useT } from '@/shared/i18n'
45import { search, type SearchHit, type SearchKind } from '@/entities/search'
56
6const kinds: Array<{ id: SearchKind; label: string }> = [
7 { id: 'code', label: '代码' },
8 { id: 'symbol', label: '符号' },
9 { id: 'issue', label: 'Issue' },
10 { id: 'pull', label: 'PR' },
11]
7/** Label keys resolved lazily so the tab strip follows the active locale. */
8const KIND_LABELS: Record<SearchKind, 'search.kind.code' | 'search.kind.symbol' | 'search.kind.issue' | 'search.kind.pull'> = {
9 code: 'search.kind.code',
10 symbol: 'search.kind.symbol',
11 issue: 'search.kind.issue',
12 pull: 'search.kind.pull',
13}
14
15const KINDS: SearchKind[] = ['code', 'symbol', 'issue', 'pull']
1216
1317export function GlobalSearchCommand() {
18 const { t } = useT()
1419 const navigate = useNavigate()
1520 const [open, setOpen] = useState(false)
1621 const [query, setQuery] = useState('')
17 const [selected, setSelected] = useState<SearchKind[]>(kinds.map(item => item.id))
22 const [selected, setSelected] = useState<SearchKind[]>(KINDS)
1823 const [results, setResults] = useState<SearchHit[]>([])
1924 const [loading, setLoading] = useState(false)
2025 const [error, setError] = useState('')
@@ -58,7 +63,7 @@
5863 failure => {
5964 if (!active) return
6065 setResults([])
61 setError(failure instanceof Error ? failure.message : '搜索失败')
66 setError(failure instanceof Error ? failure.message : t('search.failed'))
6267 setLoading(false)
6368 },
6469 )
@@ -67,7 +72,7 @@
6772 active = false
6873 window.clearTimeout(timer)
6974 }
70 }, [open, query, selected])
75 }, [open, query, selected, t])
7176
7277 const toggle = (kind: SearchKind) => {
7378 setSelected(current => current.includes(kind) ? current.filter(value => value !== kind) : [...current, kind])
@@ -92,10 +97,10 @@
9297 type="button"
9398 onClick={() => setOpen(true)}
9499 className="fixed right-4 top-3 z-40 hidden h-9 w-[clamp(200px,22vw,300px)] items-center gap-2 rounded-lg border border-shell-hairline bg-shell-surface px-3 text-[12px] text-shell-ink-muted shadow-sm transition-colors hover:bg-shell-field md:flex"
95 aria-label="打开全局搜索"
100 aria-label={t('search.command.open')}
96101 >
97102 <Search className="h-4 w-4 shrink-0" aria-hidden />
98 <span className="flex-1 truncate text-left">搜索代码、符号、Issue 与 PR…</span>
103 <span className="flex-1 truncate text-left">{t('search.command.placeholder')}</span>
99104 <kbd className="shrink-0 rounded border border-shell-hairline bg-shell-field px-1.5 py-0.5 font-mono text-[10px]">⌘K</kbd>
100105 </button>
101106 {open ? (
@@ -103,7 +108,7 @@
103108 <section
104109 role="dialog"
105110 aria-modal="true"
106 aria-label="全局搜索"
111 aria-label={t('search.command.dialog')}
107112 className="w-full max-w-2xl overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface shadow-2xl"
108113 onMouseDown={event => event.stopPropagation()}
109114 >
@@ -113,31 +118,31 @@
113118 ref={inputRef}
114119 value={query}
115120 onChange={event => setQuery(event.target.value)}
116 placeholder="搜索代码、符号、Issue 与 PR…"
121 placeholder={t('search.command.placeholder')}
117122 className="h-13 min-w-0 flex-1 bg-transparent text-[15px] outline-none placeholder:text-shell-ink-muted"
118123 />
119 <button type="button" onClick={() => setOpen(false)} className="rounded-md p-2 text-shell-ink-muted hover:bg-shell-field hover:text-shell-ink" aria-label="关闭搜索">
124 <button type="button" onClick={() => setOpen(false)} className="rounded-md p-2 text-shell-ink-muted hover:bg-shell-field hover:text-shell-ink" aria-label={t('search.command.close')}>
120125 <X className="h-4 w-4" aria-hidden />
121126 </button>
122127 </div>
123 <div className="flex gap-1 border-b border-shell-hairline px-3 py-2" aria-label="搜索类型">
124 {kinds.map(item => (
128 <div className="flex gap-1 border-b border-shell-hairline px-3 py-2" aria-label={t('search.command.types')}>
129 {KINDS.map(id => (
125130 <button
126 key={item.id}
131 key={id}
127132 type="button"
128 onClick={() => toggle(item.id)}
129 aria-pressed={kindSet.has(item.id)}
130 className={`rounded-md px-2.5 py-1 text-[12px] transition-colors ${kindSet.has(item.id) ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:bg-shell-field'}`}
133 onClick={() => toggle(id)}
134 aria-pressed={kindSet.has(id)}
135 className={`rounded-md px-2.5 py-1 text-[12px] transition-colors ${kindSet.has(id) ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:bg-shell-field'}`}
131136 >
132 {item.label}
137 {t(KIND_LABELS[id])}
133138 </button>
134139 ))}
135140 </div>
136141 <div className="max-h-[58vh] overflow-y-auto p-2">
137 {!query.trim() ? <p className="px-3 py-8 text-center text-[13px] text-shell-ink-muted">输入关键词开始搜索</p> : null}
138 {loading ? <p className="px-3 py-4 text-[13px] text-shell-ink-muted">正在搜索…</p> : null}
142 {!query.trim() ? <p className="px-3 py-8 text-center text-[13px] text-shell-ink-muted">{t('search.command.hint')}</p> : null}
143 {loading ? <p className="px-3 py-4 text-[13px] text-shell-ink-muted">{t('search.command.loading')}</p> : null}
139144 {error ? <p role="alert" className="px-3 py-4 text-[13px] text-[#b42318]">{error}</p> : null}
140 {!loading && !error && query.trim() && results.length === 0 ? <p className="px-3 py-8 text-center text-[13px] text-shell-ink-muted">没有匹配结果</p> : null}
145 {!loading && !error && query.trim() && results.length === 0 ? <p className="px-3 py-8 text-center text-[13px] text-shell-ink-muted">{t('search.empty')}</p> : null}
141146 {results.map(hit => <SearchRow key={`${hit.kind}:${hit.id}`} hit={hit} onOpen={() => openHit(hit)} />)}
142147 </div>
143148 </section>
Modified web/src/features/space-management/ui/space-settings/bans-section.tsx View web/src/features/space-management/ui/space-settings/bans-section.tsx
@@ -13,6 +13,7 @@
1313 useSave,
1414} from '@/shared/ui'
1515import { spaceAdminKey, useAsync } from '@/shared/lib'
16import { t as tr, useT } from '@/shared/i18n'
1617import {
1718 banSpaceUser,
1819 findUserByUsername,
@@ -24,17 +25,23 @@
2425import { userLabel } from './meta'
2526import type { SpaceSectionProps } from './props'
2627
27const BAN_FLAGS = [
28 { key: 'allow_speak', label: '允许在语音频道发言' },
29 { key: 'allow_push_repo', label: '允许推送仓库' },
30 { key: 'allow_comment_any', label: '允许评论任意问题' },
31 { key: 'allow_agent_join', label: '允许其代理加入' },
32] as const
28const BAN_FLAG_KEYS = {
29 allow_speak: 'space.bans.allowSpeak',
30 allow_push_repo: 'space.bans.allowPushRepo',
31 allow_comment_any: 'space.bans.allowCommentAny',
32 allow_agent_join: 'space.bans.allowAgentJoin',
33} as const
34
35const BAN_FLAGS = (Object.keys(BAN_FLAG_KEYS) as Array<keyof typeof BAN_FLAG_KEYS>).map(key => ({
36 key,
37 get label() { return tr(BAN_FLAG_KEYS[key]) },
38}))
3339
3440type BanFlag = (typeof BAN_FLAGS)[number]['key']
3541
3642/** Ban list: who is blocked, until when, and which exceptions they keep. */
3743export function BansSection({ spaceName }: SpaceSectionProps) {
44 const { t } = useT()
3845 const [tick, setTick] = useState(0)
3946 const { busy, notice, error, run } = useSave()
4047 const bans = useAsync(() => listSpaceBans(spaceName), [spaceName, tick], {
@@ -47,10 +54,10 @@
4754
4855 return (
4956 <section>
50 <SectionTitle title="封禁" sub="被封禁的用户无法加入,除非规则中放行了单项能力。" />
57 <SectionTitle title={t('space.settings.sections.bans')} sub={t('space.bans.sub')} />
5158 <div className="settings-card mb-6">
5259 <div className="grid gap-x-4 sm:grid-cols-2">
53 <Field label="用户名">
60 <Field label={t('space.invites.username')}>
5461 <input
5562 className={inputCls}
5663 placeholder="ada"
@@ -58,7 +65,7 @@
5865 onChange={e => setHandle(e.target.value)}
5966 />
6067 </Field>
61 <Field label="到期时间" hint="留空表示永久封禁">
68 <Field label={t('space.bans.expires')} hint={t('space.bans.expiresHint')}>
6269 <input
6370 type="datetime-local"
6471 className={inputCls}
@@ -67,7 +74,7 @@
6774 />
6875 </Field>
6976 </div>
70 <Field label="原因" hint="仅管理员可见,会记录在封禁行上">
77 <Field label={t('space.bans.reason')} hint={t('space.bans.reasonHint')}>
7178 <input
7279 className={inputCls}
7380 maxLength={200}
@@ -91,10 +98,10 @@
9198 setReason('')
9299 setExpires('')
93100 refresh()
94 }, '用户已封禁。')
101 }, t('space.bans.banned'))
95102 }
96103 >
97 封禁用户
104 {t('space.bans.banUser')}
98105 </Button>
99106 {error ? <Alert tone="error">{error}</Alert> : null}
100107 {notice && !error ? <Alert tone="success">{notice}</Alert> : null}
@@ -102,9 +109,9 @@
102109 </div>
103110
104111 {bans.loading ? (
105 <EmptyNote>正在读取封禁列表…</EmptyNote>
112 <EmptyNote>{t('space.bans.loading')}</EmptyNote>
106113 ) : (bans.data ?? []).length === 0 ? (
107 <EmptyNote>没有封禁记录。</EmptyNote>
114 <EmptyNote>{t('space.bans.empty')}</EmptyNote>
108115 ) : (
109116 <div className="space-y-2">
110117 {bans.data!.map(ban => (
@@ -134,6 +141,7 @@
134141 allow_agent_join: ban.allow_agent_join,
135142 }))
136143 const [reason, setReason] = useState(ban.reason ?? '')
144 const { t } = useT()
137145 const { run, busy: rowBusy, notice, error } = useSave()
138146 const userId = ban.user?.user_id
139147
@@ -145,8 +153,8 @@
145153 meta={
146154 <>
147155 {ban.reason ? `${ban.reason} · ` : ''}
148 {ban.expires_at ? `解封于 ${fmtDate(ban.expires_at)}` : '永久'} · 由{' '}
149 {userLabel(ban.banned_by)} 于 {fmtDate(ban.created_at)} 执行
156 {ban.expires_at ? t('space.bans.unbanAt', { date: fmtDate(ban.expires_at) }) : t('space.bans.permanent')} ·{' '}
157 {t('space.bans.by', { by: userLabel(ban.banned_by), date: fmtDate(ban.created_at) })}
150158 </>
151159 }
152160 actions={
@@ -159,16 +167,16 @@
159167 void run(async () => {
160168 await unbanSpaceUser(spaceName, userId)
161169 onSaved()
162 }, '已解除封禁。')
170 }, t('space.bans.unbanned'))
163171 }
164172 >
165 解除封禁
173 {t('space.bans.unban')}
166174 </Button>
167175 ) : null
168176 }
169177 />
170178 <div className="settings-subpanel">
171 <Field label="原因">
179 <Field label={t('space.bans.reason')}>
172180 <input className={inputCls} maxLength={200} value={reason} onChange={e => setReason(e.target.value)} />
173181 </Field>
174182 <div className="grid gap-x-6 sm:grid-cols-2">
@@ -188,13 +196,13 @@
188196 disabled={busy || rowBusy || !userId}
189197 onClick={() =>
190198 void run(async () => {
191 if (!userId) throw new Error('该封禁记录缺少用户标识')
199 if (!userId) throw new Error(t('space.bans.missingUser'))
192200 await updateSpaceBan(spaceName, userId, { reason: reason.trim() || null, ...flags })
193201 onSaved()
194 }, '封禁设置已更新。')
202 }, t('space.bans.saved'))
195203 }
196204 >
197 保存例外
205 {t('space.bans.saveExceptions')}
198206 </Button>
199207 {notice ? <Alert tone="success">{notice}</Alert> : null}
200208 {error ? <Alert tone="error">{error}</Alert> : null}
Modified web/src/features/space-management/ui/space-settings/channel-permissions-section.tsx View web/src/features/space-management/ui/space-settings/channel-permissions-section.tsx
@@ -12,6 +12,7 @@
1212 useSave,
1313} from '@/shared/ui'
1414import { spaceAdminKey, spaceAdminMembersKey, spaceDirKey, useAsync } from '@/shared/lib'
15import { t as tr, useT } from '@/shared/i18n'
1516import { fetchSpaceDir } from '../../model/api'
1617import {
1718 findUserByUsername,
@@ -35,17 +36,23 @@
3536const stanceOf = (allow: number, deny: number): Stance =>
3637 deny & PERM_DIR_VISIBLE ? 'deny' : allow & PERM_DIR_VISIBLE ? 'allow' : 'inherit'
3738
38const STANCES: { value: Stance; label: string }[] = [
39 { value: 'deny', label: '隐藏' },
40 { value: 'inherit', label: '跟随' },
41 { value: 'allow', label: '可见' },
42]
39const STANCE_KEYS = {
40 deny: 'space.channelPermissions.stance.deny',
41 inherit: 'space.channelPermissions.stance.inherit',
42 allow: 'space.channelPermissions.stance.allow',
43} as const
44
45const STANCES: { value: Stance; label: string }[] = (Object.keys(STANCE_KEYS) as Stance[]).map(value => ({
46 value,
47 get label() { return tr(STANCE_KEYS[value]) },
48}))
4349
4450/**
4551 * Per-node visibility overrides. The server stores a single mask bit today
4652 * (`PERM_DIR_VISIBLE`), so the editor is a hide / inherit / show switch.
4753 */
4854export function ChannelPermissionsSection({ spaceName, perms, isOwner }: SpaceSectionProps) {
55 const { t, tc } = useT()
4956 const [tick, setTick] = useState(0)
5057 const [dirId, setDirId] = useState('')
5158 const [revision, setRevision] = useState(0)
@@ -102,19 +109,19 @@
102109 else await setDirUserOverwrite(spaceName, active, id, mask)
103110 }
104111 setRevision(r => r + 1)
105 }, '节点权限已更新。')
112 }, t('space.channelPermissions.saved'))
106113
107114 return (
108115 <section>
109 <SectionTitle title="频道权限" sub="对单个文件夹或频道隐藏、或强制显示给特定角色与成员。" />
116 <SectionTitle title={t('space.channelPermissions.title')} sub={t('space.channelPermissions.sub2')} />
110117 <div className="mb-5 flex flex-wrap items-center gap-2">
111118 <select
112119 className={`${inputCls} h-9 w-auto max-w-[340px]`}
113120 value={active}
114121 onChange={e => setDirId(e.target.value)}
115 aria-label="选择节点"
122 aria-label={t('space.channelPermissions.selectNode')}
116123 >
117 {nodes.length === 0 ? <option value="">目录为空</option> : null}
124 {nodes.length === 0 ? <option value="">{t('space.channelPermissions.emptyTree')}</option> : null}
118125 {nodes.map(({ node, depth }) => (
119126 <option key={node.id} value={node.id}>
120127 {`${' '.repeat(depth)}${node.kind === 'Dir' ? '▸ ' : '· '}${node.name}`}
@@ -133,11 +140,11 @@
133140 refresh()
134141 return result
135142 },
136 result => `目录结构已检查:扫描 ${result.scanned} 个节点,修正 ${result.fixed} 个。`,
143 result => t('space.channelPermissions.repaired', result),
137144 )
138145 }
139146 >
140 修复目录结构
147 {t('space.channelPermissions.repair')}
141148 </Button>
142149 ) : null}
143150 </div>
@@ -147,7 +154,7 @@
147154 </div>
148155 ) : null}
149156 {!activeNode ? (
150 <EmptyNote>这个社区还没有文件夹或频道。</EmptyNote>
157 <EmptyNote>{t('space.channelPermissions.emptyNote')}</EmptyNote>
151158 ) : (
152159 <>
153160 <div className="settings-card mb-5">
@@ -155,12 +162,12 @@
155162 title={activeNode.name}
156163 meta={
157164 <>
158 {activeNode.kind === 'Dir' ? '文件夹' : '条目'} · 排序 {activeNode.position} ·
159 创建于 {fmtDate(activeNode.created_at)}
160 {activeNode.is_archived ? ' · 已归档' : ''}
165 {activeNode.kind === 'Dir' ? t('space.channelPermissions.folder') : t('space.channelPermissions.entry')} · {t('space.channelPermissions.position', { position: activeNode.position })} ·{' '}
166 {t('space.roles.createdAt', { date: fmtDate(activeNode.created_at) })}
167 {activeNode.is_archived ? ` · ${t('space.channelPermissions.archived')}` : ''}
161168 </>
162169 }
163 actions={<Chip>{activeNode.topic ? activeNode.topic : '无主题'}</Chip>}
170 actions={<Chip>{activeNode.topic ? activeNode.topic : t('space.channelPermissions.noTopic')}</Chip>}
164171 />
165172 {canRename ? (
166173 <RenameField
@@ -175,9 +182,9 @@
175182 ) : null}
176183 </div>
177184
178 <h3 className="mb-2 text-[14px] font-bold">按角色</h3>
185 <h3 className="mb-2 text-[14px] font-bold">{t('space.channelPermissions.byRole')}</h3>
179186 {(groups.data ?? []).length === 0 ? (
180 <EmptyNote>还没有角色。</EmptyNote>
187 <EmptyNote>{t('space.roles.emptyShort')}</EmptyNote>
181188 ) : (
182189 <div className="mb-8 space-y-2">
183190 {(groups.data ?? []).map(group => {
@@ -186,7 +193,7 @@
186193 <StanceRow
187194 key={group.id}
188195 title={group.unicode_emoji ? `${group.unicode_emoji} ${group.name}` : group.name}
189 meta={`${group.permissions.length} 项权限`}
196 meta={tc('space.roles.permissionCount', group.permissions.length)}
190197 stance={stanceOf(row?.allow_mask ?? 0, row?.deny_mask ?? 0)}
191198 busy={busy || overwrite.loading || !canOverwrite}
192199 onChange={value => setStance('group', group.id, value)}
@@ -196,7 +203,7 @@
196203 </div>
197204 )}
198205
199 <h3 className="mb-2 text-[14px] font-bold">按成员</h3>
206 <h3 className="mb-2 text-[14px] font-bold">{t('space.channelPermissions.byMember')}</h3>
200207 <div className="space-y-2">
201208 {(members.data ?? []).map(member => {
202209 const userId = member.user?.user_id
@@ -206,7 +213,7 @@
206213 <StanceRow
207214 key={userId}
208215 title={userLabel(member.user)}
209 meta={member.alias_name ?? '默认昵称'}
216 meta={member.alias_name ?? t('space.channelPermissions.defaultAlias')}
210217 stance={stanceOf(row?.allow_mask ?? 0, row?.deny_mask ?? 0)}
211218 busy={busy || overwrite.loading || !canOverwrite}
212219 onChange={value => setStance('user', userId, value)}
@@ -251,10 +258,11 @@
251258 run: ReturnType<typeof useSave>['run']
252259 onRenamed: () => void
253260}) {
261 const { t } = useT()
254262 const [name, setName] = useState(current)
255263 return (
256264 <div className="settings-subpanel">
257 <Field label="重命名" hint="服务端只允许修改名称">
265 <Field label={t('space.channelPermissions.rename')} hint={t('space.channelPermissions.renameHint')}>
258266 <div className="flex flex-wrap items-center gap-2">
259267 <input
260268 className={inputCls}
@@ -270,10 +278,10 @@
270278 void run(async () => {
271279 await renameSpaceDir(spaceName, nodeId, name.trim())
272280 onRenamed()
273 }, '节点已重命名。')
281 }, t('space.channelPermissions.renamed'))
274282 }
275283 >
276 保存名称
284 {t('space.channelPermissions.saveName')}
277285 </Button>
278286 </div>
279287 </Field>
@@ -294,13 +302,14 @@
294302 busy: boolean
295303 onChange: (value: Stance) => void
296304}) {
305 const { t } = useT()
297306 return (
298307 <div className="settings-card">
299308 <SettingRow
300309 title={title}
301310 meta={meta}
302311 actions={
303 <span className="settings-segment-group" role="group" aria-label={`${title} 可见性`}>
312 <span className="settings-segment-group" role="group" aria-label={t('space.channelPermissions.visibility', { title })}>
304313 {STANCES.map(option => (
305314 <button
306315 key={option.value}
@@ -339,12 +348,13 @@
339348 current: { user_id: string }[]
340349 onApplied: () => void
341350}) {
351 const { t } = useT()
342352 const [handle, setHandle] = useState('')
343353 const { run, busy: rowBusy, notice, error } = useSave()
344354 return (
345355 <div className="settings-card mt-3">
346356 <div className="settings-subpanel is-flat">
347 <Field label="按用户名添加例外" hint="社区成员列表不可用时,直接输入用户名">
357 <Field label={t('space.channelPermissions.addByUsername')} hint={t('space.channelPermissions.addByUsernameHint')}>
348358 <div className="flex flex-wrap items-center gap-2">
349359 <input
350360 className={inputCls}
@@ -360,7 +370,7 @@
360370 void run(async () => {
361371 const found = await findUserByUsername(handle.trim())
362372 if (current.some(row => row.user_id === found.user_id)) {
363 throw new Error('该成员已经在例外列表里。')
373 throw new Error(t('space.channelPermissions.alreadyExceptional'))
364374 }
365375 await setDirUserOverwrite(spaceName, dirId, found.user_id, {
366376 allow_mask: PERM_DIR_VISIBLE,
@@ -368,10 +378,10 @@
368378 })
369379 setHandle('')
370380 onApplied()
371 }, '已添加成员例外。')
381 }, t('space.channelPermissions.exceptionAdded'))
372382 }
373383 >
374 添加为可见
384 {t('space.channelPermissions.addVisible')}
375385 </Button>
376386 </div>
377387 </Field>
Modified web/src/features/space-management/ui/space-settings/danger-section.tsx View web/src/features/space-management/ui/space-settings/danger-section.tsx
@@ -1,10 +1,12 @@
11import { useState } from 'react'
22import { Alert, Button, Field, SectionTitle, inputCls, useSave } from '@/shared/ui'
3import { useT } from '@/shared/i18n'
34import { deleteSpace } from '../../model/space-admin'
45import type { SpaceSectionProps } from './props'
56
67/** Owner-only teardown, kept away from every other form on purpose. */
78export function DangerSection({ spaceName, space, isOwner, onDeleted }: SpaceSectionProps & { onDeleted: () => void }) {
9 const { t } = useT()
810 const [confirm, setConfirm] = useState('')
911 const [armed, setArmed] = useState(false)
1012 const { busy, error, run } = useSave()
@@ -12,19 +14,19 @@
1214 if (!isOwner) {
1315 return (
1416 <section>
15 <SectionTitle title="危险操作" sub="删除社区等不可逆操作只对拥有者开放。" />
16 <Alert tone="info">你不是这个社区的拥有者,无法删除它。</Alert>
17 <SectionTitle title={t('space.settings.danger')} sub={t('space.danger.ownerSub')} />
18 <Alert tone="info">{t('space.danger.notOwner')}</Alert>
1719 </section>
1820 )
1921 }
2022
2123 return (
2224 <section>
23 <SectionTitle title="危险操作" sub="删除后社区立即对外不可见,恢复需要联系平台管理员。" />
25 <SectionTitle title={t('space.settings.danger')} sub={t('space.danger.sub')} />
2426 <div className="settings-card is-danger">
25 <p className="text-[13.5px] font-semibold">删除 {space.name}</p>
27 <p className="text-[13.5px] font-semibold">{t('space.danger.deleteName', { name: space.name })}</p>
2628 <p className="mt-1 text-[12.5px] text-shell-ink-muted">
27 仓库、问题集与频道会一并从这个地址下线。成员数 {space.members_count}。
29 {t('space.danger.description', { count: space.members_count })}
2830 </p>
2931 {!armed ? (
3032 <Button
@@ -32,11 +34,11 @@
3234 disabled={busy}
3335 onClick={() => setArmed(true)}
3436 >
35 删除社区
37 {t('space.settings.deleteSpace')}
3638 </Button>
3739 ) : (
3840 <div className="mt-4">
39 <Field label={`输入社区名称 ${space.name} 以确认`}>
41 <Field label={t('space.danger.confirmLabel', { name: space.name })}>
4042 <input
4143 className={inputCls}
4244 value={confirm}
@@ -50,10 +52,10 @@
5052 disabled={busy || confirm.trim().toLowerCase() !== spaceName.toLowerCase()}
5153 onClick={() => void run(() => deleteSpace(spaceName).then(onDeleted))}
5254 >
53 {busy ? '删除中…' : '确认删除'}
55 {busy ? t('space.danger.deleting') : t('space.danger.confirm')}
5456 </Button>
5557 <Button variant="ghost" className="h-9" disabled={busy} onClick={() => setArmed(false)}>
56 取消
58 {t('common.action.cancel')}
5759 </Button>
5860 {error ? <Alert tone="error">{error}</Alert> : null}
5961 </div>
Modified web/src/features/space-management/ui/space-settings/general-section.tsx View web/src/features/space-management/ui/space-settings/general-section.tsx
@@ -11,6 +11,7 @@
1111 inputCls,
1212 useSave,
1313} from '@/shared/ui'
14import { useT } from '@/shared/i18n'
1415import { JOIN_LEVEL_OPTIONS, slug } from './meta'
1516import type { SpaceSectionProps } from './props'
1617
@@ -38,6 +39,7 @@
3839
3940/** Identity, description and the join/visibility switches. Owner may write. */
4041export function GeneralSection(props: SpaceSectionProps) {
42 const { t } = useT()
4143 const { space, isOwner, onSpaceUpdated } = props
4244 // Read once per open: the modal unmounts a section when you leave it, so
4345 // reopening always picks up whatever the server holds at that moment.
@@ -62,15 +64,15 @@
6264 member_must_enable_2fa: draft.member_must_enable_2fa,
6365 })
6466 onSpaceUpdated()
65 }, '社区资料已保存。')
67 }, t('space.general.saved'))
6668
6769 return (
6870 <section>
69 <SectionTitle title="社区资料" sub="名称、介绍与加入方式。" />
71 <SectionTitle title={t('space.general.title')} sub={t('space.general.sub')} />
7072 {!isOwner ? (
71 <Alert tone="info">只有社区拥有者可以修改这些内容,你当前只能查看。</Alert>
73 <Alert tone="info">{t('space.general.ownerOnly')}</Alert>
7274 ) : null}
73 <Field label="社区名称" hint="仅限小写字母、数字与连字符,最长 64 个字符">
75 <Field label={t('space.general.name')} hint={t('space.general.nameHint')}>
7476 <input
7577 className={inputCls}
7678 value={draft.name}
@@ -79,7 +81,7 @@
7981 onChange={e => set('name', slug(e.target.value))}
8082 />
8183 </Field>
82 <Field label="简介" hint="展示在社区卡片与目录页,最长 512 个字符">
84 <Field label={t('space.general.description')} hint={t('space.general.descriptionHint')}>
8385 <textarea
8486 className={`${inputCls} h-auto resize-none py-2 leading-relaxed`}
8587 rows={3}
@@ -89,32 +91,32 @@
8991 onChange={e => set('description', e.target.value)}
9092 />
9193 </Field>
92 <Field label="新人欢迎语" hint="成员加入后出现在频道里的一条提示">
94 <Field label={t('space.general.welcome')} hint={t('space.general.welcomeHint')}>
9395 <textarea
9496 className={`${inputCls} h-auto resize-none py-2 leading-relaxed`}
9597 rows={2}
9698 maxLength={512}
9799 disabled={!isOwner}
98100 value={draft.welcome_message}
99 placeholder="欢迎回到社区,请先阅读频道主题再发言。"
101 placeholder={t('space.general.welcomePlaceholder')}
100102 onChange={e => set('welcome_message', e.target.value)}
101103 />
102104 </Field>
103105
104 <h3 className="mb-3 mt-8 text-[14px] font-bold">访问与加入</h3>
106 <h3 className="mb-3 mt-8 text-[14px] font-bold">{t('space.general.access')}</h3>
105107 <Toggle
106108 checked={draft.is_private}
107109 onChange={v => set('is_private', v)}
108 label="私有社区(仅成员可见)"
110 label={t('space.general.private')}
109111 disabled={!isOwner}
110112 />
111113 <Toggle
112114 checked={draft.allow_join}
113115 onChange={v => set('allow_join', v)}
114 label="允许主动申请加入"
116 label={t('space.general.allowJoin')}
115117 disabled={!isOwner}
116118 />
117 <Field label="加入门槛">
119 <Field label={t('space.general.joinLevel')}>
118120 <select
119121 className={inputCls}
120122 disabled={!isOwner || !draft.allow_join}
@@ -131,43 +133,43 @@
131133 <Toggle
132134 checked={draft.allow_invite}
133135 onChange={v => set('allow_invite', v)}
134 label="允许成员发出邀请"
136 label={t('space.general.allowInvite')}
135137 disabled={!isOwner}
136138 />
137139 <Toggle
138140 checked={draft.member_must_enable_2fa}
139141 onChange={v => set('member_must_enable_2fa', v)}
140 label="成员必须开启两步验证"
142 label={t('space.general.require2fa')}
141143 disabled={!isOwner}
142144 />
143145
144146 <div className="mt-8">
145 <SaveBar busy={busy} notice={notice} error={error} onSave={save} label="保存资料" />
147 <SaveBar busy={busy} notice={notice} error={error} onSave={save} label={t('space.general.save')} />
146148 </div>
147149
148 <h3 className="mb-3 mt-10 text-[14px] font-bold">只读信息</h3>
150 <h3 className="mb-3 mt-10 text-[14px] font-bold">{t('space.general.readonly')}</h3>
149151 <dl className="settings-facts">
150152 <div>
151 <dt>社区 ID</dt>
153 <dt>{t('space.general.id')}</dt>
152154 <dd>
153155 <code>{space.id}</code>
154156 <CopyButton text={space.id} />
155157 </dd>
156158 </div>
157159 <div>
158 <dt>拥有者</dt>
160 <dt>{t('space.general.owner')}</dt>
159161 <dd>{space.owner?.display_name || space.owner?.username || '—'}</dd>
160162 </div>
161163 <div>
162 <dt>成员数</dt>
164 <dt>{t('space.general.members')}</dt>
163165 <dd>{space.members_count}</dd>
164166 </div>
165167 <div>
166 <dt>状态</dt>
167 <dd>{space.is_active ? '正常' : '已停用'}</dd>
168 <dt>{t('space.general.status')}</dt>
169 <dd>{space.is_active ? t('space.general.active') : t('space.general.inactive')}</dd>
168170 </div>
169171 <div>
170 <dt>创建 / 更新</dt>
172 <dt>{t('space.general.createdUpdated')}</dt>
171173 <dd>
172174 {fmtDate(space.created_at)} · {fmtDate(space.updated_at)}
173175 </dd>
Modified web/src/features/space-management/ui/space-settings/images-section.tsx View web/src/features/space-management/ui/space-settings/images-section.tsx
@@ -9,6 +9,7 @@
99 useSave,
1010} from '@/shared/ui'
1111import { spaceAdminKey, useAsync } from '@/shared/lib'
12import { useT } from '@/shared/i18n'
1213import { deleteSpaceImage, getSpaceImage, uploadSpaceImage, type SpaceImage } from '../../model/space-admin'
1314import { IMAGE_KINDS } from './meta'
1415import type { SpaceSectionProps } from './props'
@@ -17,6 +18,7 @@
1718
1819/** The four images a space can carry, each behind `/{kind}` routes. */
1920export function ImagesSection({ spaceName, onSpaceUpdated }: SpaceSectionProps) {
21 const { t } = useT()
2022 const [tick, setTick] = useState(0)
2123 const images = useAsync(async () => {
2224 const settled = await Promise.allSettled(IMAGE_KINDS.map(item => getSpaceImage(spaceName, item.kind)))
@@ -30,10 +32,10 @@
3032
3133 return (
3234 <section>
33 <SectionTitle title="图标与横幅" sub="图片以 base64 上传,单个文件不超过 5 MB。" />
35 <SectionTitle title={t('space.settings.sections.images')} sub={t('space.images.sub')} />
3436 {images.error ? (
3537 <div className="mb-4">
36 <Alert tone="error">读取图片失败,可能没有管理频道的权限。</Alert>
38 <Alert tone="error">{t('space.images.loadFailed')}</Alert>
3739 </div>
3840 ) : null}
3941 <div className="space-y-2">
@@ -74,12 +76,13 @@
7476 onSaved: () => void
7577 onSpaceUpdated: () => void
7678}) {
79 const { t } = useT()
7780 const inputRef = useRef<HTMLInputElement>(null)
7881 const { busy, notice, error, run } = useSave()
7982
8083 const pick = (file: File) =>
8184 void run(async () => {
82 if (file.size > MAX_BYTES) throw new Error('图片超过 5 MB,请压缩后再上传。')
85 if (file.size > MAX_BYTES) throw new Error(t('space.images.tooLarge'))
8386 const data = await fileToBase64(file)
8487 const size = await readImageSize(file)
8588 await uploadSpaceImage(spaceName, kind, {
@@ -91,7 +94,7 @@
9194 onSaved()
9295 // The header avatar and the rail tile read the space row, not this call.
9396 onSpaceUpdated()
94 }, `${label}已更新。`)
97 }, t('space.images.updated', { label }))
9598
9699 return (
97100 <div className="settings-card">
@@ -108,7 +111,7 @@
108111 aria-hidden
109112 className='grid h-[46px] w-[76px] shrink-0 place-items-center rounded-md border border-dashed border-shell-hairline bg-shell-field text-[11px] text-shell-ink-muted'
110113 >
111 {loading ? '读取中' : '未设置'}
114 {loading ? t('space.images.loading') : t('space.images.unset')}
112115 </span>
113116 )
114117 }
@@ -139,7 +142,7 @@
139142 disabled={busy}
140143 onClick={() => inputRef.current?.click()}
141144 >
142 {image ? '更换' : '上传'}
145 {image ? t('space.images.replace') : t('common.action.upload')}
143146 </Button>
144147 {image ? (
145148 <Button
@@ -151,10 +154,10 @@
151154 await deleteSpaceImage(spaceName, kind)
152155 onSaved()
153156 onSpaceUpdated()
154 }, `${label}已删除。`)
157 }, t('space.images.deleted', { label }))
155158 }
156159 >
157 删除
160 {t('common.action.delete')}
158161 </Button>
159162 ) : null}
160163 </>
Modified web/src/features/space-management/ui/space-settings/invites-section.tsx View web/src/features/space-management/ui/space-settings/invites-section.tsx
@@ -13,6 +13,7 @@
1313 useSave,
1414} from '@/shared/ui'
1515import { spaceAdminKey, useAsync } from '@/shared/lib'
16import { t as tr, useT } from '@/shared/i18n'
1617import {
1718 acceptSpaceInvite,
1819 createSpaceInvite,
@@ -25,22 +26,32 @@
2526import { userLabel } from './meta'
2627import type { SpaceSectionProps } from './props'
2728
28type InviteState = '待接受' | '已接受' | '已撤销' | '已过期'
29type InviteState = 'pending' | 'accepted' | 'revoked' | 'expired'
2930
3031function inviteState(row: {
3132 accepted_at?: string | null
3233 revoked_at?: string | null
3334 expires_at?: string | null
3435}): InviteState {
35 if (row.accepted_at) return '已接受'
36 if (row.revoked_at) return '已撤销'
36 if (row.accepted_at) return 'accepted'
37 if (row.revoked_at) return 'revoked'
3738 const expires = parseDate(row.expires_at)
38 if (expires && expires.getTime() < Date.now()) return '已过期'
39 return '待接受'
39 if (expires && expires.getTime() < Date.now()) return 'expired'
40 return 'pending'
4041}
4142
43const INVITE_STATE_KEYS = {
44 pending: 'space.invites.state.pending',
45 accepted: 'space.invites.state.accepted',
46 revoked: 'space.invites.state.revoked',
47 expired: 'space.invites.state.expired',
48} as const
49
50const inviteStateLabel = (state: InviteState) => tr(INVITE_STATE_KEYS[state])
51
4252/** Outstanding invitations, and the form that creates them. */
4353export function InvitesSection({ spaceName }: SpaceSectionProps) {
54 const { t } = useT()
4455 const [tick, setTick] = useState(0)
4556 const { busy, notice, error, run } = useSave()
4657 const invites = useAsync(() => listSpaceInvites(spaceName), [spaceName, tick], {
@@ -55,15 +66,15 @@
5566 const [groupIds, setGroupIds] = useState<Set<string>>(() => new Set())
5667 const refresh = () => setTick(t => t + 1)
5768
58 const active = (invites.data ?? []).filter(row => inviteState(row) === '待接受')
59 const history = (invites.data ?? []).filter(row => inviteState(row) !== '待接受')
69 const active = (invites.data ?? []).filter(row => inviteState(row) === 'pending')
70 const history = (invites.data ?? []).filter(row => inviteState(row) !== 'pending')
6071
6172 const send = () =>
6273 void run(async () => {
6374 const trimmed = value.trim()
64 if (!trimmed) throw new Error('请填写邮箱或用户名。')
75 if (!trimmed) throw new Error(t('space.invites.missingTarget'))
6576 // The server rejects an invite that grants no role at all.
66 if (groupIds.size === 0) throw new Error('请至少选择一个要授予的角色。')
77 if (groupIds.size === 0) throw new Error(t('space.invites.missingRole'))
6778 const cmd = {
6879 ...(mode === 'email' ? { email: trimmed } : {}),
6980 ...(mode === 'user' ? { user_id: (await findUserByUsername(trimmed)).user_id } : {}),
@@ -75,16 +86,13 @@
7586 setExpires('')
7687 setGroupIds(new Set())
7788 refresh()
78 }, '邀请已发出。')
89 }, t('space.invites.created'))
7990
8091 return (
8192 <section>
82 <SectionTitle
83 title="邀请"
84 sub="接受后自动成为成员并获得所选角色;角色至少要有一个是必须的。"
85 />
93 <SectionTitle title={t('space.invites.title')} sub={t('space.invites.sub')} />
8694 <div className="settings-card mb-5">
87 <div className="settings-segment-group mb-4" role="group" aria-label="邀请方式">
95 <div className="settings-segment-group mb-4" role="group" aria-label={t('space.invites.mode')}>
8896 {(['email', 'user'] as const).map(kind => (
8997 <button
9098 key={kind}
@@ -93,12 +101,12 @@
93101 aria-pressed={mode === kind}
94102 className="settings-segment"
95103 >
96 {kind === 'email' ? '按邮箱' : '按用户名'}
104 {kind === 'email' ? t('space.invites.byEmail') : t('space.invites.byUsername')}
97105 </button>
98106 ))}
99107 </div>
100108 <div className="grid gap-x-4 sm:grid-cols-2">
101 <Field label={mode === 'email' ? '邮箱地址' : '用户名'} hint="两者只需填写一个">
109 <Field label={mode === 'email' ? t('space.invites.email') : t('space.invites.username')} hint={t('space.invites.oneOf')}>
102110 <input
103111 className={`${inputCls} max-w-none`}
104112 type={mode === 'email' ? 'email' : 'text'}
@@ -107,7 +115,7 @@
107115 onChange={e => setValue(e.target.value)}
108116 />
109117 </Field>
110 <Field label="过期时间" hint="留空表示长期有效">
118 <Field label={t('space.invites.expires')} hint={t('space.invites.expiresHint')}>
111119 <input
112120 type="datetime-local"
113121 className={`${inputCls} max-w-none`}
@@ -119,7 +127,7 @@
119127 {(groups.data ?? []).length > 0 ? (
120128 <div className="mb-4">
121129 <p className="mb-2 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
122 接受后自动授予角色 · 必选
130 {t('space.invites.grantLabel')}
123131 </p>
124132 <div className="flex flex-wrap gap-2">
125133 {groups.data!.map(group => {
@@ -149,18 +157,18 @@
149157 ) : null}
150158 <div className="flex flex-wrap items-center gap-3">
151159 <Button className="h-9" disabled={busy || !value.trim()} onClick={send}>
152 发送邀请
160 {t('space.invites.send')}
153161 </Button>
154162 {error ? <Alert tone="error">{error}</Alert> : null}
155163 {notice && !error ? <Alert tone="success">{notice}</Alert> : null}
156164 </div>
157165 </div>
158166
159 <h3 className="mb-2 text-[14px] font-bold">进行中的邀请</h3>
167 <h3 className="mb-2 text-[14px] font-bold">{t('space.invites.activeTitle')}</h3>
160168 {invites.loading ? (
161 <EmptyNote>正在读取邀请…</EmptyNote>
169 <EmptyNote>{t('space.invites.loading')}</EmptyNote>
162170 ) : active.length === 0 ? (
163 <EmptyNote>没有待接受的邀请。</EmptyNote>
171 <EmptyNote>{t('space.invites.nonePending')}</EmptyNote>
164172 ) : (
165173 <div className="mb-8 space-y-2">
166174 {active.map(row => (
@@ -171,7 +179,7 @@
171179 <RedeemInvite busy={busy} />
172180 {history.length > 0 ? (
173181 <>
174 <h3 className="mb-2 text-[14px] font-bold">历史邀请</h3>
182 <h3 className="mb-2 text-[14px] font-bold">{t('space.invites.historyTitle')}</h3>
175183 <div className="space-y-2">
176184 {history.map(row => (
177185 <InviteRow key={row.id} row={row} spaceName={spaceName} busy={busy} onSaved={refresh} groups={groups.data ?? []} />
@@ -196,8 +204,9 @@
196204 onSaved: () => void
197205 groups: SpaceGroup[]
198206}) {
207 const { t } = useT()
199208 const state = inviteState(row)
200 const label = row.user ? userLabel(row.user) : row.email || '未填写邮箱'
209 const label = row.user ? userLabel(row.user) : row.email || t('space.invites.noEmail')
201210 const groupNames = row.groups
202211 .map(id => groups.find(group => group.id === id)?.name)
203212 .filter((name): name is string => Boolean(name))
@@ -207,15 +216,15 @@
207216 title={label}
208217 meta={
209218 <>
210 {state} · 创建于 {fmtDate(row.created_at)}
211 {row.expires_at ? ` · 过期于 ${fmtDate(row.expires_at)}` : ''}
212 {row.accepted_at ? ` · 接受于 ${fmtDate(row.accepted_at)}` : ''}
219 {inviteStateLabel(state)} · {t('space.roles.createdAt', { date: fmtDate(row.created_at) })}
220 {row.expires_at ? ` · ${t('space.invites.expiresOn', { date: fmtDate(row.expires_at) })}` : ''}
221 {row.accepted_at ? ` · ${t('space.invites.acceptedOn', { date: fmtDate(row.accepted_at) })}` : ''}
213222 </>
214223 }
215224 actions={
216225 <>
217 {groupNames.length > 0 ? <Chip>{groupNames.join('、')}</Chip> : null}
218 {state === '待接受' ? (
226 {groupNames.length > 0 ? <Chip>{groupNames.join(', ')}</Chip> : null}
227 {state === 'pending' ? (
219228 <Button
220229 variant="secondary"
221230 className="h-8 text-[12.5px]"
@@ -224,7 +233,7 @@
224233 void revoke(spaceName, row.id, onSaved)
225234 }
226235 >
227 撤销
236 {t('space.invites.revoke')}
228237 </Button>
229238 ) : null}
230239 </>
@@ -240,22 +249,20 @@
240249
241250/** A code from an email link works whether or not you are already a member. */
242251function RedeemInvite({ busy }: { busy: boolean }) {
252 const { t } = useT()
243253 const [code, setCode] = useState('')
244254 const { run, busy: rowBusy, notice, error } = useSave()
245255 return (
246256 <div className="mt-8 border-t border-shell-hairline pt-5">
247 <h3 className="mb-1 text-[14px] font-bold">使用邀请码</h3>
248 <p className="mb-3 text-[12.5px] text-shell-ink-muted">
249 接受码只写在邀请邮件里,接口不会返回它。若对方没有已验证邮箱,收信人会拿不到链接,
250 这时改用「按用户名」邀请,对方会在站内通知里看到。
251 </p>
257 <h3 className="mb-1 text-[14px] font-bold">{t('space.invites.redeemTitle')}</h3>
258 <p className="mb-3 text-[12.5px] text-shell-ink-muted">{t('space.invites.redeemNote')}</p>
252259 <div className="flex flex-wrap items-center gap-2">
253260 <input
254261 className={`${inputCls} h-9 max-w-[300px]`}
255 placeholder="邀请码"
262 placeholder={t('space.invites.code')}
256263 value={code}
257264 onChange={e => setCode(e.target.value)}
258 aria-label="邀请码"
265 aria-label={t('space.invites.code')}
259266 />
260267 <Button
261268 variant="secondary"
@@ -265,10 +272,10 @@
265272 void run(async () => {
266273 await acceptSpaceInvite({ code: code.trim() })
267274 setCode('')
268 }, '邀请码已使用。')
275 }, t('space.invites.redeemed'))
269276 }
270277 >
271 加入
278 {t('space.join')}
272279 </Button>
273280 </div>
274281 {notice ? <div className="mt-3">
Modified web/src/features/space-management/ui/space-settings/join-rules-section.tsx View web/src/features/space-management/ui/space-settings/join-rules-section.tsx
@@ -13,6 +13,7 @@
1313 useSave,
1414} from '@/shared/ui'
1515import { spaceAdminKey, useAsync } from '@/shared/lib'
16import { useT } from '@/shared/i18n'
1617import {
1718 createSpaceJoinRule,
1819 deleteSpaceJoinRule,
@@ -29,6 +30,7 @@
2930 * comment) adds the reactor to the rule's group.
3031 */
3132export function JoinRulesSection({ spaceName }: SpaceSectionProps) {
33 const { t } = useT()
3234 const [tick, setTick] = useState(0)
3335 const { busy, notice, error, run } = useSave()
3436 const rules = useAsync(() => listSpaceJoinRules(spaceName), [spaceName, tick], {
@@ -39,26 +41,23 @@
3941 })
4042 const [draft, setDraft] = useState({ group_id: '', trigger: JOIN_TRIGGER_OPTIONS[0].value, matcher: '' })
4143 const refresh = () => setTick(t => t + 1)
42 const groupName = (id: string) => groups.data?.find(group => group.id === id)?.name ?? '已删除的角色'
44 const groupName = (id: string) => groups.data?.find(group => group.id === id)?.name ?? t('space.joinRules.deletedRole')
4345
4446 return (
4547 <section>
46 <SectionTitle
47 title="加入规则"
48 sub="满足条件的人自动获得角色,用于把问题集的热心参与者升级为成员。"
49 />
48 <SectionTitle title={t('space.settings.sections.join')} sub={t('space.joinRules.sub')} />
5049 {(groups.data ?? []).length === 0 ? (
51 <EmptyNote>先在「角色」里创建一个角色,才能配置规则。</EmptyNote>
50 <EmptyNote>{t('space.joinRules.needRole')}</EmptyNote>
5251 ) : (
5352 <div className="settings-card mb-6">
5453 <div className="grid gap-x-4 sm:grid-cols-3">
55 <Field label="授予角色">
54 <Field label={t('space.joinRules.grantRole')}>
5655 <select
5756 className={inputCls}
5857 value={draft.group_id}
5958 onChange={e => setDraft({ ...draft, group_id: e.target.value })}
6059 >
61 <option value="">选择角色…</option>
60 <option value="">{t('space.joinRules.selectRole')}</option>
6261 {groups.data!.map(group => (
6362 <option key={group.id} value={group.id}>
6463 {group.name}
@@ -66,7 +65,7 @@
6665 ))}
6766 </select>
6867 </Field>
69 <Field label="触发方式">
68 <Field label={t('space.joinRules.trigger')}>
7069 <select
7170 className={inputCls}
7271 value={draft.trigger}
@@ -79,7 +78,7 @@
7978 ))}
8079 </select>
8180 </Field>
82 <Field label="匹配内容" hint="表情名称,例如 thumbs_up">
81 <Field label={t('space.joinRules.matcher')} hint={t('space.joinRules.matcherHint')}>
8382 <input
8483 className={inputCls}
8584 value={draft.matcher}
@@ -102,10 +101,10 @@
102101 })
103102 setDraft({ ...draft, matcher: '' })
104103 refresh()
105 }, '规则已创建。')
104 }, t('space.joinRules.created'))
106105 }
107106 >
108 新建规则
107 {t('space.joinRules.create')}
109108 </Button>
110109 {error ? <Alert tone="error">{error}</Alert> : null}
111110 {notice && !error ? <Alert tone="success">{notice}</Alert> : null}
@@ -114,9 +113,9 @@
114113 )}
115114
116115 {rules.loading ? (
117 <EmptyNote>正在读取规则…</EmptyNote>
116 <EmptyNote>{t('space.joinRules.loading')}</EmptyNote>
118117 ) : (rules.data ?? []).length === 0 ? (
119 <EmptyNote>还没有加入规则。</EmptyNote>
118 <EmptyNote>{t('space.joinRules.empty')}</EmptyNote>
120119 ) : (
121120 <div className="space-y-2">
122121 {rules.data!.map(rule => (
@@ -150,6 +149,7 @@
150149}) {
151150 const [enabled, setEnabled] = useState(rule.enabled)
152151 const [matcher, setMatcher] = useState(rule.matcher)
152 const { t } = useT()
153153 const { run, busy: rowBusy, notice, error } = useSave()
154154 const trigger = JOIN_TRIGGER_OPTIONS.find(option => option.value === rule.trigger)?.label ?? rule.trigger
155155
@@ -161,16 +161,16 @@
161161 {trigger} · <code className="text-[12.5px]">{rule.matcher}</code> → {groupName}
162162 </>
163163 }
164 meta={`创建于 ${fmtDate(rule.created_at)}`}
165 actions={<Chip>{rule.enabled ? '生效中' : '已停用'}</Chip>}
164 meta={t('space.roles.createdAt', { date: fmtDate(rule.created_at) })}
165 actions={<Chip>{rule.enabled ? t('space.joinRules.enabled') : t('space.joinRules.disabled')}</Chip>}
166166 />
167167 <div className="settings-subpanel">
168168 <div className="grid gap-x-4 sm:grid-cols-2">
169 <Field label="匹配内容">
169 <Field label={t('space.joinRules.matcher')}>
170170 <input className={inputCls} value={matcher} maxLength={64} onChange={e => setMatcher(e.target.value)} />
171171 </Field>
172172 <div className="pt-1">
173 <Toggle checked={enabled} onChange={setEnabled} label="启用该规则" />
173 <Toggle checked={enabled} onChange={setEnabled} label={t('space.joinRules.enable')} />
174174 </div>
175175 </div>
176176 <div className="flex flex-wrap items-center gap-3">
@@ -182,10 +182,10 @@
182182 void run(async () => {
183183 await updateSpaceJoinRule(spaceName, rule.id, { enabled, matcher: matcher.trim() })
184184 onSaved()
185 }, '规则已更新。')
185 }, t('space.joinRules.updated'))
186186 }
187187 >
188 保存规则
188 {t('space.joinRules.save')}
189189 </Button>
190190 <Button
191191 variant="ghost"
@@ -195,10 +195,10 @@
195195 void run(async () => {
196196 await deleteSpaceJoinRule(spaceName, rule.id)
197197 onSaved()
198 }, '规则已删除。')
198 }, t('space.joinRules.deleted'))
199199 }
200200 >
201 删除
201 {t('common.action.delete')}
202202 </Button>
203203 {notice ? <Alert tone="success">{notice}</Alert> : null}
204204 {error ? <Alert tone="error">{error}</Alert> : null}
Modified web/src/features/space-management/ui/space-settings/members-section.tsx View web/src/features/space-management/ui/space-settings/members-section.tsx
@@ -16,6 +16,7 @@
1616 useSave,
1717} from '@/shared/ui'
1818import { spaceAdminMembersKey, useAsync } from '@/shared/lib'
19import { useT } from '@/shared/i18n'
1920import {
2021 addSpaceMember,
2122 findUserByUsername,
@@ -29,6 +30,7 @@
2930
3031/** The people in the community, with their per-space nickname and alerts. */
3132export function MembersSection({ spaceName }: SpaceSectionProps) {
33 const { t, tc } = useT()
3234 const [tick, setTick] = useState(0)
3335 const [query, setQuery] = useState('')
3436 const [handle, setHandle] = useState('')
@@ -53,26 +55,26 @@
5355 return (
5456 <section>
5557 <SectionTitle
56 title="成员"
57 sub={`共 ${members.data?.length ?? 0} 人。备注名与提醒设置只在本社区生效。`}
58 title={t('space.members.title')}
59 sub={tc('space.members.count', members.data?.length ?? 0)}
5860 />
5961 <div className="mb-4 flex flex-wrap items-center gap-2">
6062 <input
6163 className={`${inputCls} h-9 max-w-[240px]`}
62 placeholder="搜索成员…"
64 placeholder={t('space.members.searchPlaceholder')}
6365 value={query}
6466 onChange={e => setQuery(e.target.value)}
65 aria-label="搜索成员"
67 aria-label={t('space.members.searchAria')}
6668 />
6769 <input
6870 className={`${inputCls} h-9 max-w-[220px]`}
69 placeholder="用户名"
71 placeholder={t('space.members.usernamePlaceholder')}
7072 value={handle}
7173 onChange={e => setHandle(e.target.value)}
7274 onKeyDown={e => {
7375 if (e.key === 'Enter') e.preventDefault()
7476 }}
75 aria-label="要添加的用户名"
77 aria-label={t('space.members.usernameAria')}
7678 />
7779 <Button
7880 variant="secondary"
@@ -84,10 +86,10 @@
8486 await addSpaceMember(spaceName, { user_id: found.user_id })
8587 setHandle('')
8688 refresh()
87 }, '成员已添加。')
89 }, t('space.members.added'))
8890 }
8991 >
90 添加成员
92 {t('space.members.add')}
9193 </Button>
9294 </div>
9395 {error ? (
@@ -102,9 +104,9 @@
102104 ) : null}
103105
104106 {members.loading ? (
105 <EmptyNote>正在读取成员…</EmptyNote>
107 <EmptyNote>{t('space.members.loading')}</EmptyNote>
106108 ) : rows.length === 0 ? (
107 <EmptyNote>{query ? '没有匹配的成员。' : '还没有成员。'}</EmptyNote>
109 <EmptyNote>{query ? t('space.members.emptyFiltered') : t('space.members.empty')}</EmptyNote>
108110 ) : (
109111 <div className="space-y-2">
110112 {rows.map(member => {
@@ -153,8 +155,9 @@
153155 suppress_roles: member.suppress_roles,
154156 hide_muted_channels: member.hide_muted_channels,
155157 }))
158 const { t } = useT()
156159 const { run, busy: rowBusy, notice, error } = useSave()
157 const name = member.user?.display_name || member.user?.username || '未知用户'
160 const name = member.user?.display_name || member.user?.username || t('common.state.unknownUser')
158161
159162 return (
160163 <div className="settings-card">
@@ -172,8 +175,8 @@
172175 }
173176 meta={
174177 <>
175 {MEMBER_STATUS_LABELS[member.status] ?? member.status} · 加入于 {fmtDate(member.joined_at)}
176 {member.muted ? ' · 已禁言' : ''}
178 {MEMBER_STATUS_LABELS[member.status] ?? member.status} · {t('space.members.joinedAt', { date: fmtDate(member.joined_at) })}
179 {member.muted ? ` · ${t('space.members.muted')}` : ''}
177180 </>
178181 }
179182 actions={
@@ -183,7 +186,7 @@
183186 aria-expanded={expanded}
184187 onClick={onToggle}
185188 >
186 {expanded ? '收起' : '编辑'}
189 {expanded ? t('space.members.collapse') : t('space.members.expand')}
187190 <ChevronDown className={`h-3.5 w-3.5 ${expanded ? 'rotate-180' : ''}`} aria-hidden />
188191 </button>
189192 }
@@ -191,7 +194,7 @@
191194 {expanded ? (
192195 <div className="settings-subpanel">
193196 <div className="grid gap-x-4 sm:grid-cols-2">
194 <Field label="社区备注名" hint="留空则使用全局昵称,最长 64 个字符">
197 <Field label={t('space.alias.label')} hint={t('space.alias.hint')}>
195198 <input
196199 className={`${inputCls} max-w-none`}
197200 maxLength={64}
@@ -199,7 +202,7 @@
199202 onChange={e => setDraft({ ...draft, alias_name: e.target.value })}
200203 />
201204 </Field>
202 <Field label="消息提醒">
205 <Field label={t('space.alerts.label')}>
203206 <select
204207 className={`${inputCls} max-w-none`}
205208 value={draft.message_notifications}
@@ -221,10 +224,10 @@
221224 <Toggle
222225 checked={draft.muted}
223226 onChange={v => setDraft({ ...draft, muted: v })}
224 label="禁言该成员"
227 label={t('space.members.mute')}
225228 />
226229 {draft.muted ? (
227 <Field label="禁言至" hint="留空表示永久">
230 <Field label={t('space.members.muteUntil')} hint={t('space.members.muteForever')}>
228231 <input
229232 type="datetime-local"
230233 className={inputCls}
@@ -237,32 +240,32 @@
237240 <Toggle
238241 checked={draft.mobile_push}
239242 onChange={v => setDraft({ ...draft, mobile_push: v })}
240 label="允许移动端推送"
243 label={t('space.members.allowPush')}
241244 />
242245 <Toggle
243246 checked={draft.suppress_everyone}
244247 onChange={v => setDraft({ ...draft, suppress_everyone: v })}
245 label="忽略 @everyone 提及"
248 label={t('space.alerts.suppressEveryone')}
246249 />
247250 <Toggle
248251 checked={draft.suppress_roles}
249252 onChange={v => setDraft({ ...draft, suppress_roles: v })}
250 label="忽略角色提及"
253 label={t('space.alerts.suppressRoles')}
251254 />
252255 <Toggle
253256 checked={draft.hide_muted_channels}
254257 onChange={v => setDraft({ ...draft, hide_muted_channels: v })}
255 label="隐藏被静音的频道"
258 label={t('space.alerts.hideMuted')}
256259 />
257260 </div>
258261 <SaveBar
259262 busy={busy || rowBusy}
260263 notice={notice}
261264 error={error}
262 label="保存成员设置"
265 label={t('space.members.save')}
263266 onSave={() =>
264267 void run(async () => {
265 if (!userId) throw new Error('该成员缺少用户标识')
268 if (!userId) throw new Error(t('space.members.missingUser'))
266269 await updateSpaceMember(spaceName, userId, {
267270 alias_name: draft.alias_name.trim() || null,
268271 message_notifications: draft.message_notifications,
@@ -274,7 +277,7 @@
274277 hide_muted_channels: draft.hide_muted_channels,
275278 })
276279 onSaved()
277 }, '成员设置已保存。')
280 }, t('space.members.saved'))
278281 }
279282 />
280283 {userId ? (
@@ -287,10 +290,10 @@
287290 void run(async () => {
288291 await removeSpaceMember(spaceName, userId)
289292 onSaved()
290 }, '成员已移出社区。')
293 }, t('space.members.removed'))
291294 }
292295 >
293 移出社区
296 {t('space.members.remove')}
294297 </Button>
295298 </div>
296299 ) : null}
Modified web/src/features/space-management/ui/space-settings/meta.ts View web/src/features/space-management/ui/space-settings/meta.ts
@@ -3,6 +3,7 @@
33 * sections. Non-component module for the same reason as the user settings'
44 * helpers: react-refresh wants component-only modules next to JSX.
55 */
6import { t, type MessageKey } from '@/shared/i18n'
67import type { DirNode } from '../../model/api'
78import type { PublicUser } from '../../model/space-admin'
89
@@ -18,18 +19,23 @@
1819
1920export const MANAGE_PERMS = Object.values(PERM) as string[]
2021
21export const PERMISSION_LABELS: Record<string, string> = {
22 view_space: '浏览社区',
23 read: '读取内容',
24 message: '发送消息',
25 manage_members: '管理成员',
26 manage_roles: '管理角色',
27 manage_invites: '管理邀请',
28 manage_bans: '管理封禁',
29 manage_channels: '管理频道',
30 manage_overwrites: '管理权限覆写',
22const PERMISSION_KEYS: Record<string, 'space.permission.viewSpace' | 'space.permission.read' | 'space.permission.message' | 'space.permission.manageMembers' | 'space.permission.manageRoles' | 'space.permission.manageInvites' | 'space.permission.manageBans' | 'space.permission.manageChannels' | 'space.permission.manageOverwrites'> = {
23 view_space: 'space.permission.viewSpace',
24 read: 'space.permission.read',
25 message: 'space.permission.message',
26 manage_members: 'space.permission.manageMembers',
27 manage_roles: 'space.permission.manageRoles',
28 manage_invites: 'space.permission.manageInvites',
29 manage_bans: 'space.permission.manageBans',
30 manage_channels: 'space.permission.manageChannels',
31 manage_overwrites: 'space.permission.manageOverwrites',
3132}
3233
34/** Resolved lazily so permission chips follow the active locale. */
35export const PERMISSION_LABELS = new Proxy({} as Record<string, string>, {
36 get: (_target, key: string) => (PERMISSION_KEYS[key] ? t(PERMISSION_KEYS[key]) : undefined),
37})
38
3339/** Server-side order (`ALL_PERMISSIONS`); the editor renders exactly this list. */
3440export const ALL_PERMISSION_KEYS = [
3541 'view_space',
@@ -43,39 +49,43 @@
4349 'manage_overwrites',
4450] as const
4551
46export const permissionLabel = (key: string) => PERMISSION_LABELS[key] ?? key
52export const permissionLabel = (key: string) => (PERMISSION_KEYS[key] ? t(PERMISSION_KEYS[key]) : key)
4753
4854export const JOIN_LEVEL_OPTIONS = [
49 { value: 'None', label: '不允许加入' },
50 { value: 'NeedMailActive', label: '需验证邮箱' },
51 { value: 'NeedMailActiveAndLastOneMid', label: '需验证邮箱且账号满一天' },
52 { value: 'NeedMailActiveAndLastOneHours', label: '需验证邮箱且账号满一小时' },
53 { value: 'NeedMailActiveAndPhoneActive', label: '需验证邮箱与手机号' },
55 { value: 'None', get label() { return t('space.joinLevel.none') } },
56 { value: 'NeedMailActive', get label() { return t('space.joinLevel.mail') } },
57 { value: 'NeedMailActiveAndLastOneMid', get label() { return t('space.joinLevel.mailDay') } },
58 { value: 'NeedMailActiveAndLastOneHours', get label() { return t('space.joinLevel.mailHour') } },
59 { value: 'NeedMailActiveAndPhoneActive', get label() { return t('space.joinLevel.mailPhone') } },
5460]
5561
5662export const NOTIFICATION_OPTIONS = [
57 { value: 'All', label: '全部消息' },
58 { value: 'Mentions', label: '仅提及' },
59 { value: 'Nothing', label: '不通知' },
63 { value: 'All', get label() { return t('space.notify.all') } },
64 { value: 'Mentions', get label() { return t('space.notify.mentions') } },
65 { value: 'Nothing', get label() { return t('space.notify.nothing') } },
6066]
6167
62export const MEMBER_STATUS_LABELS: Record<string, string> = {
63 Active: '活跃',
64 Inactive: '不活跃',
65 Left: '已离开',
66 Removed: '已移除',
68const MEMBER_STATUS_KEYS: Record<string, MessageKey> = {
69 Active: 'space.memberStatus.active',
70 Inactive: 'space.memberStatus.inactive',
71 Left: 'space.memberStatus.left',
72 Removed: 'space.memberStatus.removed',
6773}
6874
75export const MEMBER_STATUS_LABELS = new Proxy({} as Record<string, string>, {
76 get: (_target, key: string) => (MEMBER_STATUS_KEYS[key] ? t(MEMBER_STATUS_KEYS[key]) : undefined),
77})
78
6979export const JOIN_TRIGGER_OPTIONS = [
70 { value: 'issue_reaction', label: '问题集表情回应' },
71 { value: 'issue_comment_reaction', label: '评论表情回应' },
80 { value: 'issue_reaction', get label() { return t('space.joinTrigger.issueReaction') } },
81 { value: 'issue_comment_reaction', get label() { return t('space.joinTrigger.commentReaction') } },
7282]
7383
7484export const IMAGE_KINDS = [
75 { kind: 'avatar', label: '社区图标', hint: '建议正方形,至少 128×128' },
76 { kind: 'banner', label: '社区横幅', hint: '列表与详情页顶部展示' },
77 { kind: 'invite_banner', label: '邀请卡片图', hint: '分享邀请时预览使用' },
78 { kind: 'embed_splash', label: '嵌入背景图', hint: '外部嵌入页面使用' },
85 { kind: 'avatar', get label() { return t('space.image.avatar') }, get hint() { return t('space.image.avatarHint') } },
86 { kind: 'banner', get label() { return t('space.image.banner') }, get hint() { return t('space.image.bannerHint') } },
87 { kind: 'invite_banner', get label() { return t('space.image.inviteBanner') }, get hint() { return t('space.image.inviteBannerHint') } },
88 { kind: 'embed_splash', get label() { return t('space.image.embedSplash') }, get hint() { return t('space.image.embedSplashHint') } },
7989] as const
8090
8191/** Bit 0 of a directory overwrite: the node is visible to that subject. */
@@ -103,9 +113,9 @@
103113export const slug = (value: string) => value.toLowerCase().replace(/[^a-z0-9-]/g, '')
104114
105115export function userLabel(user?: PublicUser | null) {
106 if (!user) return '未知用户'
116 if (!user) return t('common.state.unknownUser')
107117 const name = user.display_name || user.username
108 return user.username ? `${name}(@${user.username})` : name
118 return user.username ? t('space.userLabel', { name, username: user.username }) : name
109119}
110120
111121/** `#4f7d3a` / `4f7d3a` → `#4f7d3a`; anything else falls back to the neutral default. */
Modified web/src/features/space-management/ui/space-settings/my-membership-section.tsx View web/src/features/space-management/ui/space-settings/my-membership-section.tsx
@@ -10,6 +10,7 @@
1010 useSave,
1111} from '@/shared/ui'
1212import { spaceAdminKey, useAsync } from '@/shared/lib'
13import { useT } from '@/shared/i18n'
1314import { getSpaceMember, updateMySpaceMembership, type SpaceMember } from '../../model/space-admin'
1415import { NOTIFICATION_OPTIONS } from './meta'
1516import type { SpaceSectionProps } from './props'
@@ -28,6 +29,7 @@
2829 * blank, and only what is typed gets sent.
2930 */
3031export function MyMembershipSection({ spaceName, viewerId }: SpaceSectionProps) {
32 const { t } = useT()
3133 // `GET /members/{id}` lets a caller read their own row without any
3234 // management permission, unlike the member list.
3335 const mine = useAsync(
@@ -39,7 +41,7 @@
3941 if (mine.loading)
4042 return (
4143 <>
42 <SectionTitle title="我在本社区" />
44 <SectionTitle title={t('space.settings.sections.mine')} />
4345 <Busy />
4446 </>
4547 )
@@ -49,6 +51,7 @@
4951}
5052
5153function SelfForm({ spaceName, row }: { spaceName: string; row: SpaceMember | null }) {
54 const { t } = useT()
5255 const { busy, notice, error, run } = useSave()
5356 const [draft, setDraft] = useState<Draft>({
5457 alias_name: row?.alias_name ?? null,
@@ -64,23 +67,23 @@
6467 return (
6568 <section>
6669 <SectionTitle
67 title="我在本社区"
70 title={t('space.settings.sections.mine')}
6871 sub={
6972 row
70 ? `加入于 ${fmtDate(row.joined_at)}。这些设置只影响你在这个社区里的体验。`
71 : '这些设置只影响你在这个社区里的体验。'
73 ? t('space.membership.subJoined', { date: fmtDate(row.joined_at) })
74 : t('space.membership.sub')
7275 }
7376 />
74 <Field label="社区备注名" hint="留空则使用全局昵称,最长 64 个字符">
77 <Field label={t('space.alias.label')} hint={t('space.alias.hint')}>
7578 <input
7679 className={inputCls}
7780 maxLength={64}
78 placeholder="未设置"
81 placeholder={t('space.images.unset')}
7982 value={draft.alias_name ?? ''}
8083 onChange={e => set('alias_name', e.target.value || null)}
8184 />
8285 </Field>
83 <Field label="消息提醒">
86 <Field label={t('space.alerts.label')}>
8487 <select
8588 className={inputCls}
8689 value={draft.message_notifications}
@@ -98,29 +101,29 @@
98101 <Toggle
99102 checked={draft.mobile_push}
100103 onChange={v => set('mobile_push', v)}
101 label="接收移动端推送"
104 label={t('space.alerts.mobilePush')}
102105 />
103106 <Toggle
104107 checked={draft.suppress_everyone}
105108 onChange={v => set('suppress_everyone', v)}
106 label="忽略 @everyone 提及"
109 label={t('space.alerts.suppressEveryone')}
107110 />
108111 <Toggle
109112 checked={draft.suppress_roles}
110113 onChange={v => set('suppress_roles', v)}
111 label="忽略角色提及"
114 label={t('space.alerts.suppressRoles')}
112115 />
113116 <Toggle
114117 checked={draft.hide_muted_channels}
115118 onChange={v => set('hide_muted_channels', v)}
116 label="隐藏被静音的频道"
119 label={t('space.alerts.hideMuted')}
117120 />
118121 <div className="mt-7">
119122 <SaveBar
120123 busy={busy}
121124 notice={notice}
122125 error={error}
123 label="保存我的设置"
126 label={t('space.membership.save')}
124127 onSave={() =>
125128 void run(async () => {
126129 const alias = draft.alias_name === null ? null : draft.alias_name.trim() || null
@@ -132,7 +135,7 @@
132135 suppress_roles: draft.suppress_roles,
133136 hide_muted_channels: draft.hide_muted_channels,
134137 })
135 }, '我的社区设置已保存。')
138 }, t('space.membership.saved'))
136139 }
137140 />
138141 </div>
Modified web/src/features/space-management/ui/space-settings/roles-section.tsx View web/src/features/space-management/ui/space-settings/roles-section.tsx
@@ -14,6 +14,7 @@
1414 useSave,
1515} from '@/shared/ui'
1616import { spaceAdminKey, spaceAdminMembersKey, useAsync } from '@/shared/lib'
17import { useT } from '@/shared/i18n'
1718import {
1819 assignSpaceGroup,
1920 createSpaceGroup,
@@ -31,6 +32,7 @@
3132
3233/** Roles: what a group is called, how it looks, and what it may do. */
3334export function RolesSection({ spaceName }: SpaceSectionProps) {
35 const { t } = useT()
3436 const [tick, setTick] = useState(0)
3537 const [draft, setDraft] = useState({ name: '', color: '#6b7280', unicode_emoji: '' })
3638 const { busy, notice, error, run } = useSave()
@@ -44,27 +46,24 @@
4446
4547 return (
4648 <section>
47 <SectionTitle
48 title="角色"
49 sub="角色把成员分组,并批量授予权限。顺序决定成员列表中的展示次序。"
50 />
49 <SectionTitle title={t('space.roles.title')} sub={t('space.roles.sub')} />
5150 <div className="mb-6 flex flex-wrap items-end gap-2">
5251 <input
5352 className={`${inputCls} h-9 max-w-[200px]`}
54 placeholder="角色名称"
53 placeholder={t('space.roles.namePlaceholder')}
5554 maxLength={64}
5655 value={draft.name}
5756 onChange={e => setDraft({ ...draft, name: e.target.value })}
58 aria-label="新角色名称"
57 aria-label={t('space.roles.nameAria')}
5958 />
6059 <label className="flex items-center gap-1.5 text-[12px] text-shell-ink-muted">
61 颜色
60 {t('space.roles.color')}
6261 <input
6362 type="color"
6463 className="h-9 w-12 cursor-pointer rounded-md border border-shell-hairline bg-shell-field px-1"
6564 value={normalizeHexColor(draft.color)}
6665 onChange={e => setDraft({ ...draft, color: e.target.value })}
67 aria-label="新角色颜色"
66 aria-label={t('space.roles.colorAria')}
6867 />
6968 </label>
7069 <input
@@ -73,7 +72,7 @@
7372 maxLength={8}
7473 value={draft.unicode_emoji}
7574 onChange={e => setDraft({ ...draft, unicode_emoji: e.target.value })}
76 aria-label="新角色表情"
75 aria-label={t('space.roles.emojiAria')}
7776 />
7877 <Button
7978 variant="secondary"
@@ -89,10 +88,10 @@
8988 })
9089 setDraft({ name: '', color: '#6b7280', unicode_emoji: '' })
9190 refresh()
92 }, '角色已创建,记得勾选权限。')
91 }, t('space.roles.created'))
9392 }
9493 >
95 新建角色
94 {t('space.roles.create')}
9695 </Button>
9796 </div>
9897 {error ? (
@@ -107,9 +106,9 @@
107106 ) : null}
108107
109108 {groups.loading ? (
110 <EmptyNote>正在读取角色…</EmptyNote>
109 <EmptyNote>{t('space.roles.loading')}</EmptyNote>
111110 ) : (groups.data ?? []).length === 0 ? (
112 <EmptyNote>还没有角色。新建一个后,在成员行里勾选权限即可授权。</EmptyNote>
111 <EmptyNote>{t('space.roles.empty')}</EmptyNote>
113112 ) : (
114113 <div className="space-y-2">
115114 {groups.data!.map(group => (
@@ -152,6 +151,7 @@
152151 const [position, setPosition] = useState(String(group.position))
153152 const [perms, setPerms] = useState<Set<string>>(() => new Set(group.permissions))
154153 const [target, setTarget] = useState('')
154 const { t, tc } = useT()
155155 const { run, busy: rowBusy, notice, error } = useSave()
156156 const dirty =
157157 name !== group.name ||
@@ -185,10 +185,9 @@
185185 meta={
186186 <>
187187 {group.permissions.length === 0
188 ? '尚无权限'
188 ? t('space.roles.noPermissions')
189189 : group.permissions.map(permissionLabel).join(' · ')}
190 {' · 创建于 '}
191 {fmtDate(group.created_at)}
190 {` · ${t('space.roles.createdAt', { date: fmtDate(group.created_at) })}`}
192191 </>
193192 }
194193 actions={
@@ -198,31 +197,31 @@
198197 className="h-2 w-2 rounded-full"
199198 style={{ background: normalizeHexColor(group.color) }}
200199 />
201 {group.permissions.length} 项权限
200 {tc('space.roles.permissionCount', group.permissions.length)}
202201 </Chip>
203202 }
204203 />
205204 <div className="settings-subpanel">
206205 <div className="grid gap-x-4 sm:grid-cols-2">
207 <Field label="名称">
206 <Field label={t('space.roles.name')}>
208207 <input className={inputCls} maxLength={64} value={name} onChange={e => setName(e.target.value)} />
209208 </Field>
210 <Field label="颜色">
209 <Field label={t('space.roles.color')}>
211210 <div className="flex items-center gap-2">
212211 <input
213212 type="color"
214213 className="h-10 w-14 cursor-pointer rounded-md border border-shell-hairline bg-shell-field px-1"
215214 value={color}
216215 onChange={e => setColor(e.target.value)}
217 aria-label="角色颜色"
216 aria-label={t('space.roles.colorAria2')}
218217 />
219218 <code className="text-[12px] text-shell-ink-muted">{color}</code>
220219 </div>
221220 </Field>
222 <Field label="表情图标" hint="可选,显示在角色名前">
221 <Field label={t('space.roles.emoji')} hint={t('space.roles.emojiHint')}>
223222 <input className={inputCls} maxLength={8} value={emoji} onChange={e => setEmoji(e.target.value)} />
224223 </Field>
225 <Field label="排序" hint="数字越小越靠前">
224 <Field label={t('space.roles.position')} hint={t('space.roles.positionHint')}>
226225 <input
227226 type="number"
228227 className={inputCls}
@@ -232,7 +231,7 @@
232231 </Field>
233232 </div>
234233 <p className="mb-2 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
235 权限
234 {t('space.roles.permissions')}
236235 </p>
237236 <div className="mb-4 grid gap-x-4 sm:grid-cols-2">
238237 {ALL_PERMISSION_KEYS.map(key => (
@@ -248,7 +247,7 @@
248247 busy={busy || rowBusy}
249248 notice={notice}
250249 error={error}
251 label="保存角色"
250 label={t('space.roles.save')}
252251 onSave={() =>
253252 void run(async () => {
254253 await updateSpaceGroup(spaceName, group.id, {
@@ -259,18 +258,18 @@
259258 permissions: [...perms],
260259 })
261260 onSaved()
262 }, '角色已更新。')
261 }, t('space.roles.saved'))
263262 }
264 extra={dirty ? <span className="text-[12px] text-shell-ink-muted">有未保存的修改</span> : undefined}
263 extra={dirty ? <span className="text-[12px] text-shell-ink-muted">{t('space.roles.dirty')}</span> : undefined}
265264 />
266265 <div className="mt-5 flex flex-wrap items-center gap-2 border-t border-shell-hairline pt-4">
267266 <select
268267 className={`${inputCls} h-9 w-auto max-w-[220px]`}
269268 value={target}
270269 onChange={e => setTarget(e.target.value)}
271 aria-label="选择成员"
270 aria-label={t('space.roles.selectMember')}
272271 >
273 <option value="">选择成员…</option>
272 <option value="">{t('space.roles.selectMemberPlaceholder')}</option>
274273 {memberOptions.map(option => (
275274 <option key={option.value} value={option.value}>
276275 {option.label}
@@ -286,10 +285,10 @@
286285 await assignSpaceGroup(spaceName, target, group.id)
287286 setTarget('')
288287 onSaved()
289 }, '已授予该角色。')
288 }, t('space.roles.granted'))
290289 }
291290 >
292 授予
291 {t('space.roles.grant')}
293292 </Button>
294293 <Button
295294 variant="ghost"
@@ -300,33 +299,31 @@
300299 await unassignSpaceGroup(spaceName, target, group.id)
301300 setTarget('')
302301 onSaved()
303 }, '已收回该角色。')
302 }, t('space.roles.revoked'))
304303 }
305304 >
306 收回
305 {t('space.roles.revoke')}
307306 </Button>
308307 <span className="ml-auto flex items-center gap-2">
309308 <Button
310309 variant="ghost"
311310 className="h-8 text-[12.5px]"
312311 disabled={busy || rowBusy}
313 onClick={() => void run(() => joinSpaceGroup(spaceName, group.id), '已加入该角色。')}
312 onClick={() => void run(() => joinSpaceGroup(spaceName, group.id), t('space.roles.joined'))}
314313 >
315 我加入
314 {t('space.roles.join')}
316315 </Button>
317316 <Button
318317 variant="ghost"
319318 className="h-8 text-[12.5px]"
320319 disabled={busy || rowBusy}
321 onClick={() => void run(() => leaveSpaceGroup(spaceName, group.id), '已退出该角色。')}
320 onClick={() => void run(() => leaveSpaceGroup(spaceName, group.id), t('space.roles.left'))}
322321 >
323 我退出
322 {t('space.roles.leave')}
324323 </Button>
325324 </span>
326325 </div>
327 <p className="mt-3 text-[11.5px] text-shell-ink-muted">
328 服务端只提供角色的写入接口,没有“成员当前拥有哪些角色”的读取接口,因此这里无法回显已授予的成员。
329 </p>
326 <p className="mt-3 text-[11.5px] text-shell-ink-muted">{t('space.roles.noReadback')}</p>
330327 <div className="mt-4 border-t border-shell-hairline pt-4">
331328 <Button
332329 variant="secondary"
@@ -336,10 +333,10 @@
336333 void run(async () => {
337334 await deleteSpaceGroup(spaceName, group.id)
338335 onSaved()
339 }, '角色已删除。')
336 }, t('space.roles.deleted'))
340337 }
341338 >
342 删除角色
339 {t('space.roles.delete')}
343340 </Button>
344341 </div>
345342 </div>
Modified web/src/features/space-management/ui/create-space-modal.tsx View web/src/features/space-management/ui/create-space-modal.tsx
@@ -2,6 +2,7 @@
22import { X } from 'lucide-react'
33import { Alert, Button, Checkbox } from '@/shared/ui'
44import { ApiFailure } from '@/shared/lib'
5import { useT } from '@/shared/i18n'
56import { createSpace } from '../model/api'
67
78/** Backend rule: lowercase ascii letters, digits and hyphens, max 64 chars. */
@@ -16,6 +17,7 @@
1617
1718/** Modal dialog around `POST /spaces`: slug name, optional description/privacy. */
1819export function CreateSpaceModal({ open, onClose, onCreated }: CreateSpaceModalProps) {
20 const { t } = useT()
1921 const [name, setName] = useState('')
2022 const [description, setDescription] = useState('')
2123 const [isPrivate, setIsPrivate] = useState(false)
@@ -37,7 +39,7 @@
3739 event.preventDefault()
3840 if (busy) return
3941 if (!NAME_RE.test(name)) {
40 setError('名称只能包含小写字母、数字和连字符,长度 1-64。')
42 setError(t('space.create.nameInvalid'))
4143 return
4244 }
4345 setBusy(true)
@@ -53,7 +55,7 @@
5355 setDescription('')
5456 setIsPrivate(false)
5557 } catch (err) {
56 setError(err instanceof ApiFailure ? err.message : '创建失败,请稍后重试。')
58 setError(err instanceof ApiFailure ? err.message : t('space.create.failed'))
5759 } finally {
5860 setBusy(false)
5961 }
@@ -64,15 +66,15 @@
6466 <div
6567 role="dialog"
6668 aria-modal
67 aria-label="创建空间"
69 aria-label={t('space.create')}
6870 className="workspace-dialog w-full max-w-[420px] bg-shell-surface"
6971 onClick={e => e.stopPropagation()}
7072 >
7173 <div className="mb-4 flex items-center justify-between">
72 <h2 className="text-[16px] font-semibold">创建空间</h2>
74 <h2 className="text-[16px] font-semibold">{t('space.create')}</h2>
7375 <button
7476 type="button"
75 aria-label="关闭"
77 aria-label={t('common.action.close')}
7678 className="text-shell-ink-muted transition-colors hover:text-shell-ink"
7779 onClick={onClose}
7880 >
@@ -82,7 +84,7 @@
8284 <form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
8385 <label className="flex flex-col gap-1.5">
8486 <span className="font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
85 空间名称 · 必填
87 {t('space.create.nameRequired')}
8688 </span>
8789 <input
8890 autoFocus
@@ -92,34 +94,34 @@
9294 placeholder="kernel-dev"
9395 className="h-10 w-full rounded-md border-2 border-shell-hairline bg-shell-field px-3 text-[13.5px] text-shell-ink outline-none transition-colors focus:border-shell-ink"
9496 />
95 <span className="text-[11px] text-shell-ink-muted">小写字母、数字与连字符,最长 64 字符</span>
97 <span className="text-[11px] text-shell-ink-muted">{t('space.create.nameHint')}</span>
9698 </label>
9799 <label className="flex flex-col gap-1.5">
98100 <span className="font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
99 描述 · 可选
101 {t('space.create.description')}
100102 </span>
101103 <textarea
102104 value={description}
103105 onChange={e => setDescription(e.target.value)}
104106 maxLength={512}
105107 rows={3}
106 placeholder="这个空间用来做什么?"
108 placeholder={t('space.create.descriptionPlaceholder')}
107109 className="w-full resize-none rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-2 text-[13.5px] text-shell-ink outline-none transition-colors focus:border-shell-ink"
108110 />
109111 </label>
110112 <div className="flex items-center gap-2 text-[13px]">
111 <Checkbox checked={isPrivate} onChange={setIsPrivate} aria-label="私有空间" />
113 <Checkbox checked={isPrivate} onChange={setIsPrivate} aria-label={t('space.create.private')} />
112114 <span className="cursor-pointer" onClick={() => setIsPrivate(p => !p)}>
113 私有空间
115 {t('space.create.private')}
114116 </span>
115117 </div>
116118 {error && <Alert tone="error">{error}</Alert>}
117119 <div className="flex justify-end gap-2 pt-1">
118120 <Button variant="secondary" onClick={onClose} disabled={busy}>
119 取消
121 {t('common.action.cancel')}
120122 </Button>
121123 <Button type="submit" disabled={busy}>
122 {busy ? '创建中…' : '创建'}
124 {busy ? t('space.create.creating') : t('common.action.create')}
123125 </Button>
124126 </div>
125127 </form>
Modified web/src/features/space-management/ui/import-repo-modal.tsx View web/src/features/space-management/ui/import-repo-modal.tsx
@@ -1,6 +1,7 @@
11import { useEffect, useRef, useState } from 'react'
22import { Alert, Button, Checkbox, Modal, modalInputClass, modalLabelClass } from '@/shared/ui'
33import { ApiFailure } from '@/shared/lib'
4import { formatBytes, t, useT, type MessageKey } from '@/shared/i18n'
45import {
56 cancelImport,
67 createImport,
@@ -12,41 +13,38 @@
1213 type ImportProvider,
1314} from '@/entities/repo'
1415
15const PROVIDERS: { value: ImportProvider; label: string; placeholder: string; base: string }[] = [
16 {
17 value: 'github',
18 label: 'GitHub',
19 placeholder: 'https://github.com/octocat/hello-world 或 octocat/hello-world',
20 base: 'https://github.example.com(GitHub Enterprise 实例地址)',
21 },
22 {
23 value: 'gitlab',
24 label: 'GitLab',
25 placeholder: 'https://gitlab.com/group/project 或 group/sub/project',
26 base: 'https://gitlab.example.com(自建实例地址)',
27 },
28 {
29 value: 'gitea',
30 label: 'Gitea',
31 placeholder: 'https://gitea.com/owner/repo 或 owner/repo',
32 base: 'https://gitea.example.com(自建实例地址)',
33 },
16const PROVIDERS: { value: ImportProvider; label: string }[] = [
17 { value: 'github', label: 'GitHub' },
18 { value: 'gitlab', label: 'GitLab' },
19 { value: 'gitea', label: 'Gitea' },
3420]
3521
36const STAGES: Record<string, string> = {
37 probe: '解析源仓库',
38 clone: '镜像抓取全部引用',
39 sync: '同步分支 / 提交 / 标签',
40 finalize: '统计与作者归属',
41 done: '完成',
22const STAGE_KEYS: Record<string, MessageKey> = {
23 probe: 'space.import.stage.probe',
24 clone: 'space.import.stage.clone',
25 sync: 'space.import.stage.sync',
26 finalize: 'space.import.stage.finalize',
27 done: 'space.import.stage.done',
28}
29
30const STATE_KEYS: Record<string, MessageKey> = {
31 queued: 'space.import.step.queued',
32 processing: 'space.import.step.processing',
33 succeeded: 'space.import.step.succeeded',
34 failed: 'space.import.step.failed',
35 canceled: 'space.import.step.canceled',
36}
37
38const SOURCE_PLACEHOLDER_KEYS: Record<ImportProvider, MessageKey> = {
39 github: 'space.import.sourcePlaceholder.github',
40 gitlab: 'space.import.sourcePlaceholder.gitlab',
41 gitea: 'space.import.sourcePlaceholder.gitea',
4242}
4343
44const STATES: Record<string, string> = {
45 queued: '排队中',
46 processing: '进行中',
47 succeeded: '已完成',
48 failed: '失败',
49 canceled: '已取消',
44const BASE_PLACEHOLDER_KEYS: Record<ImportProvider, MessageKey> = {
45 github: 'space.import.basePlaceholder.github',
46 gitlab: 'space.import.basePlaceholder.gitlab',
47 gitea: 'space.import.basePlaceholder.gitea',
5048}
5149
5250const POLL_MS = 2000
@@ -54,19 +52,7 @@
5452function messageOf(err: unknown): string {
5553 if (err instanceof ApiFailure) return err.message
5654 const text = (err as Error | undefined)?.message
57 return text && text !== 'request failed' ? text : '操作失败,请稍后重试。'
58}
59
60function formatBytes(bytes: number | null | undefined): string {
61 if (!bytes || bytes <= 0) return '未知'
62 const units = ['B', 'KB', 'MB', 'GB', 'TB']
63 let value = bytes
64 let index = 0
65 while (value >= 1024 && index < units.length - 1) {
66 value /= 1024
67 index += 1
68 }
69 return `${index === 0 ? value : value.toFixed(1)} ${units[index]}`
55 return text && text !== 'request failed' ? text : t('space.import.actionFailed')
7056}
7157
7258export interface ImportRepoModalProps {
@@ -92,6 +78,7 @@
9278 onClose,
9379 onImported,
9480}: ImportRepoModalProps) {
81 const { t } = useT()
9582 const [step, setStep] = useState<Step>('form')
9683 const [provider, setProvider] = useState<ImportProvider>('github')
9784 const [source, setSource] = useState('')
@@ -213,14 +200,16 @@
213200 }
214201 }
215202
216 const meta = PROVIDERS.find(item => item.value === provider) ?? PROVIDERS[0]
217 const title = step === 'progress' ? `导入进度 · ${job?.repo_name ?? ''}` : '导入外部仓库'
203 const title =
204 step === 'progress'
205 ? t('space.import.progress', { repo: job?.repo_name ?? '' })
206 : t('space.import.title')
218207 const subtitle =
219208 step === 'form'
220 ? '从 GitHub / GitLab / Gitea 镜像导入一个仓库:全部分支、标签与提交历史,以及描述、默认分支和私有性。'
209 ? t('space.import.subtitleForm')
221210 : step === 'confirm'
222 ? '确认解析结果后创建仓库并开始异步镜像。'
223 : '任务由 synchost 异步执行,可以关闭本窗口稍后再来查看。'
211 ? t('space.import.subtitleConfirm')
212 : t('space.import.subtitleProgress')
224213
225214 return (
226215 <Modal title={title} description={subtitle} open onClose={onClose} width="max-w-[560px]">
@@ -233,7 +222,7 @@
233222 {step === 'form' ? (
234223 <div className="space-y-4">
235224 <div>
236 <span className={modalLabelClass}>来源平台</span>
225 <span className={modalLabelClass}>{t('space.import.provider')}</span>
237226 <div className="flex gap-2">
238227 {PROVIDERS.map(item => (
239228 <button
@@ -253,41 +242,41 @@
253242 </div>
254243 <div>
255244 <label className={modalLabelClass} htmlFor="import-source">
256 仓库地址或 owner/repo
245 {t('space.import.source')}
257246 </label>
258247 <input
259248 id="import-source"
260249 className={modalInputClass}
261 placeholder={meta.placeholder}
250 placeholder={t(SOURCE_PLACEHOLDER_KEYS[provider])}
262251 value={source}
263252 onChange={event => setSource(event.target.value)}
264253 />
265254 </div>
266255 <div>
267256 <label className={modalLabelClass} htmlFor="import-base">
268 实例地址 · 自建时填写
257 {t('space.import.base')}
269258 </label>
270259 <input
271260 id="import-base"
272261 className={modalInputClass}
273 placeholder={meta.base}
262 placeholder={t(BASE_PLACEHOLDER_KEYS[provider])}
274263 value={baseUrl}
275264 onChange={event => setBaseUrl(event.target.value)}
276265 />
277266 <span className="mt-1.5 block text-[12px] leading-5 text-shell-ink-muted">
278 内网或 localhost 实例需要管理员先把它加入 <code>import.allowed_hosts</code>。
267 {t('space.import.baseHint', { field: 'import.allowed_hosts' })}
279268 </span>
280269 </div>
281270 <div>
282271 <label className={modalLabelClass} htmlFor="import-token">
283 访问令牌 · 私有仓库必填
272 {t('space.import.token')}
284273 </label>
285274 <input
286275 id="import-token"
287276 type="password"
288277 autoComplete="off"
289278 className={modalInputClass}
290 placeholder="留空表示匿名导入公开仓库"
279 placeholder={t('space.import.tokenPlaceholder')}
291280 value={token}
292281 onChange={event => setToken(event.target.value)}
293282 />
@@ -295,19 +284,19 @@
295284 <div className="grid grid-cols-2 gap-3">
296285 <div>
297286 <label className={modalLabelClass} htmlFor="import-name">
298 本地仓库名 · 可选
287 {t('space.import.localName')}
299288 </label>
300289 <input
301290 id="import-name"
302291 className={modalInputClass}
303 placeholder="留空沿用源仓库名"
292 placeholder={t('space.import.localNamePlaceholder')}
304293 value={name}
305294 onChange={event => setName(event.target.value)}
306295 />
307296 </div>
308297 <div>
309298 <label className={modalLabelClass} htmlFor="import-dir">
310 所在文件夹
299 {t('space.import.dir')}
311300 </label>
312301 <select
313302 id="import-dir"
@@ -315,7 +304,7 @@
315304 value={parentDirId}
316305 onChange={event => setParentDirId(event.target.value)}
317306 >
318 <option value="">根目录</option>
307 <option value="">{t('space.import.root')}</option>
319308 {dirOptions.map(option => (
320309 <option key={option.value} value={option.value}>
321310 {option.label}
@@ -326,24 +315,24 @@
326315 </div>
327316 <div>
328317 <label className={modalLabelClass} htmlFor="import-description">
329 描述 · 可选
318 {t('space.import.description')}
330319 </label>
331320 <input
332321 id="import-description"
333322 className={modalInputClass}
334 placeholder="留空沿用源仓库描述"
323 placeholder={t('space.import.descriptionPlaceholder')}
335324 value={description}
336325 onChange={event => setDescription(event.target.value)}
337326 />
338327 </div>
339328 <div className="flex flex-col gap-2">
340329 <label className="flex items-center gap-2 text-[13px] text-shell-ink">
341 <Checkbox checked={isPrivate} onChange={setIsPrivate} aria-label="私有仓库" />
342 私有仓库(留空则沿用源仓库可见性)
330 <Checkbox checked={isPrivate} onChange={setIsPrivate} aria-label={t('space.import.private')} />
331 {t('space.import.private')}
343332 </label>
344333 <label className="flex items-center gap-2 text-[13px] text-shell-ink">
345 <Checkbox checked={enableLfs} onChange={setEnableLfs} aria-label="启用 Git LFS" />
346 启用 Git LFS
334 <Checkbox checked={enableLfs} onChange={setEnableLfs} aria-label={t('space.import.enableLfs')} />
335 {t('space.import.enableLfs')}
347336 </label>
348337 </div>
349338 </div>
@@ -353,12 +342,12 @@
353342 <div className="space-y-3">
354343 <dl className="divide-y divide-shell-hairline rounded-md border-2 border-shell-hairline bg-shell-field px-3 text-[13px]">
355344 {[
356 ['来源', `${probe.owner}/${probe.name}`],
357 ['克隆地址', probe.clone_url],
358 ['默认分支', probe.default_branch],
359 ['可见性', probe.is_private ? '私有' : '公开'],
360 ['体积', formatBytes(probe.size_bytes)],
361 ['描述', probe.description || '—'],
345 [t('space.import.field.source'), `${probe.owner}/${probe.name}`],
346 [t('space.import.field.cloneUrl'), probe.clone_url],
347 [t('space.import.field.defaultBranch'), probe.default_branch],
348 [t('space.import.field.visibility'), probe.is_private ? t('label.visibility.private') : t('label.visibility.public')],
349 [t('space.import.field.size'), formatBytes(probe.size_bytes)],
350 [t('space.import.field.description'), probe.description || '—'],
362351 ].map(([label, value]) => (
363352 <div key={label} className="flex gap-3 py-2">
364353 <dt className="w-20 shrink-0 text-shell-ink-muted">{label}</dt>
@@ -366,10 +355,7 @@
366355 </div>
367356 ))}
368357 </dl>
369 <p className="text-[12px] leading-5 text-shell-ink-muted">
370 导入只覆盖 Git 数据与仓库基本元数据,不包含 Issue、Pull Request、Release、Wiki 与 LFS
371 大对象。
372 </p>
358 <p className="text-[12px] leading-5 text-shell-ink-muted">{t('space.import.scopeNote')}</p>
373359 </div>
374360 ) : null}
375361
@@ -377,10 +363,10 @@
377363 <div className="space-y-3">
378364 <div className="rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-3">
379365 <div className="flex flex-wrap items-center gap-2 text-[13px] font-semibold">
380 <span>{STATES[job.state] ?? job.state}</span>
366 <span>{STATE_KEYS[job.state] ? t(STATE_KEYS[job.state]) : job.state}</span>
381367 <span className="font-normal text-shell-ink-muted">
382 · {STAGES[job.stage] ?? job.stage}
383 {job.attempts > 0 ? ` · 第 ${job.attempts + 1} 次尝试` : ''}
368 · {STAGE_KEYS[job.stage] ? t(STAGE_KEYS[job.stage]) : job.stage}
369 {job.attempts > 0 ? ` · ${t('space.import.attempt', { n: job.attempts + 1 })}` : ''}
384370 </span>
385371 </div>
386372 {!isTerminal(job) ? (
@@ -402,19 +388,19 @@
402388 ) : null}
403389 <dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-[12.5px] text-shell-ink-muted sm:grid-cols-4">
404390 <div>
405 <dt>分支</dt>
391 <dt>{t('space.import.stat.branches')}</dt>
406392 <dd className="text-shell-ink">{job.stats.branches}</dd>
407393 </div>
408394 <div>
409 <dt>标签</dt>
395 <dt>{t('space.import.stat.tags')}</dt>
410396 <dd className="text-shell-ink">{job.stats.tags}</dd>
411397 </div>
412398 <div>
413 <dt>提交</dt>
399 <dt>{t('space.import.stat.commits')}</dt>
414400 <dd className="text-shell-ink">{job.stats.commits}</dd>
415401 </div>
416402 <div>
417 <dt>体积</dt>
403 <dt>{t('space.import.stat.size')}</dt>
418404 <dd className="text-shell-ink">{formatBytes(job.stats.size_bytes)}</dd>
419405 </div>
420406 </dl>
@@ -425,20 +411,20 @@
425411 {job.last_error ? <Alert tone="error">{job.last_error}</Alert> : null}
426412 {job.state === 'succeeded' ? (
427413 <Alert tone="success">
428 仓库 {job.repo_name} 已导入完成,默认分支 {job.stats.default_branch ?? '—'},可在左侧列表中打开。
414 {t('space.import.succeeded', { repo: job.repo_name, branch: job.stats.default_branch ?? '—' })}
429415 </Alert>
430416 ) : null}
431417 {job.state === 'failed' || job.state === 'canceled' ? (
432418 <div>
433419 <label className={modalLabelClass} htmlFor="import-retry-token">
434 重试令牌 · 私有仓库必填
420 {t('space.import.retryToken')}
435421 </label>
436422 <input
437423 id="import-retry-token"
438424 type="password"
439425 autoComplete="off"
440426 className={modalInputClass}
441 placeholder="任务结束后令牌已清除,重试需重新提供"
427 placeholder={t('space.import.retryTokenPlaceholder')}
442428 value={retryToken}
443429 onChange={event => setRetryToken(event.target.value)}
444430 />
@@ -451,20 +437,20 @@
451437 {step === 'form' ? (
452438 <>
453439 <Button variant="secondary" onClick={onClose} disabled={busy}>
454 取消
440 {t('common.action.cancel')}
455441 </Button>
456442 <Button onClick={() => void runProbe()} disabled={busy || !source.trim()}>
457 {busy ? '解析中…' : '解析源仓库'}
443 {busy ? t('space.import.parsing') : t('space.import.parse')}
458444 </Button>
459445 </>
460446 ) : null}
461447 {step === 'confirm' ? (
462448 <>
463449 <Button variant="secondary" onClick={() => setStep('form')} disabled={busy}>
464 返回修改
450 {t('space.import.backToEdit')}
465451 </Button>
466452 <Button onClick={() => void runCreate()} disabled={busy}>
467 {busy ? '创建中…' : '开始导入'}
453 {busy ? t('space.create.creating') : t('space.import.start')}
468454 </Button>
469455 </>
470456 ) : null}
@@ -472,15 +458,17 @@
472458 <>
473459 {!isTerminal(job) ? (
474460 <Button variant="secondary" onClick={() => void runCancel()} disabled={busy}>
475 取消导入
461 {t('space.import.cancelImport')}
476462 </Button>
477463 ) : null}
478464 {job.state === 'failed' || job.state === 'canceled' ? (
479465 <Button variant="secondary" onClick={() => void runRetry()} disabled={busy}>
480 {busy ? '重试中…' : '重试'}
466 {busy ? t('space.import.retrying') : t('common.action.retry')}
481467 </Button>
482468 ) : null}
483 <Button onClick={onClose}>{job.state === 'succeeded' ? '完成' : '关闭'}</Button>
469 <Button onClick={onClose}>
470 {job.state === 'succeeded' ? t('space.import.stage.done') : t('common.action.close')}
471 </Button>
484472 </>
485473 ) : null}
486474 </div>
Modified web/src/features/space-management/ui/space-header.tsx View web/src/features/space-management/ui/space-header.tsx
@@ -27,6 +27,7 @@
2727import { ImportRepoModal } from './import-repo-modal'
2828import { findUserByUsername, listSpaceGroups } from '../model/space-admin'
2929import { FormModal, parseList } from '@/shared/ui'
30import { t as tr, useT } from '@/shared/i18n'
3031import { MANAGE_PERMS, parentDirOptions } from './space-settings/meta'
3132
3233type Dialog =
@@ -44,9 +45,9 @@
4445const slug = (v: string) => v.toLowerCase().replace(/[^a-z0-9-]/g, '')
4546
4647const AUDIENCE_OPTIONS = [
47 { value: 'Everyone', label: '所有人' },
48 { value: 'FollowingOnly', label: '仅我关注的人' },
49 { value: 'Nobody', label: '没有人' },
48 { value: 'Everyone', get label() { return tr('label.audience.everyone') } },
49 { value: 'FollowingOnly', get label() { return tr('label.audience.following') } },
50 { value: 'Nobody', get label() { return tr('label.audience.nobody') } },
5051]
5152
5253export interface SpaceHeaderProps {
@@ -68,6 +69,7 @@
6869 onOpenSettings,
6970 onLeft,
7071}: SpaceHeaderProps) {
72 const { t } = useT()
7173 const { space, permissions } = mySpace
7274 const [menuOpen, setMenuOpen] = useState(false)
7375 const [dialog, setDialog] = useState<Dialog>(null)
@@ -179,73 +181,73 @@
179181 className="absolute left-0 top-full z-40 mt-1 w-56 rounded-lg border-2 border-shell-hairline bg-shell-surface p-1.5 shadow-xl"
180182 >
181183 {perms.has('manage_invites') ? (
182 <MenuItem icon={UserPlus} label="邀请朋友" onClick={() => openDialog('invite')} />
184 <MenuItem icon={UserPlus} label={t('space.menu.invite')} onClick={() => openDialog('invite')} />
183185 ) : null}
184 <MenuItem icon={FolderGit2} label="创建仓库" onClick={() => openDialog('repo')} />
185 <MenuItem icon={Download} label="导入仓库" onClick={() => openDialog('import')} />
186 <MenuItem icon={SquareKanban} label="创建问题集" onClick={() => openDialog('board')} />
186 <MenuItem icon={FolderGit2} label={t('space.menu.createRepo')} onClick={() => openDialog('repo')} />
187 <MenuItem icon={Download} label={t('space.import')} onClick={() => openDialog('import')} />
188 <MenuItem icon={SquareKanban} label={t('space.menu.createBoard')} onClick={() => openDialog('board')} />
187189 {perms.has('manage_channels') ? (
188 <MenuItem icon={Hash} label="创建聊天室" onClick={() => openDialog('room')} />
190 <MenuItem icon={Hash} label={t('space.menu.createRoom')} onClick={() => openDialog('room')} />
189191 ) : null}
190 <MenuItem icon={FolderPlus} label="创建文件夹" onClick={() => openDialog('dir')} />
192 <MenuItem icon={FolderPlus} label={t('space.menu.createDir')} onClick={() => openDialog('dir')} />
191193 <MenuDivider />
192194 {canManage ? (
193 <MenuItem icon={Settings} label="社区设置" onClick={onOpenSettings} />
195 <MenuItem icon={Settings} label={t('space.menu.settings')} onClick={onOpenSettings} />
194196 ) : null}
195 <MenuItem icon={ShieldCheck} label="隐私设置" onClick={() => openDialog('privacy')} />
197 <MenuItem icon={ShieldCheck} label={t('space.menu.privacy')} onClick={() => openDialog('privacy')} />
196198 {!isOwner ? (
197199 <>
198200 <MenuDivider />
199 <MenuItem danger icon={LogOut} label="退出社区" onClick={() => openDialog('leave')} />
201 <MenuItem danger icon={LogOut} label={t('space.menu.leave')} onClick={() => openDialog('leave')} />
200202 </>
201203 ) : null}
202204 </div>
203205 ) : null}
204206
205207 <FormModal
206 title="邀请朋友"
207 description={`邀请 ${space.name} 的新成员。对方接受后会自动获得所选角色。`}
208 title={t('space.invite.title')}
209 description={t('space.invite.sub', { space: space.name })}
208210 open={dialog === 'invite'}
209211 wide
210 confirmLabel="发出邀请"
212 confirmLabel={t('space.invite.submit')}
211213 fields={[
212214 {
213215 kind: 'text',
214216 key: 'email',
215 label: '邮箱',
217 label: t('space.invite.email'),
216218 type: 'email',
217219 optional: true,
218220 placeholder: 'ada@example.com',
219 hint: '站外朋友填这里,会收到邀请邮件',
221 hint: t('space.invite.emailHint'),
220222 },
221223 {
222224 kind: 'text',
223225 key: 'username',
224 label: '或用户名',
226 label: t('space.invite.username'),
225227 optional: true,
226228 placeholder: 'ada',
227 hint: '已注册的朋友填这里,会同时收到站内通知',
229 hint: t('space.invite.usernameHint'),
228230 },
229231 {
230232 kind: 'datetime',
231233 key: 'expires_at',
232 label: '过期时间',
234 label: t('space.invite.expires'),
233235 optional: true,
234 hint: '留空表示长期有效',
236 hint: t('space.invite.expiresHint'),
235237 },
236238 {
237239 kind: 'multiselect',
238240 key: 'group_ids',
239 label: '加入后授予角色',
241 label: t('space.invite.groups'),
240242 options: groupOptions,
241 emptyLabel: '这个社区还没有角色,请先到 社区设置 → 角色 创建一个。',
242 hint: '至少选择一个角色',
243 emptyLabel: t('space.invite.groupsEmpty'),
244 hint: t('space.invite.groupsHint'),
243245 },
244246 ]}
245247 onSubmit={async values => {
246248 const email = String(values.email).trim()
247249 const username = String(values.username).trim().replace(/^@/, '')
248 if (!email && !username) throw new Error('请填写邮箱或用户名,至少一个。')
250 if (!email && !username) throw new Error(t('space.invite.missingTarget'))
249251 const expires = String(values.expires_at).trim()
250252 const target = email
251253 ? { email }
@@ -255,54 +257,54 @@
255257 group_ids: parseList(values.group_ids),
256258 expires_at: expires ? new Date(expires).toISOString() : null,
257259 })
258 return `邀请已发出:${email || username} 接受后即可加入。`
260 return t('space.invite.sent', { target: email || username })
259261 }}
260262 onClose={closeDialog}
261263 />
262264 <FormModal
263 title="创建仓库"
265 title={t('space.repo.title')}
264266 open={dialog === 'repo'}
265 confirmLabel="创建"
267 confirmLabel={t('common.action.create')}
266268 fields={[
267269 {
268270 kind: 'text',
269271 key: 'name',
270 label: '仓库名称',
272 label: t('space.repo.name'),
271273 placeholder: 'kernel',
272 hint: '小写字母、数字与连字符',
274 hint: t('space.repo.nameHint'),
273275 sanitize: slug,
274276 },
275 { kind: 'text', key: 'description', label: '描述 · 可选', optional: true },
277 { kind: 'text', key: 'description', label: t('space.repo.description'), optional: true },
276278 {
277279 kind: 'select',
278280 key: 'parent_dir_id',
279 label: '所在文件夹',
281 label: t('space.repo.dir'),
280282 initialValue: '',
281 options: [{ value: '', label: '根目录' }, ...dirOptions],
283 options: [{ value: '', label: t('space.dir.root') }, ...dirOptions],
282284 },
283285 {
284286 kind: 'text',
285287 key: 'default_branch',
286 label: '默认分支 · 可选',
288 label: t('space.repo.defaultBranch'),
287289 placeholder: 'main',
288290 optional: true,
289291 },
290292 {
291293 kind: 'text',
292294 key: 'license_name',
293 label: '许可证 · 可选',
295 label: t('space.repo.license'),
294296 placeholder: 'MIT',
295297 optional: true,
296298 },
297299 {
298300 kind: 'text',
299301 key: 'init_message',
300 label: '初始提交信息 · 可选',
302 label: t('space.repo.initMessage'),
301303 placeholder: 'Initial commit',
302304 optional: true,
303305 },
304 { kind: 'toggle', key: 'enable_lfs', label: '启用 Git LFS' },
305 { kind: 'toggle', key: 'is_private', label: '私有仓库' },
306 { kind: 'toggle', key: 'enable_lfs', label: t('space.repo.enableLfs') },
307 { kind: 'toggle', key: 'is_private', label: t('space.repo.private') },
306308 ]}
307309 onSubmit={async values => {
308310 await createRepo(space.name, {
@@ -328,22 +330,22 @@
328330 />
329331 ) : null}
330332 <FormModal
331 title="创建问题集"
333 title={t('space.board.create.title')}
332334 open={dialog === 'board'}
333 confirmLabel="创建"
335 confirmLabel={t('common.action.create')}
334336 fields={[
335 { kind: 'text', key: 'name', label: '名称', placeholder: 'bug-triage' },
337 { kind: 'text', key: 'name', label: t('space.board.nameField'), placeholder: 'bug-triage' },
336338 {
337339 kind: 'textarea',
338340 key: 'description',
339 label: '描述 · 可选',
341 label: t('space.board.description'),
340342 optional: true,
341343 maxLength: 512,
342344 },
343345 {
344346 kind: 'text',
345347 key: 'icon',
346 label: '图标 · 可选',
348 label: t('space.board.icon'),
347349 placeholder: '',
348350 optional: true,
349351 maxLength: 16,
@@ -351,9 +353,9 @@
351353 {
352354 kind: 'select',
353355 key: 'parent_dir_id',
354 label: '所在文件夹',
356 label: t('space.repo.dir'),
355357 initialValue: '',
356 options: [{ value: '', label: '根目录' }, ...dirOptions],
358 options: [{ value: '', label: t('space.dir.root') }, ...dirOptions],
357359 },
358360 ]}
359361 onSubmit={async values => {
@@ -368,52 +370,52 @@
368370 onClose={closeDialog}
369371 />
370372 <FormModal
371 title="创建聊天室"
373 title={t('space.room.title')}
372374 open={dialog === 'room'}
373 confirmLabel="创建"
375 confirmLabel={t('common.action.create')}
374376 fields={[
375377 {
376378 kind: 'text',
377379 key: 'name',
378 label: '频道名称',
380 label: t('space.room.name'),
379381 placeholder: 'general',
380 hint: '小写字母、数字与连字符',
382 hint: t('space.room.nameHint'),
381383 sanitize: slug,
382384 },
383385 {
384386 kind: 'select',
385387 key: 'kind',
386 label: '类型',
388 label: t('space.room.kind'),
387389 initialValue: 'Text',
388390 options: [
389 { value: 'Text', label: '文字频道' },
390 { value: 'Voice', label: '语音频道' },
391 { value: 'Stage', label: '舞台频道' },
391 { value: 'Text', label: t('space.room.kind.text') },
392 { value: 'Voice', label: t('space.room.kind.voice') },
393 { value: 'Stage', label: t('space.room.kind.stage') },
392394 ],
393395 },
394396 {
395397 kind: 'select',
396398 key: 'parent_id',
397 label: '所在文件夹',
399 label: t('space.room.dir'),
398400 initialValue: '',
399 options: [{ value: '', label: '根目录' }, ...dirOptions],
401 options: [{ value: '', label: t('space.dir.root') }, ...dirOptions],
400402 },
401 { kind: 'text', key: 'topic', label: '主题 · 可选', optional: true },
403 { kind: 'text', key: 'topic', label: t('space.room.topic'), optional: true },
402404 {
403405 kind: 'number',
404406 key: 'user_limit',
405 label: '语音人数上限 · 可选',
406 placeholder: '0 = 不限制',
407 label: t('space.room.userLimit'),
408 placeholder: t('space.room.noLimit'),
407409 optional: true,
408410 },
409411 {
410412 kind: 'number',
411413 key: 'rate_limit_per_user',
412 label: '发言冷却(秒) · 可选',
413 placeholder: '0 = 不限制',
414 label: t('space.room.rateLimit'),
415 placeholder: t('space.room.noLimit'),
414416 optional: true,
415417 },
416 { kind: 'toggle', key: 'is_private', label: '私有频道(仅受邀可见)' },
418 { kind: 'toggle', key: 'is_private', label: t('space.room.private') },
417419 ]}
418420 onSubmit={async values => {
419421 const userLimit = String(values.user_limit)
@@ -434,24 +436,24 @@
434436 onClose={closeDialog}
435437 />
436438 <FormModal
437 title="创建文件夹"
438 description="文件夹用于在空间目录树中归档仓库与问题集。"
439 title={t('space.dir.title')}
440 description={t('space.dir.description')}
439441 open={dialog === 'dir'}
440 confirmLabel="创建"
442 confirmLabel={t('common.action.create')}
441443 fields={[
442444 {
443445 kind: 'text',
444446 key: 'name',
445 label: '文件夹名称',
447 label: t('space.dir.name'),
446448 placeholder: 'backend',
447449 maxLength: 64,
448450 },
449451 {
450452 kind: 'select',
451453 key: 'parent_dir_id',
452 label: '父级文件夹',
454 label: t('space.dir.parent'),
453455 initialValue: '',
454 options: [{ value: '', label: '根目录' }, ...dirOptions],
456 options: [{ value: '', label: t('space.dir.root') }, ...dirOptions],
455457 },
456458 ]}
457459 onSubmit={async values => {
@@ -465,10 +467,10 @@
465467 onClose={closeDialog}
466468 />
467469 <FormModal
468 title="隐私设置"
469 description="个人账号的私信与提及权限,对全部社区生效。"
470 title={t('space.privacy.title')}
471 description={t('space.privacy.description')}
470472 open={dialog === 'privacy'}
471 confirmLabel="保存"
473 confirmLabel={t('common.action.save')}
472474 load={async () => {
473475 const prefs = await fetchPreferences()
474476 return {
@@ -481,16 +483,16 @@
481483 {
482484 kind: 'select',
483485 key: 'allow_dms_from',
484 label: '允许私信',
486 label: t('space.privacy.dms'),
485487 options: AUDIENCE_OPTIONS,
486488 },
487489 {
488490 kind: 'select',
489491 key: 'allow_mentions_from',
490 label: '允许提及',
492 label: t('space.privacy.mentions'),
491493 options: AUDIENCE_OPTIONS,
492494 },
493 { kind: 'toggle', key: 'show_online_status', label: '展示在线状态' },
495 { kind: 'toggle', key: 'show_online_status', label: t('space.privacy.online') },
494496 ]}
495497 onSubmit={async values => {
496498 await updatePreferences({
@@ -498,16 +500,16 @@
498500 allow_mentions_from: String(values.allow_mentions_from) as AudienceValue,
499501 show_online_status: Boolean(values.show_online_status),
500502 })
501 return '隐私设置已保存。'
503 return t('space.privacy.saved')
502504 }}
503505 onClose={closeDialog}
504506 />
505507 <FormModal
506 title="退出社区"
507 description={`退出后需要重新受邀才能回到 ${space.name}。`}
508 title={t('space.leave.title')}
509 description={t('space.leave.sub', { space: space.name })}
508510 open={dialog === 'leave'}
509511 danger
510 confirmLabel="退出社区"
512 confirmLabel={t('space.leave.title')}
511513 fields={[]}
512514 onSubmit={async () => {
513515 await leaveSpace(space.name)
Modified web/src/features/space-management/ui/space-settings-modal.tsx View web/src/features/space-management/ui/space-settings-modal.tsx
@@ -1,6 +1,7 @@
11import { useEffect, useState } from 'react'
22import { Alert, Busy, SettingsModalShell, type SettingsSection } from '@/shared/ui'
33import { spaceAdminKey, useAsync } from '@/shared/lib'
4import { useT } from '@/shared/i18n'
45import { fetchSpace } from '../model/api'
56import type { MySpace } from '../model/api'
67import { BansSection } from './space-settings/bans-section'
@@ -41,6 +42,7 @@
4142 onSpaceUpdated,
4243 onDeleted,
4344}: SpaceSettingsModalProps) {
45 const { t } = useT()
4446 const [section, setSection] = useState('mine')
4547 const { permissions } = mySpace
4648 const loaded = useAsync(
@@ -59,18 +61,18 @@
5961 const perms = new Set(permissions)
6062
6163 const visible: SettingsSection[] = [
62 { id: 'mine', label: '我在本社区' },
63 { id: 'general', label: '社区资料' },
64 ...(perms.has(PERM.channels) ? [{ id: 'images', label: '图标与横幅' }] : []),
65 ...(perms.has(PERM.members) ? [{ id: 'members', label: '成员' }] : []),
66 ...(perms.has(PERM.roles) ? [{ id: 'roles', label: '角色' }] : []),
67 ...(perms.has(PERM.invites) ? [{ id: 'invites', label: '邀请' }] : []),
68 ...(perms.has(PERM.roles) ? [{ id: 'join', label: '加入规则' }] : []),
69 ...(perms.has(PERM.bans) ? [{ id: 'bans', label: '封禁' }] : []),
64 { id: 'mine', label: t('space.settings.sections.mine') },
65 { id: 'general', label: t('space.settings.sections.general') },
66 ...(perms.has(PERM.channels) ? [{ id: 'images', label: t('space.settings.sections.images') }] : []),
67 ...(perms.has(PERM.members) ? [{ id: 'members', label: t('space.members.title') }] : []),
68 ...(perms.has(PERM.roles) ? [{ id: 'roles', label: t('space.roles.title') }] : []),
69 ...(perms.has(PERM.invites) ? [{ id: 'invites', label: t('space.invites.title') }] : []),
70 ...(perms.has(PERM.roles) ? [{ id: 'join', label: t('space.settings.sections.join') }] : []),
71 ...(perms.has(PERM.bans) ? [{ id: 'bans', label: t('space.settings.sections.bans') }] : []),
7072 ...(perms.has(PERM.channels) || perms.has(PERM.overwrites)
71 ? [{ id: 'channels', label: '频道权限' }]
73 ? [{ id: 'channels', label: t('space.channelPermissions.title') }]
7274 : []),
73 { id: 'danger', label: '危险操作' },
75 { id: 'danger', label: t('space.settings.danger') },
7476 ]
7577
7678 // Losing the permission that owned the current section (or opening a
@@ -94,7 +96,7 @@
9496 return (
9597 <SettingsModalShell
9698 key={mySpace.space.name}
97 title="社区设置"
99 title={t('space.settings.title')}
98100 sections={visible}
99101 active={section}
100102 onSelect={setSection}
@@ -102,9 +104,7 @@
102104 >
103105 {loaded.error && !loaded.data ? (
104106 <div className="mb-5">
105 <Alert tone="info">
106 读取完整社区资料失败,以下表单基于列表缓存,部分字段可能不是最新。
107 </Alert>
107 <Alert tone="info">{t('space.settings.fullLoadFailed')}</Alert>
108108 </div>
109109 ) : null}
110110 {section === 'mine' ? <MyMembershipSection {...props} /> : null}
Modified web/src/features/user-settings/ui/sections/access-keys-section.tsx View web/src/features/user-settings/ui/sections/access-keys-section.tsx
@@ -1,9 +1,11 @@
11import { useState } from 'react'
2import { useT } from '@/shared/i18n'
23import { Busy, Button, CopyButton, SaveBar, SectionTitle, fmtDate, inputCls, useSave } from '@/shared/ui'
34import { settingsKey, useAsync } from '@/shared/lib'
45import * as api from '../../model/api'
56
67export function KeysSection() {
8 const { t } = useT()
79 const [tick, setTick] = useState(0)
810 const keys = useAsync(api.listAccessKeys, [tick], { cacheKey: settingsKey('access-keys') })
911 const { busy, notice, error, run } = useSave()
@@ -13,38 +15,40 @@
1315
1416 return (
1517 <section>
16 <SectionTitle title="访问密钥" sub="用于 git over HTTP 的 Basic 凭证(仅仓库接口有效)。密钥只在创建/轮换时显示一次。" />
18 <SectionTitle title={t('settings.keys.title')} sub={t('settings.keys.sub')} />
1719 <div className="mb-4 flex max-w-[620px] gap-2">
18 <input className={inputCls} placeholder="密钥名称,如 laptop-git" value={name} onChange={e => setName(e.target.value)} />
20 <input className={inputCls} placeholder={t('userSettings.keys.namePlaceholder')} value={name} onChange={e => setName(e.target.value)} />
1921 <select className={`${inputCls} h-10 max-w-[120px]`} value={scope} onChange={e => setScope(e.target.value as 'read' | 'write')}>
2022 <option value="read">read</option>
2123 <option value="write">write</option>
2224 </select>
23 <Button disabled={busy || !name} onClick={() => void run(async () => { const result = await api.createAccessKey({ name, scope }); setToken(result.token); setName(''); setTick(t => t + 1) }, '访问密钥已创建。')}>创建</Button>
25 <Button disabled={busy || !name} onClick={() => void run(async () => { const result = await api.createAccessKey({ name, scope }); setToken(result.token); setName(''); setTick(t => t + 1) }, t('settings.keys.created'))}>{t('userSettings.keys.create')}</Button>
2426 </div>
2527 {token ? (
2628 <div className="mb-4 max-w-[620px] rounded-lg border-2 border-[#f0b232]/50 bg-shell-field p-4">
27 <p className="mb-1 text-[12.5px] font-semibold text-warning-foreground">新密钥(仅显示一次)</p>
29 <p className="mb-1 text-[12.5px] font-semibold text-warning-foreground">{t('userSettings.keys.newToken')}</p>
2830 <p className="flex items-center gap-2 break-all font-mono text-[13px]">{token} <CopyButton text={token} /></p>
2931 </div>
3032 ) : null}
3133 {keys.loading ? <Busy /> : (
3234 <div className="space-y-2">
33 {(keys.data?.items ?? []).length === 0 ? <p className="text-[13px] text-shell-ink-muted">暂无访问密钥。</p> : null}
35 {(keys.data?.items ?? []).length === 0 ? <p className="text-[13px] text-shell-ink-muted">{t('userSettings.keys.empty')}</p> : null}
3436 {(keys.data?.items ?? []).map(key => (
3537 <div key={key.id} className="settings-card flex flex-wrap items-center gap-3 px-4 py-3">
3638 <span className="text-[13.5px] font-semibold">{key.name}</span>
3739 <span className="rounded-full bg-shell-active px-2 py-0.5 font-mono text-[11px]">{key.scope}</span>
38 <span className="text-[12px] text-shell-ink-muted">创建 {fmtDate(key.created_at)} · 最近使用 {fmtDate(key.last_used_at)}</span>
40 <span className="text-[12px] text-shell-ink-muted">
41 {t('userSettings.keys.createdAt', { created: fmtDate(key.created_at) })} · {t('userSettings.keys.lastUsed', { used: fmtDate(key.last_used_at) })}
42 </span>
3943 <div className="ml-auto flex gap-2">
40 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void run(async () => { const result = await api.rotateAccessKey(key.id); setToken(result.token); setTick(t => t + 1) }, '已轮换,请复制新密钥。')}>轮换</Button>
41 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void run(async () => { await api.deleteAccessKey(key.id); setTick(t => t + 1) }, '密钥已删除。')}>删除</Button>
44 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void run(async () => { const result = await api.rotateAccessKey(key.id); setToken(result.token); setTick(t => t + 1) }, t('userSettings.keys.rotated'))}>{t('userSettings.keys.rotate')}</Button>
45 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void run(async () => { await api.deleteAccessKey(key.id); setTick(t => t + 1) }, t('userSettings.keys.deleted'))}>{t('userSettings.keys.delete')}</Button>
4246 </div>
4347 </div>
4448 ))}
4549 </div>
4650 )}
47 <div className="mt-4"><SaveBar busy={busy} notice={notice} error={error} label="刷新列表" onSave={() => setTick(t => t + 1)} /></div>
51 <div className="mt-4"><SaveBar busy={busy} notice={notice} error={error} label={t('userSettings.emails.refresh')} onSave={() => setTick(t => t + 1)} /></div>
4852 </section>
4953 )
5054}
Modified web/src/features/user-settings/ui/sections/account-section.tsx View web/src/features/user-settings/ui/sections/account-section.tsx
@@ -1,9 +1,21 @@
11import { useRef, useState } from 'react'
2import { Button, CopyButton, Field, SaveBar, SectionTitle, fmtDate, inputCls, useSave } from '@/shared/ui'
2import { useT, type Locale } from '@/shared/i18n'
3import {
4 Button,
5 CopyButton,
6 Field,
7 LanguagePicker,
8 SaveBar,
9 SectionTitle,
10 fmtDate,
11 inputCls,
12 useSave,
13} from '@/shared/ui'
314import * as api from '../../model/api'
415import type { Me } from '../../model/user-settings'
516
617export function AccountSection({ me, onMeChanged }: { me: Me; onMeChanged: () => void }) {
18 const { t } = useT()
719 const { busy, notice, error, run } = useSave()
820 const [displayName, setDisplayName] = useState(me.user.display_name)
921 const [username, setUsername] = useState(me.user.username)
@@ -14,7 +26,7 @@
1426
1527 return (
1628 <section>
17 <SectionTitle title="我的账户" sub="账号身份、语言区域与危险操作。" />
29 <SectionTitle title={t('settings.account.title')} sub={t('settings.account.sub')} />
1830 <div className="mb-6 flex items-center gap-4">
1931 {me.user.avatar_url ? (
2032 <img src={me.user.avatar_url} alt="" className="h-16 w-16 rounded-full object-cover" />
@@ -31,32 +43,51 @@
3143 className="hidden"
3244 onChange={event => {
3345 const file = event.target.files?.[0]
34 if (file) void run(async () => { await api.uploadAvatar(file); onMeChanged() }, '头像已更新。')
46 if (file) {
47 void run(async () => {
48 await api.uploadAvatar(file)
49 onMeChanged()
50 }, t('settings.account.avatarUpdated'))
51 }
3552 event.target.value = ''
3653 }}
3754 />
3855 <Button variant="secondary" className="h-9 text-[13px]" disabled={busy} onClick={() => fileRef.current?.click()}>
39 上传头像
56 {t('settings.account.uploadAvatar')}
4057 </Button>
41 <Button variant="secondary" className="h-9 text-[13px]" disabled={busy} onClick={() => void run(async () => { await api.deleteAvatar(); onMeChanged() }, '头像已移除。')}>
42 移除
58 <Button
59 variant="secondary"
60 className="h-9 text-[13px]"
61 disabled={busy}
62 onClick={() =>
63 void run(async () => {
64 await api.deleteAvatar()
65 onMeChanged()
66 }, t('settings.account.avatarRemoved'))
67 }
68 >
69 {t('settings.account.removeAvatar')}
4370 </Button>
4471 </div>
4572 </div>
46 <Field label="显示名称">
73 <Field label={t('settings.account.displayName')}>
4774 <input className={inputCls} value={displayName} onChange={e => setDisplayName(e.target.value)} maxLength={64} />
4875 </Field>
49 <Field label="用户名" hint="小写字母、数字与下划线;修改后立即生效。">
76 <Field label={t('settings.account.username')} hint={t('settings.account.usernameHint')}>
5077 <input className={inputCls} value={username} onChange={e => setUsername(e.target.value.toLowerCase())} maxLength={32} />
5178 </Field>
5279 <div className="grid max-w-[700px] grid-cols-3 gap-3">
53 <Field label="语言">
54 <input className={`${inputCls} max-w-none`} value={language} onChange={e => setLanguage(e.target.value)} placeholder="zh-CN" />
80 <Field label={t('settings.account.language')}>
81 <LanguagePicker
82 className={`${inputCls} max-w-none`}
83 value={language}
84 onChange={(next: Locale | null) => setLanguage(next ?? '')}
85 />
5586 </Field>
56 <Field label="地区">
87 <Field label={t('settings.account.locale')}>
5788 <input className={`${inputCls} max-w-none`} value={locale} onChange={e => setLocale(e.target.value)} placeholder="zh_CN" />
5889 </Field>
59 <Field label="时区">
90 <Field label={t('settings.account.timezone')}>
6091 <input className={`${inputCls} max-w-none`} value={timezone} onChange={e => setTimezone(e.target.value)} placeholder="Asia/Shanghai" />
6192 </Field>
6293 </div>
@@ -75,28 +106,37 @@
75106 timezone: timezone || null,
76107 })
77108 onMeChanged()
78 }, '账户信息已保存。')
109 }, t('settings.account.saved'))
79110 }
80111 />
81112 </div>
82113 <dl className="mb-8 space-y-1.5 text-[12.5px] text-shell-ink-muted">
83114 <div className="flex gap-2">
84 <dt>用户 ID</dt>
115 <dt>{t('settings.account.userId')}</dt>
85116 <dd className="flex items-center gap-2 font-mono text-shell-ink">{me.user.user_id} <CopyButton text={me.user.user_id} /></dd>
86117 </div>
87 <div className="flex gap-2"><dt>注册时间</dt><dd className="text-shell-ink">{fmtDate(me.user.created_at)}</dd></div>
88 <div className="flex gap-2"><dt>状态</dt><dd className="text-shell-ink">{me.user.is_active ? '正常' : '已停用'}{me.user.is_restricted ? ' · 受限' : ''}</dd></div>
118 <div className="flex gap-2">
119 <dt>{t('settings.account.createdAt')}</dt>
120 <dd className="text-shell-ink">{fmtDate(me.user.created_at)}</dd>
121 </div>
122 <div className="flex gap-2">
123 <dt>{t('settings.account.status')}</dt>
124 <dd className="text-shell-ink">
125 {me.user.is_active ? t('settings.account.active') : t('settings.account.inactive')}
126 {me.user.is_restricted ? ` · ${t('settings.account.restricted')}` : ''}
127 </dd>
128 </div>
89129 </dl>
90130 <div className="rounded-lg border-2 border-[#f23f43]/40 p-4">
91 <h3 className="text-[14px] font-bold text-[#f23f43]">危险操作</h3>
92 <p className="mb-3 mt-1 text-[12.5px] text-shell-ink-muted">撤销除当前会话外的所有登录会话。</p>
131 <h3 className="text-[14px] font-bold text-[#f23f43]">{t('settings.account.danger')}</h3>
132 <p className="mb-3 mt-1 text-[12.5px] text-shell-ink-muted">{t('settings.account.revokeNote')}</p>
93133 <SaveBar
94134 danger
95 label="注销所有会话"
135 label={t('settings.account.revokeAll')}
96136 busy={busy}
97137 notice={notice}
98138 error={error}
99 onSave={() => void run(async () => { await api.revokeAllSessions() }, '其他会话已全部注销。')}
139 onSave={() => void run(async () => { await api.revokeAllSessions() }, t('settings.account.revoked'))}
100140 />
101141 </div>
102142 </section>
Modified web/src/features/user-settings/ui/sections/appearance-section.tsx View web/src/features/user-settings/ui/sections/appearance-section.tsx
@@ -1,18 +1,20 @@
11import { useState } from 'react'
2import { useT, type MessageKey } from '@/shared/i18n'
23import { previewColors, setTheme } from '@/shared/lib'
34import { Busy, Field, SaveBar, SectionTitle, inputCls, useSave, useThemeContext } from '@/shared/ui'
45import * as api from '../../model/api'
56import { usePreferences } from '../../model/preferences'
67
7const DIFF_STYLE_OPTIONS = [
8 { value: '', label: '默认(跟随服务端设置)', note: '不写入 diff_view_style,由服务端决定' },
9 { value: 'unified', label: '统一 · 增删同列上下排列', note: '保存值 unified' },
10 { value: 'split', label: '分栏 · 左删右增两列对照', note: '保存值 split' },
8const DIFF_STYLE_OPTIONS: ReadonlyArray<{ value: string; key: MessageKey; noteKey: MessageKey }> = [
9 { value: '', key: 'settings.appearance.diffDefault', noteKey: 'settings.appearance.diffDefaultNote' },
10 { value: 'unified', key: 'settings.appearance.diffUnified', noteKey: 'settings.appearance.diffUnifiedNote' },
11 { value: 'split', key: 'settings.appearance.diffSplit', noteKey: 'settings.appearance.diffSplitNote' },
1112]
1213
1314/** Static illustration of the chosen layout — the setting is otherwise
1415 * invisible until you open a commit. */
1516function DiffStylePreview({ style }: { style: 'unified' | 'split' }) {
17 const { t } = useT()
1618 const rows = [
1719 { kind: 'ctx', no: 16, text: ' browsergym-related:' },
1820 { kind: 'ctx', no: 17, text: ' patterns:' },
@@ -53,7 +55,7 @@
5355 return (
5456 <div className="settings-card max-w-[620px] overflow-hidden">
5557 <p className="border-b border-shell-hairline bg-shell-field px-3 py-1.5 font-mono text-[10px] uppercase tracking-[1.2px] text-shell-ink-muted">
56 示例 · {style === 'split' ? '分栏(左原内容 / 右新内容)' : '统一(单列)'}
58 {t('userSettings.appearance.example', { style: style === 'split' ? t('userSettings.appearance.splitExample') : t('userSettings.appearance.unifiedExample') })}
5759 </p>
5860 <table className="w-full border-collapse font-mono text-[11.5px] leading-5">
5961 <tbody>
@@ -95,13 +97,14 @@
9597 * the server only stores the light/dark preference.
9698 */
9799function PalettePicker() {
100 const { t } = useT()
98101 const { paletteId, palette, mode, variant, encoded, palettes, setPalette, resetPalette } = useThemeContext()
99102 return (
100103 <div className="mb-6">
101104 <span className="mb-2 block font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
102 主题配色
105 {t('userSettings.appearance.themeColor')}
103106 </span>
104 <div className="palette-grid" role="radiogroup" aria-label="主题配色">
107 <div className="palette-grid" role="radiogroup" aria-label={t('userSettings.appearance.themeColor')}>
105108 {palettes.map(item => {
106109 const active = item.id === paletteId
107110 return (
@@ -127,12 +130,12 @@
127130 </div>
128131 <p className="palette-meta">
129132 <span>
130 当前「{palette.name}」· 以{variant === 'light' ? '浅色' : '深色'}渲染 · 已存入本机
133 {t('userSettings.appearance.currentPalette', { name: palette.name, mode: variant === 'light' ? t('settings.appearance.themeLight') : t('settings.appearance.themeDark') })}
131134 </span>
132135 <code title={encoded}>{encoded.slice(0, 32)}…</code>
133136 {paletteId !== 'graphite' ? (
134137 <button type="button" className="palette-restore" onClick={resetPalette}>
135 恢复默认
138 {t('userSettings.appearance.resetDefault')}
136139 </button>
137140 ) : null}
138141 </p>
@@ -141,6 +144,7 @@
141144}
142145
143146export function AppearanceSection() {
147 const { t } = useT()
144148 const prefs = usePreferences()
145149 const { busy, notice, error, run } = useSave()
146150 const [form, setForm] = useState<{ theme: string; diff: string } | null>(null)
@@ -149,7 +153,7 @@
149153 if (prefs.loading || !current)
150154 return (
151155 <>
152 <SectionTitle title="外观" />
156 <SectionTitle title={t('settings.appearance.title')} />
153157 <Busy />
154158 </>
155159 )
@@ -158,9 +162,9 @@
158162
159163 return (
160164 <section>
161 <SectionTitle title="外观" sub="配色、主题模式与 diff 展示风格。" />
165 <SectionTitle title={t('settings.appearance.title')} sub={t('settings.appearance.sub')} />
162166 <PalettePicker />
163 <Field label="主题模式">
167 <Field label={t('settings.appearance.themeMode')}>
164168 <select
165169 className={inputCls}
166170 value={current.theme}
@@ -169,12 +173,15 @@
169173 setForm({ ...current, theme: e.target.value })
170174 }}
171175 >
172 <option value="system">跟随系统</option>
173 <option value="light">浅色</option>
174 <option value="dark">深色</option>
176 <option value="system">{t('userSettings.appearance.themeSystem')}</option>
177 <option value="light">{t('settings.appearance.themeLight')}</option>
178 <option value="dark">{t('settings.appearance.themeDark')}</option>
175179 </select>
176180 </Field>
177 <Field label="Diff 视图风格" hint={`${option.note} · 服务端仅识别 split,其它值一律按统一渲染`}>
181 <Field
182 label={t('settings.appearance.diffStyle')}
183 hint={`${t(option.noteKey)} · ${t('settings.appearance.diffHintSuffix')}`}
184 >
178185 <select
179186 className={inputCls}
180187 value={current.diff}
@@ -182,7 +189,7 @@
182189 >
183190 {DIFF_STYLE_OPTIONS.map(item => (
184191 <option key={item.value || 'default'} value={item.value}>
185 {item.label}
192 {t(item.key)}
186193 </option>
187194 ))}
188195 </select>
@@ -198,7 +205,7 @@
198205 void run(async () => {
199206 await api.updatePreferences({ theme_mode: current.theme, diff_view_style: current.diff || null })
200207 applyMode(current.theme)
201 }, '外观设置已保存。')
208 }, t('settings.appearance.saved'))
202209 }
203210 />
204211 </section>
Modified web/src/features/user-settings/ui/sections/emails-section.tsx View web/src/features/user-settings/ui/sections/emails-section.tsx
@@ -1,9 +1,11 @@
11import { useState } from 'react'
2import { useT } from '@/shared/i18n'
23import { Busy, Button, SaveBar, SectionTitle, inputCls, useSave } from '@/shared/ui'
34import { settingsKey, useAsync } from '@/shared/lib'
45import * as api from '../../model/api'
56
67export function EmailsSection() {
8 const { t } = useT()
79 const [tick, setTick] = useState(0)
810 const emails = useAsync(api.listEmails, [tick], { cacheKey: settingsKey('emails') })
911 const { busy, notice, error, run } = useSave()
@@ -13,35 +15,35 @@
1315
1416 return (
1517 <section>
16 <SectionTitle title="邮箱" sub="登录邮箱、主邮箱与验证状态。" />
18 <SectionTitle title={t('userSettings.emails.title')} sub={t('userSettings.emails.sub')} />
1719 {emails.loading ? <Busy /> : (
1820 <div className="mb-6 space-y-2">
1921 {(emails.data ?? []).map(email => (
2022 <div key={email.id} className="settings-card flex flex-wrap items-center gap-3 px-4 py-3">
2123 <span className="text-[13.5px] font-semibold">{email.email}</span>
22 {email.is_primary ? <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]">主邮箱</span> : null}
23 {email.is_activated ? <span className="rounded-full bg-[#23a55a]/15 px-2 py-0.5 text-[11px] text-[#23a55a]">已验证</span> : <span className="rounded-full bg-[#f0b232]/15 px-2 py-0.5 text-[11px] text-[#8a6100]">待验证</span>}
24 {email.is_primary ? <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]">{t('userSettings.emails.primary')}</span> : null}
25 {email.is_activated ? <span className="rounded-full bg-[#23a55a]/15 px-2 py-0.5 text-[11px] text-[#23a55a]">{t('userSettings.emails.verified')}</span> : <span className="rounded-full bg-[#f0b232]/15 px-2 py-0.5 text-[11px] text-[#8a6100]">{t('userSettings.emails.pending')}</span>}
2426 <div className="ml-auto flex gap-2">
2527 {!email.is_primary ? (
26 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void run(async () => { await api.setPrimaryEmail(email.id); setTick(t => t + 1) }, '主邮箱已切换。')}>设为主要</Button>
28 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void run(async () => { await api.setPrimaryEmail(email.id); setTick(t => t + 1) }, t('userSettings.emails.primaryChanged'))}>{t('userSettings.emails.setPrimary')}</Button>
2729 ) : null}
28 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void run(async () => { await api.deleteEmail(email.id); setTick(t => t + 1) }, '邮箱已删除。')}>删除</Button>
30 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void run(async () => { await api.deleteEmail(email.id); setTick(t => t + 1) }, t('userSettings.emails.deleted'))}>{t('common.action.delete')}</Button>
2931 </div>
3032 </div>
3133 ))}
3234 </div>
3335 )}
3436 <div className="flex max-w-[620px] gap-2">
35 <input className={inputCls} type="email" placeholder="新邮箱地址" value={newEmail} onChange={e => setNewEmail(e.target.value)} />
36 <Button disabled={busy || !newEmail} onClick={() => void run(async () => { const result = await api.addEmail(newEmail); if (result.pending) setPending(newEmail); setTick(t => t + 1) }, '添加请求已提交。')}>添加邮箱</Button>
37 <input className={inputCls} type="email" placeholder={t('userSettings.emails.newPlaceholder')} value={newEmail} onChange={e => setNewEmail(e.target.value)} />
38 <Button disabled={busy || !newEmail} onClick={() => void run(async () => { const result = await api.addEmail(newEmail); if (result.pending) setPending(newEmail); setTick(t => t + 1) }, t('userSettings.emails.added'))}>{t('userSettings.emails.add')}</Button>
3739 </div>
3840 {pending ? (
3941 <div className="mt-3 flex max-w-[620px] gap-2">
40 <input className={inputCls} placeholder={`发往 ${pending} 的验证码`} value={verifyCode} onChange={e => setVerifyCode(e.target.value)} />
41 <Button disabled={busy || !verifyCode} onClick={() => void run(async () => { await api.verifyAddEmail(pending, verifyCode); setPending(null); setVerifyCode(''); setNewEmail(''); setTick(t => t + 1) }, '邮箱已验证。')}>验证</Button>
42 <input className={inputCls} placeholder={t('userSettings.emails.codePlaceholder', { email: pending })} value={verifyCode} onChange={e => setVerifyCode(e.target.value)} />
43 <Button disabled={busy || !verifyCode} onClick={() => void run(async () => { await api.verifyAddEmail(pending, verifyCode); setPending(null); setVerifyCode(''); setNewEmail(''); setTick(t => t + 1) }, t('userSettings.emails.verifiedNotice'))}>{t('userSettings.emails.verify')}</Button>
4244 </div>
4345 ) : null}
44 <div className="mt-4"><SaveBar busy={busy} notice={notice} error={error} label="刷新列表" onSave={() => setTick(t => t + 1)} /></div>
46 <div className="mt-4"><SaveBar busy={busy} notice={notice} error={error} label={t('userSettings.emails.refresh')} onSave={() => setTick(t => t + 1)} /></div>
4547 </section>
4648 )
4749}
Modified web/src/features/user-settings/ui/sections/notifications-section.tsx View web/src/features/user-settings/ui/sections/notifications-section.tsx
@@ -1,23 +1,25 @@
11import { useState } from 'react'
2import { useT } from '@/shared/i18n'
23import * as api from '../../model/api'
34import { usePreferences } from '../../model/preferences'
45import { Busy, Field, SaveBar, SectionTitle, Toggle, inputCls, useSave } from '@/shared/ui'
56
67export function NotificationsSection() {
8 const { t } = useT()
79 const prefs = usePreferences()
810 const { busy, notice, error, run } = useSave()
911 const [form, setForm] = useState<{ digest: boolean; mute: string } | null>(null)
1012 const current = form ?? (prefs.data ? { digest: prefs.data.email_digest, mute: prefs.data.mute_until?.slice(0, 16) ?? '' } : null)
11 if (prefs.loading || !current) return <><SectionTitle title="通知" /><Busy /></>
13 if (prefs.loading || !current) return <><SectionTitle title={t('settings.notifications.title')} /><Busy /></>
1214
1315 return (
1416 <section>
15 <SectionTitle title="通知" sub="邮件摘要与免打扰时段。" />
16 <Toggle checked={current.digest} onChange={v => setForm({ ...current, digest: v })} label="接收邮件摘要(每日)" />
17 <Field label="免打扰截止时间" hint="留空表示不启用免打扰。">
17 <SectionTitle title={t('settings.notifications.title')} sub={t('settings.notifications.sub')} />
18 <Toggle checked={current.digest} onChange={v => setForm({ ...current, digest: v })} label={t('userSettings.notifications.digest')} />
19 <Field label={t('userSettings.notifications.muteUntil')} hint={t('userSettings.notifications.muteHint')}>
1820 <input type="datetime-local" className={inputCls} value={current.mute} onChange={e => setForm({ ...current, mute: e.target.value })} />
1921 </Field>
20 <SaveBar busy={busy} notice={notice} error={error} onSave={() => void run(async () => { await api.updatePreferences({ email_digest: current.digest, mute_until: current.mute ? new Date(current.mute).toISOString() : null }) }, '通知设置已保存。')} />
22 <SaveBar busy={busy} notice={notice} error={error} onSave={() => void run(async () => { await api.updatePreferences({ email_digest: current.digest, mute_until: current.mute ? new Date(current.mute).toISOString() : null }) }, t('settings.notifications.saved'))} />
2123 </section>
2224 )
2325}
Modified web/src/features/user-settings/ui/sections/privacy-section.tsx View web/src/features/user-settings/ui/sections/privacy-section.tsx
@@ -1,36 +1,44 @@
11import { useState } from 'react'
2import { useT } from '@/shared/i18n'
23import * as api from '../../model/api'
34import { usePreferences } from '../../model/preferences'
45import { Busy, Field, SaveBar, SectionTitle, Toggle, inputCls, useSave } from '@/shared/ui'
56
6const AUDIENCE_OPTIONS = [
7 { value: 'Everyone', label: '所有人' },
8 { value: 'FollowingOnly', label: '仅我关注的人' },
9 { value: 'Nobody', label: '没有人' },
10]
7/** Values stay the API's own enum names; only the labels are localized. */
8const AUDIENCE_VALUES = ['Everyone', 'FollowingOnly', 'Nobody'] as const
9const AUDIENCE_KEYS = {
10 Everyone: 'label.audience.everyone',
11 FollowingOnly: 'label.audience.following',
12 Nobody: 'label.audience.nobody',
13} as const
1114
1215export function PrivacySection() {
16 const { t } = useT()
1317 const prefs = usePreferences()
1418 const { busy, notice, error, run } = useSave()
1519 const [form, setForm] = useState<{ dms: string; mentions: string; online: boolean } | null>(null)
1620 const current = form ?? (prefs.data ? { dms: prefs.data.allow_dms_from, mentions: prefs.data.allow_mentions_from, online: prefs.data.show_online_status } : null)
17 if (prefs.loading || !current) return <><SectionTitle title="隐私设置" /><Busy /></>
21 if (prefs.loading || !current) return <><SectionTitle title={t('settings.privacy.title')} /><Busy /></>
1822
1923 return (
2024 <section>
21 <SectionTitle title="隐私设置" sub="个人账号的私信与提及权限,对全部社区生效。" />
22 <Field label="允许私信">
25 <SectionTitle title={t('settings.privacy.title')} sub={t('settings.privacy.sub')} />
26 <Field label={t('userSettings.privacy.dms')}>
2327 <select className={inputCls} value={current.dms} onChange={e => setForm({ ...current, dms: e.target.value })}>
24 {AUDIENCE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
28 {AUDIENCE_VALUES.map(value => (
29 <option key={value} value={value}>{t(AUDIENCE_KEYS[value])}</option>
30 ))}
2531 </select>
2632 </Field>
27 <Field label="允许提及">
33 <Field label={t('userSettings.privacy.mentions')}>
2834 <select className={inputCls} value={current.mentions} onChange={e => setForm({ ...current, mentions: e.target.value })}>
29 {AUDIENCE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
35 {AUDIENCE_VALUES.map(value => (
36 <option key={value} value={value}>{t(AUDIENCE_KEYS[value])}</option>
37 ))}
3038 </select>
3139 </Field>
32 <Toggle checked={current.online} onChange={v => setForm({ ...current, online: v })} label="展示在线状态" />
33 <SaveBar busy={busy} notice={notice} error={error} onSave={() => void run(async () => { await api.updatePreferences({ allow_dms_from: current.dms as 'Everyone', allow_mentions_from: current.mentions as 'Everyone', show_online_status: current.online }) }, '隐私设置已保存。')} />
40 <Toggle checked={current.online} onChange={v => setForm({ ...current, online: v })} label={t('userSettings.privacy.online')} />
41 <SaveBar busy={busy} notice={notice} error={error} onSave={() => void run(async () => { await api.updatePreferences({ allow_dms_from: current.dms as 'Everyone', allow_mentions_from: current.mentions as 'Everyone', show_online_status: current.online }) }, t('settings.privacy.saved'))} />
3442 </section>
3543 )
3644}
Modified web/src/features/user-settings/ui/sections/profile-section.tsx View web/src/features/user-settings/ui/sections/profile-section.tsx
@@ -2,16 +2,18 @@
22import type { ProfileVisibility } from '@/shared/api'
33import * as api from '../../model/api'
44import type { Me } from '../../model/user-settings'
5import { useT, type MessageKey } from '@/shared/i18n'
56import { Field, SaveBar, SectionTitle, Toggle, inputCls, useSave } from '@/shared/ui'
67
7const VISIBILITY_OPTIONS = [
8 { value: 'Public', label: '公开' },
9 { value: 'RegisteredOnly', label: '仅注册用户' },
10 { value: 'MembersOnly', label: '仅成员' },
11 { value: 'Private', label: '私密' },
8const VISIBILITY_OPTIONS: ReadonlyArray<{ value: string; key: MessageKey }> = [
9 { value: 'Public', key: 'label.visibility.public' },
10 { value: 'RegisteredOnly', key: 'label.visibility.registered' },
11 { value: 'MembersOnly', key: 'label.visibility.members' },
12 { value: 'Private', key: 'label.visibility.private' },
1213]
1314
1415export function ProfileSection({ me, onMeChanged }: { me: Me; onMeChanged: () => void }) {
16 const { t } = useT()
1517 const { busy, notice, error, run } = useSave()
1618 const profile = me.profile
1719 const [form, setForm] = useState({
@@ -33,8 +35,8 @@
3335
3436 return (
3537 <section>
36 <SectionTitle title="公开资料" sub="其他用户看到的资料页与可见性开关。" />
37 <Field label="简介">
38 <SectionTitle title={t('settings.profile.title')} sub={t('settings.profile.sub')} />
39 <Field label={t('userSettings.profile.bio')}>
3840 <textarea
3941 className="w-full max-w-[560px] resize-none rounded-md border border-shell-hairline bg-shell-field px-3 py-2 text-[13.5px] outline-none"
4042 rows={3}
@@ -44,24 +46,24 @@
4446 />
4547 </Field>
4648 <div className="grid max-w-[700px] grid-cols-2 gap-3">
47 <Field label="网站"><input className={`${inputCls} max-w-none`} value={form.website} onChange={e => set('website', e.target.value)} /></Field>
48 <Field label="公司"><input className={`${inputCls} max-w-none`} value={form.company} onChange={e => set('company', e.target.value)} /></Field>
49 <Field label="所在地"><input className={`${inputCls} max-w-none`} value={form.location} onChange={e => set('location', e.target.value)} /></Field>
50 <Field label="代词"><input className={`${inputCls} max-w-none`} value={form.pronouns} onChange={e => set('pronouns', e.target.value)} /></Field>
51 <Field label="真实姓名"><input className={`${inputCls} max-w-none`} value={form.realName} onChange={e => set('realName', e.target.value)} /></Field>
52 <Field label="出生日期"><input type="date" className={`${inputCls} max-w-none`} value={form.dateOfBirth} onChange={e => set('dateOfBirth', e.target.value)} /></Field>
49 <Field label={t('userSettings.profile.website')}><input className={`${inputCls} max-w-none`} value={form.website} onChange={e => set('website', e.target.value)} /></Field>
50 <Field label={t('userSettings.profile.company')}><input className={`${inputCls} max-w-none`} value={form.company} onChange={e => set('company', e.target.value)} /></Field>
51 <Field label={t('userSettings.profile.location')}><input className={`${inputCls} max-w-none`} value={form.location} onChange={e => set('location', e.target.value)} /></Field>
52 <Field label={t('settings.profile.pronouns')}><input className={`${inputCls} max-w-none`} value={form.pronouns} onChange={e => set('pronouns', e.target.value)} /></Field>
53 <Field label={t('settings.profile.realName')}><input className={`${inputCls} max-w-none`} value={form.realName} onChange={e => set('realName', e.target.value)} /></Field>
54 <Field label={t('settings.profile.dateOfBirth')}><input type="date" className={`${inputCls} max-w-none`} value={form.dateOfBirth} onChange={e => set('dateOfBirth', e.target.value)} /></Field>
5355 </div>
54 <Field label="资料可见性">
56 <Field label={t('userSettings.profile.visibility')}>
5557 <select className={inputCls} value={form.visibility} onChange={e => set('visibility', e.target.value as ProfileVisibility)}>
5658 {VISIBILITY_OPTIONS.map(option => (
57 <option key={option.value} value={option.value}>{option.label}</option>
59 <option key={option.value} value={option.value}>{t(option.key)}</option>
5860 ))}
5961 </select>
6062 </Field>
61 <Toggle checked={form.showEmail} onChange={v => set('showEmail', v)} label="公开邮箱" />
62 <Toggle checked={form.showLocation} onChange={v => set('showLocation', v)} label="公开所在地" />
63 <Toggle checked={form.showRealName} onChange={v => set('showRealName', v)} label="公开真实姓名" />
64 <Toggle checked={form.showActivity} onChange={v => set('showActivity', v)} label="公开动态" />
63 <Toggle checked={form.showEmail} onChange={v => set('showEmail', v)} label={t('settings.profile.showEmail')} />
64 <Toggle checked={form.showLocation} onChange={v => set('showLocation', v)} label={t('settings.profile.showLocation')} />
65 <Toggle checked={form.showRealName} onChange={v => set('showRealName', v)} label={t('settings.profile.showRealName')} />
66 <Toggle checked={form.showActivity} onChange={v => set('showActivity', v)} label={t('settings.profile.showActivity')} />
6567 <SaveBar
6668 busy={busy}
6769 notice={notice}
@@ -83,7 +85,7 @@
8385 show_activity: form.showActivity,
8486 })
8587 onMeChanged()
86 }, '公开资料已保存。')
88 }, t('settings.profile.saved'))
8789 }
8890 />
8991 </section>
Modified web/src/features/user-settings/ui/sections/security-section.tsx View web/src/features/user-settings/ui/sections/security-section.tsx
@@ -1,9 +1,11 @@
11import { useState } from 'react'
22import { Busy, Button, CopyButton, SaveBar, SectionTitle, inputCls, useSave } from '@/shared/ui'
3import { useT } from '@/shared/i18n'
34import { ApiFailure, settingsKey, useAsync } from '@/shared/lib'
45import * as api from '../../model/api'
56
67export function SecuritySection() {
8 const { t } = useT()
79 const { busy, notice, error, run } = useSave()
810 const [oldPassword, setOldPassword] = useState('')
911 const [newPassword, setNewPassword] = useState('')
@@ -19,69 +21,69 @@
1921
2022 return (
2123 <section>
22 <SectionTitle title="密码与安全" sub="修改密码、两步验证与恢复代码。" />
23 <h3 className="mb-3 text-[14px] font-bold">修改密码</h3>
24 <SectionTitle title={t('settings.security.title')} sub={t('userSettings.security.sub')} />
25 <h3 className="mb-3 text-[14px] font-bold">{t('userSettings.security.changePassword')}</h3>
2426 <div className="mb-2 max-w-[440px] space-y-3">
25 <input type="password" className={inputCls} placeholder="当前密码" value={oldPassword} onChange={e => setOldPassword(e.target.value)} />
26 <input type="password" className={inputCls} placeholder="新密码" value={newPassword} onChange={e => setNewPassword(e.target.value)} />
27 <input type="password" className={inputCls} placeholder="确认新密码" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} />
27 <input type="password" className={inputCls} placeholder={t('userSettings.security.currentPlaceholder')} value={oldPassword} onChange={e => setOldPassword(e.target.value)} />
28 <input type="password" className={inputCls} placeholder={t('userSettings.security.newPlaceholder')} value={newPassword} onChange={e => setNewPassword(e.target.value)} />
29 <input type="password" className={inputCls} placeholder={t('userSettings.security.confirmPlaceholder')} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} />
2830 </div>
2931 <div className="mb-8">
3032 <SaveBar
3133 busy={busy}
3234 notice={notice}
3335 error={error}
34 label="更新密码"
36 label={t('userSettings.security.update')}
3537 onSave={() => {
36 if (!oldPassword || !newPassword) return void run(async () => { throw new ApiFailure('请填写当前与新密码。', 400) })
37 if (newPassword !== confirmPassword) return void run(async () => { throw new ApiFailure('两次输入的新密码不一致。', 400) })
38 return void run(async () => { await api.changePassword({ old_password: oldPassword, new_password: newPassword }); setOldPassword(''); setNewPassword(''); setConfirmPassword('') }, '密码已更新。')
38 if (!oldPassword || !newPassword) return void run(async () => { throw new ApiFailure(t('userSettings.security.fillBoth'), 400) })
39 if (newPassword !== confirmPassword) return void run(async () => { throw new ApiFailure(t('userSettings.security.mismatch'), 400) })
40 return void run(async () => { await api.changePassword({ old_password: oldPassword, new_password: newPassword }); setOldPassword(''); setNewPassword(''); setConfirmPassword('') }, t('userSettings.security.passwordUpdated'))
3941 }}
4042 />
4143 </div>
42 <h3 className="mb-3 text-[14px] font-bold">两步验证(TOTP)</h3>
44 <h3 className="mb-3 text-[14px] font-bold">{t('userSettings.security.totpHeading')}</h3>
4345 {status.loading ? (
4446 <Busy />
4547 ) : (
4648 <>
4749 <p className="mb-3 text-[13px] text-shell-ink-muted">
48 当前状态:
50 {t('userSettings.security.status')}
4951 <span className={status.data?.enabled ? 'ml-1 font-semibold text-[#23a55a]' : 'ml-1 font-semibold text-[#f23f43]'}>
50 {status.data?.enabled ? '已开启' : '未开启'}
52 {status.data?.enabled ? t('userSettings.security.enabled') : t('userSettings.security.notEnabled')}
5153 </span>
5254 </p>
5355 {!status.data?.enabled && !setup ? (
54 <Button variant="secondary" className="h-9 text-[13px]" onClick={() => void run(async () => setSetup(await api.totpSetup()), '用验证器 App 添加以下密钥后输入 6 位验证码完成开启。')}>
55 开启两步验证
56 <Button variant="secondary" className="h-9 text-[13px]" onClick={() => void run(async () => setSetup(await api.totpSetup()), t('userSettings.security.setupHint'))}>
57 {t('userSettings.security.enable')}
5658 </Button>
5759 ) : null}
5860 {setup ? (
5961 <div className="settings-card mb-4 max-w-[560px] p-4">
60 <p className="mb-1 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">密钥</p>
62 <p className="mb-1 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">{t('userSettings.security.secret')}</p>
6163 <p className="mb-3 flex items-center gap-2 font-mono text-[14px]">{setup.secret} <CopyButton text={setup.secret} /></p>
6264 <p className="mb-1 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">otpauth URI</p>
6365 <p className="mb-3 flex items-center gap-2 break-all font-mono text-[11.5px]">{setup.otpauth_uri} <CopyButton text={setup.otpauth_uri} /></p>
6466 <div className="space-y-3">
65 <input className={inputCls} placeholder="验证器 6 位代码" value={enableCode} onChange={e => setEnableCode(e.target.value)} />
66 <input type="password" className={inputCls} placeholder="当前密码(确认用)" value={enablePassword} onChange={e => setEnablePassword(e.target.value)} />
67 <input className={inputCls} placeholder={t('userSettings.security.codePlaceholder')} value={enableCode} onChange={e => setEnableCode(e.target.value)} />
68 <input type="password" className={inputCls} placeholder={t('userSettings.security.passwordConfirmPlaceholder')} value={enablePassword} onChange={e => setEnablePassword(e.target.value)} />
6769 <SaveBar
6870 busy={busy}
6971 notice={notice}
7072 error={error}
71 label="验证并开启"
73 label={t('userSettings.security.verifyAndEnable')}
7274 onSave={() => void run(async () => {
7375 const result = await api.totpEnable(enableCode, enablePassword)
7476 setCodes(result.codes)
7577 setSetup(null)
7678 setStatusTick(t => t + 1)
77 }, '两步验证已开启,请保存下方恢复代码。')}
79 }, t('userSettings.security.enabledNotice'))}
7880 />
7981 </div>
8082 </div>
8183 ) : null}
8284 {codes ? (
8385 <div className="mb-4 max-w-[560px] rounded-lg border-2 border-[#f0b232]/50 bg-shell-field p-4">
84 <p className="mb-2 text-[12.5px] font-semibold text-warning-foreground">恢复代码(仅显示这一次,请妥善保存)</p>
86 <p className="mb-2 text-[12.5px] font-semibold text-warning-foreground">{t('userSettings.security.codesOnce')}</p>
8587 <div className="grid grid-cols-2 gap-x-6 gap-y-1 font-mono text-[13px]">
8688 {codes.map(code => <span key={code}>{code}</span>)}
8789 </div>
@@ -90,18 +92,18 @@
9092 {status.data?.enabled ? (
9193 <div className="settings-card max-w-[560px] space-y-3 p-4">
9294 <div className="flex gap-2">
93 <Button variant="secondary" className="h-9 text-[13px]" onClick={() => void run(async () => setCodes((await api.recoveryCodes()).codes), '已加载恢复代码。')}>
94 查看恢复代码
95 <Button variant="secondary" className="h-9 text-[13px]" onClick={() => void run(async () => setCodes((await api.recoveryCodes()).codes), t('userSettings.security.codesLoaded'))}>
96 {t('userSettings.security.viewCodes')}
9597 </Button>
96 <Button variant="secondary" className="h-9 text-[13px]" onClick={() => void run(async () => { const r = await api.regenerateRecoveryCodes(regenPassword); setCodes(r.codes) }, '恢复代码已重新生成。')}>
97 重新生成
98 <Button variant="secondary" className="h-9 text-[13px]" onClick={() => void run(async () => { const r = await api.regenerateRecoveryCodes(regenPassword); setCodes(r.codes) }, t('settings.security.codesRegenerated'))}>
99 {t('userSettings.security.regenerate')}
98100 </Button>
99101 </div>
100 <input type="password" className={inputCls} placeholder="当前密码(重新生成需要)" value={regenPassword} onChange={e => setRegenPassword(e.target.value)} />
102 <input type="password" className={inputCls} placeholder={t('userSettings.security.regenPasswordPlaceholder')} value={regenPassword} onChange={e => setRegenPassword(e.target.value)} />
101103 <div className="flex gap-2">
102 <input className={inputCls} placeholder="6 位验证码" value={disableCode} onChange={e => setDisableCode(e.target.value)} />
103 <Button className="h-10 bg-[#f23f43] text-[13px] hover:brightness-95" disabled={busy} onClick={() => void run(async () => { await api.totpDisable(disableCode); setStatusTick(t => t + 1); setCodes(null) }, '两步验证已关闭。')}>
104 关闭 2FA
104 <input className={inputCls} placeholder={t('userSettings.security.code6Placeholder')} value={disableCode} onChange={e => setDisableCode(e.target.value)} />
105 <Button className="h-10 bg-[#f23f43] text-[13px] hover:brightness-95" disabled={busy} onClick={() => void run(async () => { await api.totpDisable(disableCode); setStatusTick(t => t + 1); setCodes(null) }, t('settings.security.totpDisabled'))}>
106 {t('userSettings.security.disable')}
105107 </Button>
106108 </div>
107109 {codes ? (
Modified web/src/features/user-settings/ui/sections/sessions-section.tsx View web/src/features/user-settings/ui/sections/sessions-section.tsx
@@ -1,9 +1,11 @@
11import { useState } from 'react'
2import { useT } from '@/shared/i18n'
23import { Busy, Button, SaveBar, SectionTitle, fmtDate, inputCls, useSave } from '@/shared/ui'
34import { settingsKey, useAsync } from '@/shared/lib'
45import * as api from '../../model/api'
56
67export function SessionsSection() {
8 const { t } = useT()
79 const [tick, setTick] = useState(0)
810 const data = useAsync(async () => {
911 const [devices, sessions] = await Promise.all([api.listDevices(), api.listSessions()])
@@ -15,51 +17,53 @@
1517
1618 return (
1719 <section>
18 <SectionTitle title="设备与会话" sub="登录过的设备与当前活跃会话。" />
20 <SectionTitle title={t('userSettings.sessions.title')} sub={t('userSettings.sessions.sub')} />
1921 {data.loading ? (
2022 <Busy />
2123 ) : (
2224 <>
23 <h3 className="mb-3 text-[14px] font-bold">设备</h3>
25 <h3 className="mb-3 text-[14px] font-bold">{t('userSettings.sessions.devices')}</h3>
2426 <div className="mb-8 space-y-2">
25 {(data.data?.devices ?? []).length === 0 ? <p className="text-[13px] text-shell-ink-muted">暂无设备记录。</p> : null}
27 {(data.data?.devices ?? []).length === 0 ? <p className="text-[13px] text-shell-ink-muted">{t('userSettings.sessions.noDevices')}</p> : null}
2628 {(data.data?.devices ?? []).map(device => (
2729 <div key={device.id} className="settings-card flex flex-wrap items-center gap-3 px-4 py-3">
28 <span className="min-w-[160px] text-[13.5px] font-semibold">{device.device_name || device.device_type || '未知设备'}</span>
29 <span className="text-[12px] text-shell-ink-muted">{device.os ?? '—'} · 最近 {fmtDate(device.last_seen_at)}</span>
30 <span className="min-w-[160px] text-[13.5px] font-semibold">{device.device_name || device.device_type || t('userSettings.sessions.unknownDevice')}</span>
31 <span className="text-[12px] text-shell-ink-muted">{device.os ?? '—'} · {t('userSettings.sessions.recently', { when: fmtDate(device.last_seen_at) })}</span>
3032 <input
3133 className={`${inputCls} ml-auto h-8 max-w-[180px]`}
32 placeholder={device.device_name || '重命名…'}
34 placeholder={device.device_name || t('userSettings.sessions.renamePlaceholder')}
3335 value={names[device.id] ?? ''}
3436 onChange={e => setNames(prev => ({ ...prev, [device.id]: e.target.value }))}
3537 />
36 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void run(async () => { await api.renameDevice(device.id, names[device.id] ?? ''); refresh() }, '设备已重命名。')}>
37 重命名
38 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void run(async () => { await api.renameDevice(device.id, names[device.id] ?? ''); refresh() }, t('userSettings.sessions.renamed'))}>
39 {t('userSettings.sessions.rename')}
3840 </Button>
39 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void run(async () => { await api.deleteDevice(device.id); refresh() }, '设备已删除。')}>
40 删除
41 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void run(async () => { await api.deleteDevice(device.id); refresh() }, t('userSettings.sessions.deviceDeleted'))}>
42 {t('common.action.delete')}
4143 </Button>
4244 </div>
4345 ))}
4446 </div>
45 <h3 className="mb-3 text-[14px] font-bold">会话</h3>
47 <h3 className="mb-3 text-[14px] font-bold">{t('userSettings.sessions.sessions')}</h3>
4648 <div className="mb-4 space-y-2">
47 {(data.data?.sessions ?? []).length === 0 ? <p className="text-[13px] text-shell-ink-muted">暂无活跃会话。</p> : null}
49 {(data.data?.sessions ?? []).length === 0 ? <p className="text-[13px] text-shell-ink-muted">{t('userSettings.sessions.noSessions')}</p> : null}
4850 {(data.data?.sessions ?? []).map(session => (
4951 <div key={session.id} className="settings-card flex flex-wrap items-center gap-3 px-4 py-3">
50 <span className="text-[13.5px] font-semibold">{session.kind === 'WebLogin' ? '网页登录' : '访问密钥'}</span>
51 {session.is_current ? <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]">当前设备</span> : null}
52 {session.is_revoked ? <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]">已注销</span> : null}
53 <span className="text-[12px] text-shell-ink-muted">创建于 {fmtDate(session.created_at)} · 最近 {fmtDate(session.last_seen_at)}</span>
52 <span className="text-[13.5px] font-semibold">{session.kind === 'WebLogin' ? t('userSettings.sessions.kindWeb') : t('userSettings.sessions.kindKey')}</span>
53 {session.is_current ? <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]">{t('userSettings.sessions.current')}</span> : null}
54 {session.is_revoked ? <span className="rounded-full bg-shell-active px-2 py-0.5 text-[11px]">{t('userSettings.sessions.revoked')}</span> : null}
55 <span className="text-[12px] text-shell-ink-muted">
56 {t('userSettings.sessions.createdAt', { when: fmtDate(session.created_at) })} · {t('userSettings.sessions.recently', { when: fmtDate(session.last_seen_at) })}
57 </span>
5458 {!session.is_current && !session.is_revoked ? (
55 <Button variant="secondary" className="ml-auto h-8 text-[12.5px]" disabled={busy} onClick={() => void run(async () => { await api.revokeSession(session.id); refresh() }, '会话已注销。')}>
56 注销
59 <Button variant="secondary" className="ml-auto h-8 text-[12.5px]" disabled={busy} onClick={() => void run(async () => { await api.revokeSession(session.id); refresh() }, t('settings.sessions.revoked'))}>
60 {t('userSettings.sessions.revoke')}
5761 </Button>
5862 ) : null}
5963 </div>
6064 ))}
6165 </div>
62 <SaveBar busy={busy} notice={notice} error={error} label="注销所有其他会话" onSave={() => void run(async () => { await api.revokeAllSessions(); refresh() }, '其他会话已全部注销。')} />
66 <SaveBar busy={busy} notice={notice} error={error} label={t('userSettings.sessions.revokeAll')} onSave={() => void run(async () => { await api.revokeAllSessions(); refresh() }, t('settings.account.revoked'))} />
6367 </>
6468 )}
6569 </section>
Modified web/src/features/user-settings/ui/sections/ssh-keys-section.tsx View web/src/features/user-settings/ui/sections/ssh-keys-section.tsx
@@ -1,5 +1,6 @@
11import { useState } from 'react'
22import { Alert, Busy, Button, CopyButton, SectionTitle, fmtDate, useSave } from '@/shared/ui'
3import { t, useT } from '@/shared/i18n'
34import { ApiFailure, settingsKey, useAsync } from '@/shared/lib'
45import { FormModal } from '@/shared/ui'
56import * as api from '../../model/api'
@@ -23,17 +24,17 @@
2324 const raw = err instanceof ApiFailure ? err.message : ''
2425 const mapped =
2526 code === 409
26 ? '该公钥已被登记(指纹全站唯一,可能已属于其他账户)。'
27 ? t('userSettings.ssh.duplicate')
2728 : code === 404
28 ? '公钥不存在或已被删除,请刷新列表。'
29 ? t('userSettings.ssh.deleted')
2930 : /invalid ssh public key/i.test(raw)
30 ? '公钥内容无法解析,请确认粘贴的是完整的一行公钥。'
31 ? t('userSettings.ssh.unparsable')
3132 : /unsupported ssh key type/i.test(raw)
32 ? '不支持的密钥类型:仅接受 RSA / Ed25519 / ECDSA P-256。'
33 ? t('userSettings.ssh.unsupported')
3334 : /name must be between/i.test(raw)
34 ? '名称需为 1–64 个字符。'
35 ? t('userSettings.ssh.nameLength')
3536 : raw
36 return new ApiFailure(mapped || '操作失败,请稍后重试。', code)
37 return new ApiFailure(mapped || t('ui.actionFailed'), code)
3738}
3839
3940function keyTypeLabel(type: string): string {
@@ -50,6 +51,7 @@
5051}
5152
5253export function SshKeysSection() {
54 const { t } = useT()
5355 const [tick, setTick] = useState(0)
5456 const keys = useAsync(api.listSshKeys, [tick], { cacheKey: settingsKey('ssh-keys') })
5557 const { busy, notice, error, run } = useSave()
@@ -63,8 +65,8 @@
6365 return (
6466 <section>
6567 <SectionTitle
66 title="SSH 公钥"
67 sub="用于 git over SSH 的克隆与推送。公钥指纹全站唯一,重复登记会被拒绝。"
68 title={t('userSettings.ssh.title')}
69 sub={t('userSettings.ssh.sub')}
6870 />
6971 <div className="mb-5 flex items-center gap-3">
7072 <Button
@@ -73,7 +75,7 @@
7375 setAdding(true)
7476 }}
7577 >
76 添加公钥
78 {t('userSettings.ssh.add')}
7779 </Button>
7880 {flash ? <Alert tone="success">{flash}</Alert> : null}
7981 {notice ? <Alert tone="success">{notice}</Alert> : null}
@@ -83,9 +85,9 @@
8385 <Busy />
8486 ) : (
8587 <>
86 <h3 className="mb-3 text-[14px] font-bold">已登记的公钥({total})</h3>
88 <h3 className="mb-3 text-[14px] font-bold">{t('userSettings.ssh.registered', { count: total })}</h3>
8789 <div className="space-y-2">
88 {items.length === 0 ? <p className="text-[13px] text-shell-ink-muted">还没有添加任何 SSH 公钥。</p> : null}
90 {items.length === 0 ? <p className="text-[13px] text-shell-ink-muted">{t('userSettings.ssh.none')}</p> : null}
8991 {items.map(key => (
9092 <div
9193 key={key.id}
@@ -97,7 +99,7 @@
9799 <span className="truncate">{key.fingerprint}</span>
98100 <CopyButton text={key.fingerprint} />
99101 </span>
100 <span className="text-[12px] text-shell-ink-muted">添加于 {fmtDate(key.created_at)}</span>
102 <span className="text-[12px] text-shell-ink-muted">{t('userSettings.ssh.addedAt', { when: fmtDate(key.created_at) })}</span>
101103 <Button
102104 variant="secondary"
103105 className="ml-auto h-8 text-[12.5px]"
@@ -117,11 +119,11 @@
117119 }
118120 setTick(t => t + 1)
119121 },
120 'SSH 公钥已删除。',
122 t('userSettings.ssh.addedNotice'),
121123 )
122124 }}
123125 >
124 {pendingDelete === key.id ? '确认删除' : '删除'}
126 {pendingDelete === key.id ? t('userSettings.ssh.confirmDelete') : t('common.action.delete')}
125127 </Button>
126128 </div>
127129 ))}
@@ -129,22 +131,22 @@
129131 </>
130132 )}
131133 <FormModal
132 title="添加 SSH 公钥"
133 description="仅接受 OpenSSH 单行公钥格式,支持 RSA / Ed25519 / ECDSA P-256。"
134 title={t('userSettings.ssh.modalTitle')}
135 description={t('userSettings.ssh.modalDescription')}
134136 open={adding}
135137 wide
136 confirmLabel="添加公钥"
138 confirmLabel={t('userSettings.ssh.add')}
137139 extra={
138140 <div className="settings-card p-3">
139141 <p className="mb-1 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
140 还没有密钥?先生成一个
142 {t('userSettings.ssh.generateHint')}
141143 </p>
142144 <p className="flex flex-wrap items-center gap-2 break-all font-mono text-[12px]">
143145 ssh-keygen -t ed25519 -C "you@example.com"
144146 <CopyButton text={'ssh-keygen -t ed25519 -C "you@example.com"'} />
145147 </p>
146148 <p className="mt-1.5 text-[11.5px] text-shell-ink-muted">
147 公钥在 <code className="font-mono">~/.ssh/id_ed25519.pub</code>(私钥不要告诉任何人)。
149 {t('userSettings.ssh.privateHint')}<code className="font-mono">~/.ssh/id_ed25519.pub</code>{t('userSettings.ssh.privateHintTail')}
148150 </p>
149151 </div>
150152 }
@@ -152,35 +154,35 @@
152154 {
153155 key: 'name',
154156 kind: 'text',
155 label: '名称',
156 placeholder: '如 work-laptop',
157 label: t('userSettings.ssh.nameLabel'),
158 placeholder: t('userSettings.ssh.nameExample'),
157159 maxLength: 64,
158 hint: '1–64 个字符,只用于你自己分辨是哪台设备。',
160 hint: t('userSettings.ssh.nameHint'),
159161 },
160162 {
161163 key: 'content',
162164 kind: 'textarea',
163 label: '公钥内容',
165 label: t('userSettings.ssh.contentLabel'),
164166 rows: 4,
165167 mono: true,
166168 placeholder: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… you@example.com',
167 hint: '粘贴整行公钥即可,换行与多余空白会自动合并。',
169 hint: t('userSettings.ssh.contentHint'),
168170 },
169171 ]}
170172 onClose={() => setAdding(false)}
171173 onSubmit={async values => {
172174 const name = String(values.name ?? '').trim()
173175 const content = normalizePublicKey(String(values.content ?? ''))
174 if (!content) throw new ApiFailure('请粘贴 SSH 公钥内容。', 400)
176 if (!content) throw new ApiFailure(t('userSettings.ssh.pasteRequired'), 400)
175177 if (!looksLikePublicKey(content))
176 throw new ApiFailure('无法识别的公钥格式,应形如 ssh-ed25519 AAAAC3Nza… 注释。', 400)
178 throw new ApiFailure(t('userSettings.ssh.badFormat'), 400)
177179 try {
178180 await api.addSshKey(name, content)
179181 } catch (err) {
180182 throw sshKeyError(err)
181183 }
182184 setTick(t => t + 1)
183 setFlash(`已添加「${name}」。`)
185 setFlash(t('userSettings.ssh.addedFlash', { name }))
184186 // Returning void closes the dialog; the list already refetched.
185187 }}
186188 />
Modified web/src/features/user-settings/ui/sections/status-section.tsx View web/src/features/user-settings/ui/sections/status-section.tsx
@@ -1,19 +1,23 @@
1import { useT } from '@/shared/i18n'
12import { settingsKey, useAsync } from '@/shared/lib'
23import * as api from '../../model/api'
34import { Busy, SectionTitle, fmtDate } from '@/shared/ui'
45
56export function StatusSection() {
7 const { t } = useT()
68 const suspensions = useAsync(api.listSuspensions, [], { cacheKey: settingsKey('suspensions') })
79 return (
810 <section>
9 <SectionTitle title="账户状态" sub="封禁与限制记录(只读)。" />
11 <SectionTitle title={t('settings.status.title')} sub={t('userSettings.status.sub')} />
1012 {suspensions.loading ? <Busy /> : (
1113 <div className="space-y-2">
12 {(suspensions.data ?? []).length === 0 ? <p className="text-[13px] text-shell-ink-muted">没有处罚记录,账户状态良好。</p> : null}
14 {(suspensions.data ?? []).length === 0 ? <p className="text-[13px] text-shell-ink-muted">{t('userSettings.status.clean')}</p> : null}
1315 {(suspensions.data ?? []).map(row => (
1416 <div key={row.id} className="settings-card flex flex-wrap items-center gap-3 px-4 py-3">
1517 <span className="text-[13.5px] font-semibold">{row.suspension_type}</span>
16 <span className="text-[12px] text-shell-ink-muted">{fmtDate(row.starts_at)} → {row.ends_at ? fmtDate(row.ends_at) : '永久'}</span>
18 <span className="text-[12px] text-shell-ink-muted">
19 {fmtDate(row.starts_at)} → {row.ends_at ? fmtDate(row.ends_at) : t('userSettings.status.permanent')}
20 </span>
1721 </div>
1822 ))}
1923 </div>
Modified web/src/features/user-settings/ui/user-settings-modal.tsx View web/src/features/user-settings/ui/user-settings-modal.tsx
@@ -1,4 +1,5 @@
11import { useState } from 'react'
2import { useT } from '@/shared/i18n'
23import { SettingsModalShell, type SettingsSection } from '@/shared/ui'
34import type { Me } from '../model/user-settings'
45import { AccountSection } from './sections/account-section'
@@ -12,21 +13,6 @@
1213import { KeysSection } from './sections/access-keys-section'
1314import { SshKeysSection } from './sections/ssh-keys-section'
1415import { StatusSection } from './sections/status-section'
15
16
17const SECTIONS: SettingsSection[] = [
18 { id: 'account', label: '我的账户' },
19 { id: 'profile', label: '公开资料' },
20 { id: 'security', label: '密码与安全' },
21 { id: 'sessions', label: '设备与会话' },
22 { id: 'emails', label: '邮箱' },
23 { id: 'privacy', label: '隐私设置' },
24 { id: 'notifications', label: '通知' },
25 { id: 'appearance', label: '外观' },
26 { id: 'keys', label: '访问密钥' },
27 { id: 'sshKeys', label: 'SSH 公钥' },
28 { id: 'status', label: '账户状态' },
29]
3016
3117export interface UserSettingsModalProps {
3218 open: boolean
@@ -38,14 +24,30 @@
3824
3925/** Discord-style full-screen settings overlay covering every user API. */
4026export function UserSettingsModal({ open, onClose, me, onMeChanged }: UserSettingsModalProps) {
27 const { t } = useT()
4128 const [section, setSection] = useState('account')
4229
30 // Built per render so the section list follows the active language.
31 const sections: SettingsSection[] = [
32 { id: 'account', label: t('settings.section.account') },
33 { id: 'profile', label: t('settings.section.profile') },
34 { id: 'security', label: t('settings.section.security') },
35 { id: 'sessions', label: t('settings.section.sessions') },
36 { id: 'emails', label: t('settings.section.emails') },
37 { id: 'privacy', label: t('settings.section.privacy') },
38 { id: 'notifications', label: t('settings.section.notifications') },
39 { id: 'appearance', label: t('settings.section.appearance') },
40 { id: 'keys', label: t('settings.section.keys') },
41 { id: 'sshKeys', label: t('settings.section.sshKeys') },
42 { id: 'status', label: t('settings.section.status') },
43 ]
44
4345 if (!open) return null
4446
4547 return (
4648 <SettingsModalShell
47 title="用户设置"
48 sections={SECTIONS}
49 title={t('settings.title')}
50 sections={sections}
4951 active={section}
5052 onSelect={setSection}
5153 onClose={onClose}
Modified web/src/pages/email-verification/ui/email-verification-page.tsx View web/src/pages/email-verification/ui/email-verification-page.tsx
@@ -1,6 +1,7 @@
11import { useEffect, useState } from 'react'
22import { useNavigate, useSearchParams } from 'react-router-dom'
33import { ArrowRight, AtSign, Mail, RefreshCw } from 'lucide-react'
4import { useT } from '@/shared/i18n'
45import { Alert, Button, LabeledDivider } from '@/shared/ui'
56import {
67 AuthCard,
@@ -29,6 +30,7 @@
2930}
3031
3132export function EmailVerificationPage() {
33 const { t } = useT()
3234 const navigate = useNavigate()
3335 const [params] = useSearchParams()
3436 const email = params.get('email') ?? ''
@@ -81,48 +83,44 @@
8183 }
8284
8385 return (
84 <AuthShell current="VERIFY EMAIL" hint="CHECK SPAM FOLDER · SMTP DELIVERY ~5S">
86 <AuthShell
87 current={t('auth.verify.crumb').toUpperCase()}
88 hint={t('auth.verify.hint').toUpperCase()}
89 >
8590 <AuthCard
8691 badge={<IconBadge><Mail size={20} aria-hidden /></IconBadge>}
87 heading="Check your inbox"
88 subheading="We sent a verification link to"
92 heading={t('auth.verify.heading')}
93 subheading={t('auth.verify.subheading')}
8994 headerExtra={
9095 <div className="flex w-full flex-wrap items-center justify-center gap-2">
9196 <Chip className="font-mono text-[12.5px] text-foreground">
9297 <AtSign size={13} aria-hidden className="text-muted-foreground" />
93 {email || 'no address'}
98 {email || t('auth.reset.noAddress')}
9499 </Chip>
95 <Pill>purpose · {purpose}</Pill>
100 <Pill>{t('auth.verify.purpose', { purpose })}</Pill>
96101 </div>
97102 }
98103 >
99104 <div className="flex w-full flex-col gap-[15px]">
100105 <p className="text-center text-[12.5px] leading-[1.5] text-muted-foreground">
101 {code
102 ? 'Link opened on this device. Confirm below to continue.'
103 : 'The link expires in 24 hours. Open it on this device to continue.'}
106 {code ? t('auth.verify.linkOpened') : t('auth.verify.linkExpiry')}
104107 </p>
105 {verified && <Alert tone="success">Email verified — redirecting to sign in…</Alert>}
108 {verified && <Alert tone="success">{t('auth.verify.verified')}</Alert>}
106109 {resent && !verified && (
107 <Alert tone="success">A fresh link is on its way to {email}.</Alert>
110 <Alert tone="success">{t('auth.verify.resent', { email })}</Alert>
108111 )}
109112 {error && <Alert tone="error">{error}</Alert>}
110 {!code && !error && (
111 <Alert tone="info">
112 Waiting for the link — once you open it on this device, press the button below to
113 confirm.
114 </Alert>
115 )}
113 {!code && !error && <Alert tone="info">{t('auth.verify.waiting')}</Alert>}
116114 <Button
117115 size="lg"
118116 className="w-full"
119117 disabled={busy || verified || !code}
120118 onClick={onConfirm}
121119 >
122 {busy ? 'Working…' : 'I have verified my email'}
120 {busy ? t('common.action.working') : t('auth.verify.confirm')}
123121 <ArrowRight size={15} aria-hidden />
124122 </Button>
125 <LabeledDivider label="Did not arrive?" />
123 <LabeledDivider label={t('auth.verify.divider')} />
126124 <Button
127125 variant="secondary"
128126 className="h-11 w-full text-[13.5px] font-normal"
@@ -130,9 +128,15 @@
130128 onClick={onResend}
131129 >
132130 <RefreshCw size={15} aria-hidden />
133 {cooldown > 0 ? `Resend email (${formatCountdown(cooldown)})` : 'Resend email'}
131 {cooldown > 0
132 ? t('auth.verify.resendCountdown', { time: formatCountdown(cooldown) })
133 : t('auth.verify.resend')}
134134 </Button>
135 <AuthFooter question="Wrong address?" action="Change email" to="/auth/signup" />
135 <AuthFooter
136 question={t('auth.verify.wrongAddress')}
137 action={t('auth.verify.changeEmail')}
138 to="/auth/signup"
139 />
136140 </div>
137141 </AuthCard>
138142 </AuthShell>
Modified web/src/pages/forgot-password/ui/forgot-password-page.tsx View web/src/pages/forgot-password/ui/forgot-password-page.tsx
@@ -1,6 +1,7 @@
11import { useState, type FormEvent } from 'react'
22import { useNavigate } from 'react-router-dom'
33import { ArrowRight, AtSign, Info, KeyRound } from 'lucide-react'
4import { useT } from '@/shared/i18n'
45import { Alert, Button, TextField } from '@/shared/ui'
56import {
67 AuthCard,
@@ -13,6 +14,7 @@
1314} from '@/features/auth'
1415
1516export function ForgotPasswordPage() {
17 const { t } = useT()
1618 const navigate = useNavigate()
1719 const [email, setEmail] = useState('')
1820 const [busy, setBusy] = useState(false)
@@ -35,15 +37,18 @@
3537 }
3638
3739 return (
38 <AuthShell current="RECOVER ACCESS" hint="RECOVERY LINKS EXPIRE AFTER 15 MINUTES">
40 <AuthShell
41 current={t('auth.forgot.crumb').toUpperCase()}
42 hint={t('auth.forgot.hint').toUpperCase()}
43 >
3944 <AuthCard
4045 badge={<IconBadge><KeyRound size={20} aria-hidden /></IconBadge>}
41 heading="Forgot passphrase?"
42 subheading="Enter your workspace email and we will send a single-use recovery link. It expires after 15 minutes."
46 heading={t('auth.forgot.heading')}
47 subheading={t('auth.forgot.subheading')}
4348 >
4449 <form className="flex w-full flex-col gap-[15px]" onSubmit={onSubmit} noValidate>
4550 <TextField
46 label="Account email"
51 label={t('auth.forgot.email')}
4752 icon={AtSign}
4853 type="email"
4954 placeholder="ada.lock@kernel.dev"
@@ -54,14 +59,18 @@
5459 />
5560 <p className="flex items-start gap-2 text-[12.5px] leading-[1.5] text-muted-foreground">
5661 <Info size={14} aria-hidden className="mt-0.5 shrink-0" />
57 We always confirm delivery — existing accounts are never revealed.
62 {t('auth.forgot.privacyNote')}
5863 </p>
5964 {error && <Alert tone="error">{error}</Alert>}
6065 <Button type="submit" size="lg" className="w-full" disabled={busy}>
61 {busy ? 'Working…' : 'Send recovery link'}
66 {busy ? t('common.action.working') : t('auth.forgot.submit')}
6267 <ArrowRight size={15} aria-hidden />
6368 </Button>
64 <AuthFooter question="Remembered it?" action="Back to sign in" to="/auth/login" />
69 <AuthFooter
70 question={t('auth.forgot.remembered')}
71 action={t('auth.forgot.backToSignIn')}
72 to="/auth/login"
73 />
6574 </form>
6675 </AuthCard>
6776 </AuthShell>
Modified web/src/pages/login/ui/login-page.tsx View web/src/pages/login/ui/login-page.tsx
@@ -1,6 +1,7 @@
11import { useState, type FormEvent } from 'react'
22import { Link, useNavigate } from 'react-router-dom'
33import { ArrowRight, ArrowUpRight, AtSign, Lock, Mail, Shield } from 'lucide-react'
4import { useT } from '@/shared/i18n'
45import { Alert, Button, Checkbox, LabeledDivider, RevealToggle, TextField } from '@/shared/ui'
56import { setCacheOwner } from '@/shared/storage'
67import {
@@ -14,6 +15,7 @@
1415} from '@/features/auth'
1516
1617export function LoginPage() {
18 const { t } = useT()
1719 const navigate = useNavigate()
1820 const [identifier, setIdentifier] = useState('')
1921 const [password, setPassword] = useState('')
@@ -46,11 +48,11 @@
4648 }
4749
4850 return (
49 <AuthShell current="SIGN IN" hint="PROOF-OF-WORK SOLVED SILENTLY · 5 ATTEMPTS / 5 MIN">
50 <AuthCard badge={<KernelMark />} heading="Sign in to Kernel" subheading="Authenticate to reach the control plane and your running clusters.">
51 <AuthShell current={t('auth.login.crumb').toUpperCase()} hint={t('auth.login.hint').toUpperCase()}>
52 <AuthCard badge={<KernelMark />} heading={t('auth.login.heading')} subheading={t('auth.login.subheading')}>
5153 <form className="flex w-full flex-col gap-[15px]" onSubmit={onSubmit} noValidate>
5254 <TextField
53 label="Email or username"
55 label={t('auth.login.identifier')}
5456 icon={AtSign}
5557 placeholder="ada.lock"
5658 autoComplete="username"
@@ -59,7 +61,7 @@
5961 required
6062 />
6163 <TextField
62 label="Passphrase"
64 label={t('auth.login.passphrase')}
6365 icon={Lock}
6466 type={revealed ? 'text' : 'password'}
6567 placeholder="••••••••••••"
@@ -72,7 +74,7 @@
7274 to="/auth/forgot-password"
7375 className="inline-flex items-center gap-1 text-xs text-foreground underline-offset-4 hover:underline"
7476 >
75 Forgot?
77 {t('auth.login.forgot')}
7678 <ArrowUpRight size={11} aria-hidden />
7779 </Link>
7880 }
@@ -80,17 +82,21 @@
8082 />
8183 {/* Checkbox owns its click; a wrapper toggle would bubble-revert it. */}
8284 <div className="flex w-full items-center gap-[9px] text-[13px] text-foreground">
83 <Checkbox checked={remember} onChange={setRemember} aria-label="Keep me signed in" />
85 <Checkbox
86 checked={remember}
87 onChange={setRemember}
88 aria-label={t('auth.login.keepSignedInAria')}
89 />
8490 <span className="cursor-pointer" onClick={() => setRemember(r => !r)}>
85 Keep me signed in
91 {t('auth.login.keepSignedIn')}
8692 </span>
8793 </div>
8894 {error && <Alert tone="error">{error}</Alert>}
8995 <Button type="submit" size="lg" className="w-full" disabled={busy}>
90 {busy ? 'Working…' : 'Sign in'}
96 {busy ? t('common.action.working') : t('auth.login.submit')}
9197 <ArrowRight size={15} aria-hidden />
9298 </Button>
93 <LabeledDivider label="Losing access?" />
99 <LabeledDivider label={t('auth.login.losingAccess')} />
94100 <div className="flex w-full flex-col gap-3 sm:flex-row">
95101 <Button
96102 variant="secondary"
@@ -98,7 +104,7 @@
98104 onClick={() => navigate('/auth/2fa', { state: { challenge: null, recovery: true } })}
99105 >
100106 <Shield size={15} aria-hidden />
101 Recovery code
107 {t('auth.login.recoveryCode')}
102108 </Button>
103109 <Button
104110 variant="secondary"
@@ -108,10 +114,14 @@
108114 }
109115 >
110116 <Mail size={15} aria-hidden />
111 Resend verify email
117 {t('auth.login.resendVerify')}
112118 </Button>
113119 </div>
114 <AuthFooter question="No account yet?" action="Request access" to="/auth/signup" />
120 <AuthFooter
121 question={t('auth.login.noAccount')}
122 action={t('auth.login.requestAccess')}
123 to="/auth/signup"
124 />
115125 </form>
116126 </AuthCard>
117127 </AuthShell>
Modified web/src/pages/not-found/ui/not-found-page.tsx View web/src/pages/not-found/ui/not-found-page.tsx
@@ -1,18 +1,28 @@
1import { useEffect } from 'react'
12import { Button } from '@/shared/ui'
23import { Link } from 'react-router-dom'
4import { useT } from '@/shared/i18n'
35
46/**
57 * Fallback screen for unmatched routes.
68 * It is a route-level composition, so it is a page slice rather than app UI.
79 */
810export function NotFoundPage() {
11 const { t } = useT()
12
13 // A 404 can be the first paint of a fresh tab, so give it a real title.
14 useEffect(() => {
15 document.title = `${t('error.notFound.title')} · ${t('common.app.name')}`
16 }, [t])
17
918 return (
1019 <main className="grid min-h-screen place-content-center gap-4 p-8 text-center">
1120 <p className="text-7xl font-semibold tracking-tight text-slate-300">404</p>
12 <h1 className="text-xl">页面不存在</h1>
21 <h1 className="text-xl">{t('error.notFound.title')}</h1>
22 <p className="max-w-sm text-sm text-slate-500">{t('error.notFound.body')}</p>
1323 <div className="justify-self-center">
1424 <Link to="/app/@me">
15 <Button>返回首页</Button>
25 <Button>{t('error.notFound.back')}</Button>
1626 </Link>
1727 </div>
1828 </main>
Modified web/src/pages/reset-password/ui/reset-password-page.tsx View web/src/pages/reset-password/ui/reset-password-page.tsx
@@ -1,10 +1,12 @@
11import { useState, type FormEvent } from 'react'
22import { Link, useNavigate, useSearchParams } from 'react-router-dom'
33import { ArrowRight, AtSign, Lock, ShieldCheck } from 'lucide-react'
4import { useT } from '@/shared/i18n'
45import { Alert, Button, RevealToggle, TextField } from '@/shared/ui'
56import { AuthCard, AuthShell, Chip, IconBadge, resetPassword, toUserError } from '@/features/auth'
67
7function strengthLabel(pw: string): { text: string; className: string } | null {
8/** Password strength buckets; the labels themselves are localized at render. */
9function strengthScore(pw: string): number | null {
810 if (!pw) return null
911 let score = 0
1012 if (pw.length >= 10) score++
@@ -12,12 +14,11 @@
1214 if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++
1315 if (/\d/.test(pw)) score++
1416 if (/[^A-Za-z0-9]/.test(pw)) score++
15 if (score >= 5) return { text: 'STRONG', className: 'text-success-foreground' }
16 if (score >= 3) return { text: 'FAIR', className: 'text-warning-foreground' }
17 return { text: 'WEAK', className: 'text-destructive' }
17 return score
1818}
1919
2020export function ResetPasswordPage() {
21 const { t } = useT()
2122 const navigate = useNavigate()
2223 const [params] = useSearchParams()
2324 const email = params.get('email') ?? ''
@@ -33,13 +34,21 @@
3334 const [done, setDone] = useState(false)
3435 const [error, setError] = useState<string | null>(null)
3536
36 const strength = strengthLabel(password)
37 const score = strengthScore(password)
38 const strength =
39 score === null
40 ? null
41 : score >= 5
42 ? { text: t('auth.reset.strength.strong'), className: 'text-success-foreground' }
43 : score >= 3
44 ? { text: t('auth.reset.strength.fair'), className: 'text-warning-foreground' }
45 : { text: t('auth.reset.strength.weak'), className: 'text-destructive' }
3746
3847 async function onSubmit(event: FormEvent) {
3948 event.preventDefault()
4049 if (busy || !tokenValid) return
4150 if (password !== confirm) {
42 setError('The two passwords do not match.')
51 setError(t('auth.reset.error.mismatch'))
4352 return
4453 }
4554 setBusy(true)
@@ -56,16 +65,19 @@
5665 }
5766
5867 return (
59 <AuthShell current="NEW PASSWORD" hint="ALL OTHER SESSIONS WILL BE SIGNED OUT">
68 <AuthShell
69 current={t('auth.reset.crumb').toUpperCase()}
70 hint={t('auth.reset.hint').toUpperCase()}
71 >
6072 <AuthCard
6173 badge={<IconBadge><ShieldCheck size={20} aria-hidden /></IconBadge>}
62 heading="Set a new password"
63 subheading="Recovery token verified. Your new password is checked against the server-side policy."
74 heading={t('auth.reset.heading')}
75 subheading={t('auth.reset.subheading')}
6476 headerExtra={
6577 <div className="flex w-full items-center justify-center gap-2">
6678 <Chip className="font-mono text-[12.5px] text-foreground">
6779 <AtSign size={13} aria-hidden className="text-muted-foreground" />
68 {email || 'no address'}
80 {email || t('auth.reset.noAddress')}
6981 </Chip>
7082 <span className="font-mono text-[12.5px] text-border" aria-hidden>
7183 |
@@ -73,14 +85,14 @@
7385 <span
7486 className={`font-mono text-[10px] font-bold ${tokenValid ? 'text-success-foreground' : 'text-warning-foreground'}`}
7587 >
76 {tokenValid ? 'TOKEN VALID' : 'NO TOKEN'}
88 {(tokenValid ? t('auth.reset.tokenValid') : t('auth.reset.noToken')).toUpperCase()}
7789 </span>
7890 </div>
7991 }
8092 >
8193 <form className="flex w-full flex-col gap-[15px]" onSubmit={onSubmit} noValidate>
8294 <TextField
83 label="New password"
95 label={t('auth.reset.newPassword')}
8496 icon={Lock}
8597 type={revealed ? 'text' : 'password'}
8698 placeholder="••••••••••••••••"
@@ -91,7 +103,7 @@
91103 aside={
92104 strength && (
93105 <span className={`font-mono text-[10px] font-bold ${strength.className}`}>
94 {strength.text}
106 {strength.text.toUpperCase()}
95107 </span>
96108 )
97109 }
@@ -100,7 +112,7 @@
100112 }
101113 />
102114 <TextField
103 label="Confirm new password"
115 label={t('auth.reset.confirmPassword')}
104116 icon={Lock}
105117 type={confirmRevealed ? 'text' : 'password'}
106118 placeholder="••••••••••••••••"
@@ -117,17 +129,17 @@
117129 />
118130 {!tokenValid && (
119131 <Alert tone="info">
120 Open the recovery link from your email on this device, or{' '}
132 {t('auth.reset.noTokenNote')}{' '}
121133 <Link to="/auth/forgot-password" className="font-bold underline">
122 request a new link
134 {t('auth.reset.requestNewLink')}
123135 </Link>
124136 .
125137 </Alert>
126138 )}
127 {done && <Alert tone="success">Passphrase updated — redirecting to sign in…</Alert>}
139 {done && <Alert tone="success">{t('auth.reset.done')}</Alert>}
128140 {error && <Alert tone="error">{error}</Alert>}
129141 <Button type="submit" size="lg" className="w-full" disabled={busy || !tokenValid || done}>
130 {busy ? 'Working…' : 'Update passphrase'}
142 {busy ? t('common.action.working') : t('auth.reset.submit')}
131143 <ArrowRight size={15} aria-hidden />
132144 </Button>
133145 </form>
Modified web/src/pages/sign-up/ui/sign-up-page.tsx View web/src/pages/sign-up/ui/sign-up-page.tsx
@@ -1,6 +1,7 @@
11import { useState, type FormEvent } from 'react'
22import { useNavigate } from 'react-router-dom'
33import { ArrowRight, AtSign, ChevronDown, Lock, User } from 'lucide-react'
4import { useT } from '@/shared/i18n'
45import { Alert, Button, Checkbox, RevealToggle, TextField } from '@/shared/ui'
56import { getMinAge } from '@/shared/lib'
67import { AuthCard, AuthFooter, AuthShell, KernelMark, getPowAnswer, register, toUserError } from '@/features/auth'
@@ -69,6 +70,7 @@
6970}
7071
7172export function SignUpPage() {
73 const { t } = useT()
7274 const navigate = useNavigate()
7375 const [username, setUsername] = useState('')
7476 const [email, setEmail] = useState('')
@@ -85,17 +87,17 @@
8587 event.preventDefault()
8688 if (busy) return
8789 if (!terms) {
88 setError('Please accept the Terms and Privacy Policy to continue.')
90 setError(t('auth.signup.error.terms'))
8991 return
9092 }
9193 const dobStarted = Boolean(day || month || year)
9294 const dobComplete = Boolean(day && month && year)
9395 if (dobStarted && !dobComplete) {
94 setError('Please complete your date of birth.')
96 setError(t('auth.signup.error.dobIncomplete'))
9597 return
9698 }
9799 if (dobComplete && ageFrom(year, month, day) < MIN_AGE) {
98 setError(`You must be at least ${MIN_AGE} years old to sign up.`)
100 setError(t('auth.signup.error.age', { age: MIN_AGE }))
99101 return
100102 }
101103 setBusy(true)
@@ -130,17 +132,17 @@
130132
131133 return (
132134 <AuthShell
133 current="CREATE ACCOUNT"
134 hint="PASSWORD POLICY & AGE GATE ENFORCED SERVER-SIDE"
135 current={t('auth.signup.crumb').toUpperCase()}
136 hint={t('auth.signup.hint').toUpperCase()}
135137 >
136138 <AuthCard
137139 badge={<KernelMark />}
138 heading="Create your account"
139 subheading="Provision team access to the Kernel control plane and running clusters."
140 heading={t('auth.signup.heading')}
141 subheading={t('auth.signup.subheading')}
140142 >
141143 <form className="flex w-full flex-col gap-[15px]" onSubmit={onSubmit} noValidate>
142144 <TextField
143 label="Username (optional)"
145 label={t('auth.signup.username')}
144146 icon={User}
145147 placeholder="adalock"
146148 autoComplete="username"
@@ -148,7 +150,7 @@
148150 onChange={e => setUsername(e.target.value)}
149151 />
150152 <TextField
151 label="Email"
153 label={t('auth.signup.email')}
152154 icon={AtSign}
153155 type="email"
154156 placeholder="ada.lock@kernel.dev"
@@ -158,7 +160,7 @@
158160 required
159161 />
160162 <TextField
161 label="Password"
163 label={t('auth.signup.password')}
162164 icon={Lock}
163165 type={revealed ? 'text' : 'password'}
164166 placeholder="••••••••••••"
@@ -171,16 +173,21 @@
171173 <div className="flex w-full flex-col gap-[7px]">
172174 <div className="flex w-full items-center justify-between">
173175 <span className="font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-muted-foreground">
174 Date of birth (optional)
176 {t('auth.signup.dob')}
175177 </span>
176178 <span className="font-mono text-[9.5px] font-bold text-warning-foreground">
177 {MIN_AGE}+ REQUIRED
179 {t('auth.signup.dobRequired', { age: MIN_AGE }).toUpperCase()}
178180 </span>
179181 </div>
180182 <div className="flex w-full gap-2.5">
181 <DateSelect label="Day" value={day} onChange={setDay} options={dayOptions(year, month)} />
182183 <DateSelect
183 label="Month"
184 label={t('auth.signup.day')}
185 value={day}
186 onChange={setDay}
187 options={dayOptions(year, month)}
188 />
189 <DateSelect
190 label={t('auth.signup.month')}
184191 value={month}
185192 onChange={v => {
186193 setMonth(v)
@@ -189,7 +196,7 @@
189196 options={MONTHS}
190197 />
191198 <DateSelect
192 label="Year"
199 label={t('auth.signup.year')}
193200 value={year}
194201 onChange={v => {
195202 setYear(v)
@@ -203,32 +210,40 @@
203210 and a wrapper handler would fire second (bubbling) and revert the
204211 state, making the square look dead. */}
205212 <div className="flex w-full items-center gap-[9px] text-[13px] text-foreground">
206 <Checkbox checked={terms} onChange={setTerms} aria-label="Agree to terms" />
207 <span className="cursor-pointer" onClick={() => setTerms(t => !t)}>
208 I agree to the{' '}
213 <Checkbox
214 checked={terms}
215 onChange={setTerms}
216 aria-label={t('auth.signup.termsAria')}
217 />
218 <span className="cursor-pointer" onClick={() => setTerms(v => !v)}>
219 {t('auth.signup.termsPrefix')}{' '}
209220 <a
210221 href="#terms"
211222 className="font-bold underline-offset-4 hover:underline"
212223 onClick={e => e.stopPropagation()}
213224 >
214 Terms
225 {t('auth.signup.terms')}
215226 </a>{' '}
216 &amp;{' '}
227 {t('auth.signup.and')}{' '}
217228 <a
218229 href="#privacy"
219230 className="font-bold underline-offset-4 hover:underline"
220231 onClick={e => e.stopPropagation()}
221232 >
222 Privacy Policy
233 {t('auth.signup.privacy')}
223234 </a>
224235 </span>
225236 </div>
226237 {error && <Alert tone="error">{error}</Alert>}
227238 <Button type="submit" size="lg" className="w-full" disabled={busy}>
228 {busy ? 'Working…' : 'Create account'}
239 {busy ? t('common.action.working') : t('auth.signup.submit')}
229240 <ArrowRight size={15} aria-hidden />
230241 </Button>
231 <AuthFooter question="Already have an account?" action="Sign in" to="/auth/login" />
242 <AuthFooter
243 question={t('auth.signup.haveAccount')}
244 action={t('auth.signup.signIn')}
245 to="/auth/login"
246 />
232247 </form>
233248 </AuthCard>
234249 </AuthShell>
Modified web/src/pages/two-factor/ui/two-factor-page.tsx View web/src/pages/two-factor/ui/two-factor-page.tsx
@@ -1,6 +1,7 @@
11import { useEffect, useState } from 'react'
22import { Link, useLocation, useNavigate } from 'react-router-dom'
33import { ArrowRight, KeyRound, Smartphone } from 'lucide-react'
4import { useT } from '@/shared/i18n'
45import { Alert, Button, LabeledDivider, OtpInput, TextField } from '@/shared/ui'
56import { setCacheOwner } from '@/shared/storage'
67import { AuthCard, AuthShell, IconBadge, login2fa, toUserError } from '@/features/auth'
@@ -13,15 +14,8 @@
1314 return `${m}:${s}`
1415}
1516
16function TimerLabel({ remaining }: { remaining: number }) {
17 return (
18 <span className="font-mono text-[10px] font-bold text-warning-foreground">
19 EXPIRES IN {formatRemaining(remaining)}
20 </span>
21 )
22}
23
2417export function TwoFactorPage() {
18 const { t } = useT()
2519 const navigate = useNavigate()
2620 const location = useLocation()
2721 const state = (location.state ?? {}) as { challenge?: string | null; recovery?: boolean }
@@ -41,7 +35,7 @@
4135 async function onVerify() {
4236 if (busy) return
4337 if (!challenge) {
44 setError('No pending sign-in on this device. Start again from the sign-in screen.')
38 setError(t('auth.twofa.noChallengeError'))
4539 return
4640 }
4741 setBusy(true)
@@ -58,44 +52,53 @@
5852 }
5953 }
6054
55 const timerLabel = (
56 <span className="font-mono text-[10px] font-bold text-warning-foreground">
57 {t('auth.twofa.expiresIn', { time: formatRemaining(remaining) }).toUpperCase()}
58 </span>
59 )
60
6161 return (
62 <AuthShell current="TWO-FACTOR" hint="CHALLENGE EXPIRES IN 5 MIN · 5 ATTEMPTS MAX">
62 <AuthShell
63 current={t('auth.twofa.crumb').toUpperCase()}
64 hint={t('auth.twofa.hint').toUpperCase()}
65 >
6366 <AuthCard
6467 badge={<IconBadge><Smartphone size={20} aria-hidden /></IconBadge>}
65 heading="Two-factor code"
66 subheading="Enter the 6-digit code from your authenticator app for the signed-in account."
68 heading={t('auth.twofa.heading')}
69 subheading={t('auth.twofa.subheading')}
6770 >
6871 <form className="flex w-full flex-col gap-[15px]" onSubmit={e => { e.preventDefault(); void onVerify() }}>
6972 {recoveryMode ? (
7073 <TextField
71 label="Recovery code"
74 label={t('auth.twofa.recoveryCode')}
7275 icon={KeyRound}
7376 placeholder="xxxx-xxxx-xxxx"
7477 autoComplete="one-time-code"
7578 value={code}
7679 onChange={e => setCode(e.target.value)}
7780 required
78 aside={<TimerLabel remaining={remaining} />}
81 aside={timerLabel}
7982 />
8083 ) : (
8184 <>
8285 <div className="flex w-full items-center justify-between">
8386 <span className="font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-muted-foreground">
84 Authentication code
87 {t('auth.twofa.authCode')}
8588 </span>
86 <TimerLabel remaining={remaining} />
89 {timerLabel}
8790 </div>
88 <OtpInput value={code} onChange={setCode} aria-label="Authentication code" />
91 <OtpInput value={code} onChange={setCode} aria-label={t('auth.twofa.authCode')} />
8992 </>
9093 )}
9194 {error && <Alert tone="error">{error}</Alert>}
9295 {!challenge && !error && (
9396 <Alert tone="info">
94 No pending challenge —{' '}
97 {t('auth.twofa.noChallengeNote')}{' '}
9598 <Link to="/auth/login" className="font-bold underline">
96 sign in
99 {t('auth.twofa.signIn')}
97100 </Link>{' '}
98 first.
101 .
99102 </Alert>
100103 )}
101104 <Button
@@ -104,10 +107,10 @@
104107 className="w-full"
105108 disabled={busy || remaining === 0 || !code}
106109 >
107 {busy ? 'Working…' : 'Verify'}
110 {busy ? t('common.action.working') : t('auth.twofa.submit')}
108111 <ArrowRight size={15} aria-hidden />
109112 </Button>
110 <LabeledDivider label="Trouble?" />
113 <LabeledDivider label={t('auth.twofa.trouble')} />
111114 <Button
112115 variant="secondary"
113116 type="button"
@@ -119,7 +122,7 @@
119122 }}
120123 >
121124 <KeyRound size={15} aria-hidden />
122 {recoveryMode ? 'Use authenticator app' : 'Use a recovery code'}
125 {recoveryMode ? t('auth.twofa.useAuthenticator') : t('auth.twofa.useRecovery')}
123126 </Button>
124127 </form>
125128 </AuthCard>
Modified web/src/pages/workspace/model/workspace-data.ts View web/src/pages/workspace/model/workspace-data.ts
@@ -10,6 +10,7 @@
1010 spacesKey,
1111 useAsync,
1212} from '@/shared/lib'
13import { applyAccountLocale } from '@/shared/i18n'
1314import { setCacheOwner } from '@/shared/storage'
1415import { fetchSpaceDir } from '@/features/space-management'
1516import {
@@ -76,6 +77,16 @@
7677 if (meUserId) void setCacheOwner(meUserId)
7778 }, [meUserId])
7879
80 // The interface language follows the account: `users.language` (shared with
81 // the AI output preference) wins over `users.locale`, and either may fall
82 // through to the device choice when it names a locale this build does not
83 // ship. See `shared/i18n/locale` for the full precedence chain.
84 const meLanguage = me.data?.user.language
85 const meLocale = me.data?.user.locale
86 useEffect(() => {
87 applyAccountLocale(meLanguage, meLocale)
88 }, [meLanguage, meLocale])
89
7990 return {
8091 me,
8192 spaces,
Modified web/src/pages/workspace/ui/board/board-view.tsx View web/src/pages/workspace/ui/board/board-view.tsx
@@ -1,5 +1,6 @@
11import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22import { useNavigate } from 'react-router-dom'
3import { t as tr, useT } from '@/shared/i18n'
34import {
45 BoardActions,
56 BoardFilters,
@@ -34,10 +35,10 @@
3435
3536/** Distinguish "wrong link" from "no access" from "backend said no". */
3637function boardGoneMessage(code: number | undefined): string {
37 if (code === 404) return '问题集不存在或已被删除'
38 if (code === 403) return '你没有权限查看这个问题集'
39 if (code === 401) return '登录状态已失效,请重新登录'
40 return '问题集加载失败'
38 if (code === 404) return tr('shell.board.notFound')
39 if (code === 403) return tr('shell.board.forbidden')
40 if (code === 401) return tr('error.status.401')
41 return tr('shell.board.loadFailed')
4142}
4243
4344/**
@@ -47,6 +48,7 @@
4748 * dialogs (issue form / board management) as single instances.
4849 */
4950export function BoardView({ spaceName, boardId, boardHint, viewerId, shell, onCountsChanged }: BoardViewProps) {
51 const { t } = useT()
5052 const navigate = useNavigate()
5153 const loc = useBoardViewLocation()
5254 const { view } = loc
@@ -95,9 +97,9 @@
9597
9698 if (!boardId) {
9799 return (
98 <BoardLayout {...shell} title="问题集" variant="detail">
100 <BoardLayout {...shell} title={t('shell.board.title')} variant="detail">
99101 <div className="w-full rounded-lg border-2 border-dashed border-shell-hairline bg-shell-board px-4 py-16 text-center text-[13px] text-shell-ink-muted">
100 从左侧选择一个问题集。
102 {t('shell.board.pick')}
101103 </div>
102104 </BoardLayout>
103105 )
@@ -107,24 +109,24 @@
107109 // below it can work, so replace the whole content column.
108110 if (boardFailure && !board) {
109111 return (
110 <BoardLayout {...shell} title="问题集" variant="detail">
112 <BoardLayout {...shell} title={t('shell.board.title')} variant="detail">
111113 <div className="flex w-full flex-col items-center gap-3 rounded-lg border-2 border-shell-hairline bg-shell-surface px-4 py-16 text-center">
112114 <p className="text-[15px] font-semibold">{boardGoneMessage(boardFailure.code)}</p>
113 <p className="max-w-sm text-[12.5px] text-shell-ink-muted">{boardFailure.message ?? '该问题集可能已被删除,或你的账号没有读取权限。'}</p>
115 <p className="max-w-sm text-[12.5px] text-shell-ink-muted">{boardFailure.message ?? t('shell.board.deletedHint')}</p>
114116 <div className="flex items-center gap-2">
115117 <button
116118 type="button"
117119 onClick={() => setReload(key => key + 1)}
118120 className="rounded-md border-2 border-shell-hairline bg-shell-field px-3.5 py-1.5 text-[13px] transition-colors hover:border-shell-ink"
119121 >
120 重试
122 {t('common.action.retry')}
121123 </button>
122124 <button
123125 type="button"
124126 onClick={() => navigate(`/app/${encodeURIComponent(spaceName)}`)}
125127 className="rounded-md bg-shell-action px-3.5 py-1.5 text-[13px] font-semibold text-white transition-[filter] hover:brightness-95"
126128 >
127 返回社区
129 {t('shell.board.return')}
128130 </button>
129131 </div>
130132 </div>
@@ -136,7 +138,7 @@
136138 <>
137139 <BoardLayout
138140 {...shell}
139 title={board?.name ?? '问题集'}
141 title={board?.name ?? t('shell.board.title')}
140142 topic={board?.description ?? undefined}
141143 variant={view.issue ? 'detail' : 'columns'}
142144 toolbar={
Modified web/src/pages/workspace/ui/layout/board-layout.tsx View web/src/pages/workspace/ui/layout/board-layout.tsx
@@ -1,5 +1,6 @@
11import type { ReactNode } from 'react'
22import { Plus, SquareKanban } from 'lucide-react'
3import { useT } from '@/shared/i18n'
34import { AppShell, ShellTopBar, type AppShellProps } from '@/shared/ui'
45
56/**
@@ -27,7 +28,7 @@
2728}
2829
2930export function BoardLayout({
30 title = '研发看板',
31 title,
3132 topic,
3233 actions,
3334 toolbar,
@@ -36,6 +37,7 @@
3637 variant = 'columns',
3738 ...shell
3839}: BoardLayoutProps) {
40 const { t } = useT()
3941 return (
4042 <AppShell {...shell}>
4143 <ShellTopBar
@@ -66,7 +68,7 @@
6668 </div>
6769 <span className="flex h-[30px] items-center gap-1 rounded-md bg-shell-action px-3 text-[13px] font-semibold text-white">
6870 <Plus className="h-3.5 w-3.5" />
69 新建工单
71 {t('shell.board.newIssue')}
7072 </span>
7173 </>
7274 )}
@@ -107,6 +109,7 @@
107109}
108110
109111function ColumnCards({ n, tint }: { n: number; tint?: string }) {
112 const { t } = useT()
110113 return (
111114 <>
112115 {Array.from({ length: n }, (_, i) => (
@@ -115,7 +118,7 @@
115118 className="block h-[18px] w-[52px] rounded-full text-[11px] font-semibold leading-[18px] text-center"
116119 style={tint ? { backgroundColor: tint, color: '#fff' } : undefined}
117120 >
118 {tint ? '标签' : null}
121 {tint ? t('issue.board.filter.label') : null}
119122 </span>
120123 <div className="h-4 rounded-full bg-shell-active" />
121124 <div className="h-4 w-2/3 rounded-full bg-shell-active" />
@@ -130,18 +133,19 @@
130133}
131134
132135function BoardColumnsPlaceholder() {
136 const { t } = useT()
133137 return (
134138 <>
135 <BoardColumn title="待处理" count="4" dot="#8b8f99">
139 <BoardColumn title={t('shell.board.colPending')} count="4" dot="#8b8f99">
136140 <ColumnCards n={2} />
137141 </BoardColumn>
138 <BoardColumn title="进行中" count="2" dot="#66717d">
142 <BoardColumn title={t('shell.board.colInProgress')} count="2" dot="#66717d">
139143 <ColumnCards n={2} tint="#eb459e" />
140144 </BoardColumn>
141 <BoardColumn title="待验收" count="2" dot="#f0b232">
145 <BoardColumn title={t('shell.board.colReview')} count="2" dot="#f0b232">
142146 <ColumnCards n={2} />
143147 </BoardColumn>
144 <BoardColumn title="已完成" count="12" dot="#3ba55d">
148 <BoardColumn title={t('shell.board.colDone')} count="12" dot="#3ba55d">
145149 <ColumnCards n={1} />
146150 </BoardColumn>
147151 </>
Modified web/src/pages/workspace/ui/layout/repo-layout.tsx View web/src/pages/workspace/ui/layout/repo-layout.tsx
@@ -1,5 +1,6 @@
11import { useState, type ReactNode } from 'react'
22import { ChevronDown, File, Folder, FolderGit2, GitBranch, Info, Tag } from 'lucide-react'
3import { useT, type MessageKey } from '@/shared/i18n'
34import { AppShell, ShellTopBar, type AppShellProps } from '@/shared/ui'
45import type { RepoTabId } from '../../model/route'
56
@@ -51,6 +52,7 @@
5152 aside,
5253 ...shell
5354}: RepoLayoutProps) {
55 const { t } = useT()
5456 const [overviewOpen, setOverviewOpen] = useState(false)
5557 return (
5658 <AppShell {...shell}>
@@ -78,7 +80,7 @@
7880 </div>
7981 <div className="repo-toolbar-tabs">{tabs ?? <DefaultTabs active={activeTab} onChange={onTabChange} />}</div>
8082 <div className={clone ? 'repo-toolbar-clone' : 'repo-toolbar-clone empty'}>
81 <button type="button" className="repo-overview-toggle workspace-icon-button" title="仓库概览" aria-label="仓库概览" aria-expanded={overviewOpen} onClick={() => setOverviewOpen(value => !value)}><Info size={18} /></button>
83 <button type="button" className="repo-overview-toggle workspace-icon-button" title={t('shell.repo.overview')} aria-label={t('shell.repo.overview')} aria-expanded={overviewOpen} onClick={() => setOverviewOpen(value => !value)}><Info size={18} /></button>
8284 {clone}
8385 </div>
8486 </div>
@@ -92,15 +94,15 @@
9294 )
9395}
9496
95const tabLabels: Array<[NonNullable<RepoLayoutProps['activeTab']>, string]> = [
96 ['code', '代码'],
97 ['commits', '提交'],
98 ['pulls', '合并请求'],
99 ['branches', '分支'],
100 ['pipelines', '流水线'],
101 ['releases', '发布'],
102 ['ai', 'AI 总结'],
103 ['settings', '设置'],
97const tabLabels: Array<[NonNullable<RepoLayoutProps['activeTab']>, MessageKey]> = [
98 ['code', 'shell.repo.tabs.code'],
99 ['commits', 'shell.repo.tabs.commits'],
100 ['pulls', 'shell.repo.tabs.pulls'],
101 ['branches', 'shell.repo.tabs.branches'],
102 ['pipelines', 'shell.repo.tabs.pipelines'],
103 ['releases', 'shell.repo.tabs.releases'],
104 ['ai', 'shell.repo.tabs.ai'],
105 ['settings', 'shell.repo.tabs.settings'],
104106]
105107
106108/** The PR detail route (`?tab=pull`) belongs to the 合并请求 tab. */
@@ -113,9 +115,10 @@
113115 active: NonNullable<RepoLayoutProps['activeTab']>
114116 onChange?: (tab: RepoTabId) => void
115117}) {
118 const { t } = useT()
116119 return (
117120 <nav className="flex h-9 items-center gap-1 rounded-[11px] border border-shell-hairline bg-shell-field p-[3px]">
118 {tabLabels.map(([id, label]) => {
121 {tabLabels.map(([id, key]) => {
119122 const isActive = tabGroup(active) === id
120123 const cls = `flex h-full shrink-0 items-center rounded-lg px-3 text-[13.5px] font-medium transition-colors ${
121124 isActive
@@ -130,11 +133,11 @@
130133 onClick={() => onChange(id)}
131134 className={cls}
132135 >
133 {label}
136 {t(key)}
134137 </button>
135138 ) : (
136139 <span key={id} className={cls}>
137 {label}
140 {t(key)}
138141 </span>
139142 )
140143 })}
Modified web/src/pages/workspace/ui/repo/ref-picker.tsx View web/src/pages/workspace/ui/repo/ref-picker.tsx
@@ -1,4 +1,5 @@
11import { useEffect, useId, useRef, useState } from 'react'
2import { useT } from '@/shared/i18n'
23import { Check, ChevronDown, GitBranch, GitCommitHorizontal, Search, Tag } from 'lucide-react'
34import { repoKey, shortSha, useAsync } from '@/shared/lib'
45import { listBranches, listTags } from '@/entities/repo'
@@ -28,6 +29,7 @@
2829 commitsCount,
2930 onShowCommits,
3031}: RefPickerProps) {
32 const { t, tc } = useT()
3133 const branches = useAsync(() => listBranches(space, repo).catch(() => null), [space, repo], {
3234 cacheKey: repoKey(space, repo, 'refs:branches:100'),
3335 })
@@ -73,7 +75,7 @@
7375 const isTag = tagNames.includes(rev)
7476 // A raw sha is a valid revision too — label it so the chip never claims a
7577 // commit is a tag.
76 const revLabel = !rev ? '选择分支' : isBranch || isTag || branches.loading ? rev : `${shortSha(rev, 8)} (提交)`
78 const revLabel = !rev ? t('repo.refs.selectBranch') : isBranch || isTag || branches.loading ? rev : t('repo.refs.commit', { sha: shortSha(rev, 8) })
7779 const items = (open === 'branch' ? branchNames : tagNames).filter(name =>
7880 name.toLowerCase().includes(query.toLowerCase()),
7981 )
@@ -90,7 +92,7 @@
9092 type="button"
9193 aria-haspopup="dialog"
9294 aria-controls={open === 'branch' ? popupId : undefined}
93 aria-label={`当前修订 ${revLabel},选择分支`}
95 aria-label={t('repo.refs.current', { rev: revLabel })}
9496 aria-expanded={open === 'branch'}
9597 onClick={event => toggle('branch', event.currentTarget)}
9698 className="flex h-9 max-w-[220px] items-center gap-1.5 rounded-md bg-shell-field px-2.5 text-[13px] font-medium transition-colors hover:brightness-95"
@@ -109,7 +111,7 @@
109111 >
110112 <GitBranch className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
111113 <span className="whitespace-nowrap">
112 {branchTotal} 分支
114 {tc('repo.refs.branches', branchTotal)}
113115 </span>
114116 <ChevronDown className="h-3 w-3 shrink-0 text-shell-ink-muted" aria-hidden />
115117 </button>
@@ -123,7 +125,7 @@
123125 >
124126 <Tag className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
125127 <span className="whitespace-nowrap">
126 {tagTotal} 标签
128 {tc('repo.refs.tags', tagTotal)}
127129 </span>
128130 <ChevronDown className="h-3 w-3 shrink-0 text-shell-ink-muted" aria-hidden />
129131 </button>
@@ -133,16 +135,16 @@
133135 className="flex h-9 shrink-0 items-center gap-1.5 rounded-md bg-shell-field px-2.5 text-[13px] font-medium transition-colors hover:brightness-95"
134136 >
135137 <GitCommitHorizontal className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
136 <span className="whitespace-nowrap">{commitsCount} 提交</span>
138 <span className="whitespace-nowrap">{tc('repo.refs.commits', commitsCount)}</span>
137139 </button>
138140 {open ? (
139 <div id={popupId} role="dialog" aria-label={open === 'branch' ? '切换分支' : '切换标签'} className="repo-ref-popover absolute left-0 top-full z-40 mt-2 w-72 rounded-lg border border-shell-hairline bg-shell-surface p-2 shadow-xl">
140 <p className="px-2.5 pb-2 pt-1 text-[13px] font-semibold">{open === 'branch' ? '切换分支' : '切换标签'}</p>
141 <div id={popupId} role="dialog" aria-label={open === 'branch' ? t('repo.branch.switch') : t('repo.tag.switch')} className="repo-ref-popover absolute left-0 top-full z-40 mt-2 w-72 rounded-lg border border-shell-hairline bg-shell-surface p-2 shadow-xl">
142 <p className="px-2.5 pb-2 pt-1 text-[13px] font-semibold">{open === 'branch' ? t('repo.branch.switch') : t('repo.tag.switch')}</p>
141143 <div className="repo-ref-search flex items-center gap-2 rounded-lg border border-shell-hairline bg-shell-field px-2.5 py-1.5">
142144 <Search className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
143145 <input
144146 autoFocus
145 aria-label={open === 'branch' ? '搜索分支' : '搜索标签'}
147 aria-label={open === 'branch' ? t('repo.refs.searchBranches') : t('repo.refs.searchTags')}
146148 value={query}
147149 onChange={e => setQuery(e.target.value)}
148150 onKeyDown={event => {
@@ -151,14 +153,14 @@
151153 rootRef.current?.querySelector<HTMLButtonElement>('[role="option"]')?.focus()
152154 }
153155 }}
154 placeholder={open === 'branch' ? '筛选分支…' : '筛选标签…'}
156 placeholder={open === 'branch' ? t('repo.branches.filterPlaceholder') : t('repo.tags.filterPlaceholder')}
155157 className="w-full min-w-0 bg-transparent text-[13px] leading-6 outline-none"
156158 />
157159 </div>
158 <div role="listbox" aria-label={open === 'branch' ? '分支' : '标签'} className="max-h-60 overflow-y-auto pt-1">
160 <div role="listbox" aria-label={open === 'branch' ? t('repo.branches.title') : t('repo.tags.title')} className="max-h-60 overflow-y-auto pt-1">
159161 {items.length === 0 ? (
160162 <p className="px-2.5 py-3 text-center text-[12.5px] text-shell-ink-muted">
161 {query ? '没有匹配的结果,试试其他关键词。' : open === 'branch' ? '还没有分支' : '还没有标签'}
163 {query ? t('repo.refs.noMatch') : open === 'branch' ? t('repo.refs.noBranches') : t('repo.refs.noTags')}
162164 </p>
163165 ) : null}
164166 {items.map(name => (
Modified web/src/pages/workspace/ui/repo/repo-aside.tsx View web/src/pages/workspace/ui/repo/repo-aside.tsx
@@ -1,5 +1,6 @@
11import { useState } from 'react'
22import { ExternalLink, GitBranch, GitCommitHorizontal, Star, Tag } from 'lucide-react'
3import { useT, type MessageKey } from '@/shared/i18n'
34import { formatBytes } from '@/shared/lib'
45import {
56 htmlFragmentUrl,
@@ -19,7 +20,17 @@
1920}
2021
2122/** 280px info column: about block, star action and language breakdown. */
23/** Headline counts shown under the description; order matches the design. */
24function repoStats(repo: RepoAsideProps['repo']): ReadonlyArray<{ key: MessageKey; count: number; Icon: typeof GitBranch }> {
25 return [
26 { key: 'shell.repo.statBranches', count: repo.branches_count, Icon: GitBranch },
27 { key: 'shell.repo.statTags', count: repo.tags_count, Icon: Tag },
28 { key: 'shell.repo.statCommits', count: repo.commits_count, Icon: GitCommitHorizontal },
29 ]
30}
31
2232export function RepoAside({ space, repoName, repo, mode, rev, onChanged }: RepoAsideProps) {
33 const { t } = useT()
2334 const [starBusy, setStarBusy] = useState(false)
2435 const [starError, setStarError] = useState<string | null>(null)
2536 const starred = repo.me?.starred ?? false
@@ -41,7 +52,7 @@
4152 else await starRepo(space, repoName)
4253 onChanged()
4354 } catch {
44 setStarError('暂时无法更新收藏,请稍后重试。')
55 setStarError(t('shell.repo.starFailed'))
4556 } finally {
4657 setStarBusy(false)
4758 }
@@ -51,17 +62,17 @@
5162 <div className="repo-overview space-y-4">
5263 <section className="repo-about-card rounded-xl border border-shell-hairline bg-shell-surface p-5">
5364 <h3 className="mb-3 text-[14px] font-semibold">
54 仓库概览
65 {t('shell.repo.overview')}
5566 </h3>
56 <p className="mb-4 break-words text-[13px] leading-6 text-shell-ink-muted">{repo.description || '这个仓库的故事,正在从代码开始。'}</p>
67 <p className="mb-4 break-words text-[13px] leading-6 text-shell-ink-muted">{repo.description || t('shell.repo.descriptionFallback')}</p>
5768 <dl className="space-y-3 text-[12px] text-shell-ink-muted">
5869 {website ? (
5970 <div>
60 <dt className="sr-only">网站</dt>
71 <dt className="sr-only">{t('shell.repo.website')}</dt>
6172 <dd>
6273 <button
6374 type="button"
64 title={`在新窗口打开 ${website}`}
75 title={t('shell.repo.openWebsite', { url: website })}
6576 onClick={() => window.open(website, '_blank', 'noopener,noreferrer')}
6677 className="flex items-start gap-1.5 break-all text-shell-ink-muted underline decoration-shell-hairline underline-offset-4 hover:text-shell-ink hover:decoration-shell-ink-muted"
6778 >
@@ -71,16 +82,16 @@
7182 </dd>
7283 </div>
7384 ) : null}
74 <div className="flex justify-between gap-3"><dt>仓库大小</dt><dd className="font-medium tabular-nums text-shell-ink">{formatBytes(repo.git_size)}</dd></div>
85 <div className="flex justify-between gap-3"><dt>{t('shell.repo.size')}</dt><dd className="font-medium tabular-nums text-shell-ink">{formatBytes(repo.git_size)}</dd></div>
7586 {repo.license_name ? (
76 <div className="flex justify-between gap-3"><dt>许可证</dt><dd className="text-right text-shell-ink">{repo.license_name}</dd></div>
87 <div className="flex justify-between gap-3"><dt>{t('shell.repo.license')}</dt><dd className="text-right text-shell-ink">{repo.license_name}</dd></div>
7788 ) : null}
7889 </dl>
7990 <div className="repo-stat-grid mt-5 grid grid-cols-3 gap-2 border-t border-shell-hairline pt-4">
80 {[{ label: '分支', count: repo.branches_count, Icon: GitBranch }, { label: '标签', count: repo.tags_count, Icon: Tag }, { label: '提交', count: repo.commits_count, Icon: GitCommitHorizontal }].map(({ label, count, Icon }) => (
81 <div key={label} className="min-w-0 text-center">
91 {repoStats(repo).map(({ key, count, Icon }) => (
92 <div key={key} className="min-w-0 text-center">
8293 <p className="text-[17px] font-semibold tabular-nums">{count}</p>
83 <p className="mt-1 flex items-center justify-center gap-1 text-[11px] text-shell-ink-muted"><Icon className="h-3 w-3" aria-hidden />{label}</p>
94 <p className="mt-1 flex items-center justify-center gap-1 text-[11px] text-shell-ink-muted"><Icon className="h-3 w-3" aria-hidden />{t(key)}</p>
8495 </div>
8596 ))}
8697 </div>
@@ -96,20 +107,20 @@
96107 }`}
97108 >
98109 <Star className="h-3.5 w-3.5" fill={starred ? 'currentColor' : 'none'} aria-hidden />
99 {starBusy ? '更新中…' : starred ? `已收藏 · ${repo.stars_count}` : `收藏仓库 · ${repo.stars_count}`}
110 {starBusy ? t('shell.repo.starUpdating') : starred ? t('shell.repo.starred', { count: repo.stars_count }) : t('shell.repo.star', { count: repo.stars_count })}
100111 </button>
101112 {starError ? <p className="mt-2 text-[12px] text-shell-ink-muted" role="alert">{starError}</p> : null}
102113 </section>
103114 {languagesHtml ? (
104115 <section className="rounded-xl border border-shell-hairline bg-shell-surface p-5">
105116 <h3 className="mb-3 text-[14px] font-semibold">
106 开发语言
117 {t('shell.repo.languages')}
107118 </h3>
108119 <div className="repo-embed" dangerouslySetInnerHTML={{ __html: languagesHtml }} />
109120 </section>
110121 ) : repo.primary_language ? (
111122 <section className="rounded-xl border border-shell-hairline bg-shell-surface p-5 text-[13px]">
112 主要语言:{repo.primary_language}
123 {t('shell.repo.primaryLanguage', { language: repo.primary_language ?? '' })}
113124 </section>
114125 ) : null}
115126 </div>
Modified web/src/pages/workspace/ui/repo/repo-view.tsx View web/src/pages/workspace/ui/repo/repo-view.tsx
@@ -8,6 +8,7 @@
88 htmlFragmentUrl,
99 type CloneTarget,
1010} from '@/entities/repo'
11import { useT } from '@/shared/i18n'
1112import { useAsync, repoKey } from '@/shared/lib'
1213import { useRepoViewLocation } from '../../model/repo-location'
1314import { BranchesTab } from '@/features/repo-branches'
@@ -45,6 +46,7 @@
4546 * (public repos); private repos fall back to the JSON API.
4647 */
4748export function RepoView({ spaceName, repoName, shell }: RepoViewProps) {
49 const { t } = useT()
4850 const [tick, setTick] = useState(0)
4951 const repoState = useAsync(() => fetchRepo(spaceName, repoName), [spaceName, repoName, tick], {
5052 cacheKey: repoKey(spaceName, repoName, 'detail'),
@@ -111,7 +113,7 @@
111113 return (
112114 <RepoLayout {...shell} title={`${spaceName} / ${repoName}`}>
113115 <div className="flex h-full items-center justify-center text-[13px] text-shell-ink-muted">
114 正在加载仓库…
116 {t('shell.repo.loading')}
115117 </div>
116118 </RepoLayout>
117119 )
@@ -123,14 +125,14 @@
123125 <RepoLayout {...shell} title={`${spaceName} / ${repoName}`}>
124126 <div className="flex h-full flex-col items-center justify-center gap-3 text-center">
125127 <p className="text-[15px] font-semibold">
126 {failure?.code === 404 ? '仓库不存在或你没有访问权限' : failure?.message || '仓库加载失败'}
128 {failure?.code === 404 ? t('shell.repo.notFound') : (failure?.message || t('shell.repo.loadFailed'))}
127129 </p>
128130 <button
129131 type="button"
130132 onClick={() => setTick(t => t + 1)}
131133 className="rounded-md border-2 border-shell-hairline bg-shell-field px-4 py-1.5 text-[13px] transition-colors hover:border-shell-ink"
132134 >
133 重试
135 {t('common.action.retry')}
134136 </button>
135137 </div>
136138 </RepoLayout>
@@ -171,7 +173,7 @@
171173 {repo.is_archived ? (
172174 <div className="mb-3 flex items-center gap-2 rounded-md border-2 border-[#f0b232]/50 bg-[#f0b232]/10 px-3 py-2 text-[12.5px] text-[#8a6100]">
173175 <ArchiveRestore className="h-4 w-4 shrink-0" aria-hidden />
174 该仓库已归档(只读)。可在「设置」中恢复。
176 {t('shell.repo.archived')}
175177 </div>
176178 ) : null}
177179 {tab === 'code' && (rev || repo.commits_count === 0) ? (
@@ -309,6 +311,7 @@
309311}
310312
311313function CloneBox({ targets, fill = false }: { targets: CloneTarget[]; fill?: boolean }) {
314 const { t } = useT()
312315 const [protocol, setProtocol] = useState<CloneTarget['protocol'] | null>(null)
313316 const [copied, setCopied] = useState(false)
314317 const [copyError, setCopyError] = useState(false)
@@ -326,9 +329,9 @@
326329 return (
327330 <span
328331 className="flex h-9 items-center rounded-md bg-shell-field px-3 text-[12px] text-shell-ink-muted"
329 title="后端未配置 [git].base_url / http_clone_base_url,无法生成克隆地址"
332 title={t('shell.repo.cloneUnconfigured')}
330333 >
331 克隆地址未配置
334 {t('shell.repo.cloneUnconfiguredShort')}
332335 </span>
333336 )
334337 }
@@ -344,7 +347,7 @@
344347 key={target.protocol}
345348 type="button"
346349 title={
347 target.protocol === 'ssh' ? 'SSH 克隆需先在 用户设置 → SSH 公钥 中登记公钥' : undefined
350 target.protocol === 'ssh' ? t('shell.repo.sshHint') : undefined
348351 }
349352 aria-pressed={target.protocol === active.protocol}
350353 onClick={() => { setProtocol(target.protocol); setCopied(false); setCopyError(false) }}
@@ -364,8 +367,8 @@
364367 </span>
365368 <button
366369 type="button"
367 aria-label={copied ? '克隆地址已复制' : '复制克隆地址'}
368 title={copyError ? '复制失败,请选择左侧地址后手动复制' : '复制克隆地址'}
370 aria-label={copied ? t('shell.repo.cloneCopied') : t('shell.repo.copyClone')}
371 title={copyError ? t('shell.repo.copyFailed') : t('shell.repo.copyClone')}
369372 className="flex h-[22px] shrink-0 items-center gap-1 rounded px-1.5 text-[12px] transition-colors hover:bg-shell-active hover:text-shell-ink"
370373 onClick={async () => {
371374 try {
@@ -378,9 +381,9 @@
378381 }}
379382 >
380383 {copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
381 {copied ? '已复制' : copyError ? '请手动复制' : '复制'}
384 {copied ? t('common.action.copied') : copyError ? t('shell.repo.copyManually') : t('common.action.copy')}
382385 </button>
383 <span className="sr-only" role="status">{copied ? '克隆地址已复制到剪贴板' : copyError ? '复制失败,请选择克隆地址手动复制' : ''}</span>
386 <span className="sr-only" role="status">{copied ? t('shell.repo.clipboardOk') : copyError ? t('shell.repo.clipboardFail') : ''}</span>
384387 </span>
385388 )
386389}
Modified web/src/pages/workspace/ui/pending-panel.tsx View web/src/pages/workspace/ui/pending-panel.tsx
@@ -1,12 +1,15 @@
11import { ArrowLeft, MessagesSquare } from 'lucide-react'
2import { useT } from '@/shared/i18n'
23
4/** Placeholder column shown when no channel / board / repo is selected. */
35export function PendingPanel({ label }: { label: string }) {
6 const { t } = useT()
47 return (
58 <div className="flex min-h-0 w-full flex-1 flex-col items-center justify-center bg-shell-field/50 px-8 py-12 text-center">
69 <div className="mb-6 flex h-20 w-20 items-center justify-center rounded-3xl border border-shell-hairline bg-shell-surface text-shell-ink-muted shadow-sm"><MessagesSquare size={34} strokeWidth={1.5} /></div>
7 <h1 className="text-2xl font-semibold tracking-tight">让协作从这里开始</h1>
8 <p className="mt-3 max-w-sm text-sm leading-7 text-shell-ink-muted">{label},继续讨论、分享想法,或查看团队的最新进展。</p>
9 <span className="mt-8 inline-flex items-center gap-2 rounded-full bg-shell-field px-4 py-2 text-xs text-shell-ink-muted"><ArrowLeft size={14} />在工作区导航中选择内容</span>
10 <h1 className="text-2xl font-semibold tracking-tight">{t('shell.pending.title')}</h1>
11 <p className="mt-3 max-w-sm text-sm leading-7 text-shell-ink-muted">{t('shell.pending.body', { label })}</p>
12 <span className="mt-8 inline-flex items-center gap-2 rounded-full bg-shell-field px-4 py-2 text-xs text-shell-ink-muted"><ArrowLeft size={14} />{t('shell.pending.hint')}</span>
1013 </div>
1114 )
1215}
Modified web/src/pages/workspace/ui/sidebar-nav.tsx View web/src/pages/workspace/ui/sidebar-nav.tsx
@@ -13,6 +13,7 @@
1313 Volume2,
1414} from 'lucide-react'
1515import { moveRoom, moveSpaceDir } from '@/features/space-management'
16import { useT } from '@/shared/i18n'
1617import { ApiFailure } from '@/shared/lib'
1718import { getVoiceSpaceStates, isVoiceRoom } from '@/entities/chat'
1819import { betweenPositions, STRIDE } from '../model/nav-order'
@@ -89,6 +90,7 @@
8990 canOrganize = false,
9091 onChanged,
9192}: SidebarNavProps) {
93 const { t } = useT()
9294 const spacePath = encodeURIComponent(spaceName)
9395 const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set())
9496 const [dragKey, setDragKey] = useState<string | null>(null)
@@ -323,7 +325,7 @@
323325 // An apihost built without room reparenting accepts the PATCH and ignores
324326 // the key, so compare the returned row instead of trusting the 200.
325327 if ((row.parent_id ?? null) !== parentId) {
326 throw new ApiFailure('后端未接受频道的文件夹归属(需更新并重启 apihost)。', 501)
328 throw new ApiFailure(t('nav.unsupportedFolderMove'), 501)
327329 }
328330 }
329331
@@ -345,7 +347,7 @@
345347 onChanged?.()
346348 } catch (err) {
347349 setPending({ stamp, edits: {} })
348 setNotice(err instanceof ApiFailure ? err.message : '移动失败,请稍后重试。')
350 setNotice(err instanceof ApiFailure ? err.message : t('nav.moveFailed'))
349351 } finally {
350352 setBusy(false)
351353 setDragKey(null)
@@ -359,7 +361,7 @@
359361 if (dragged.key === view.key) return
360362 if (mode === 'into' && view.kind !== 'folder') return
361363 if (dragged.kind === 'folder' && view.kind === 'folder' && isInside(view.id, dragged.id)) {
362 setNotice('不能把文件夹移动到它自己或它的子文件夹里。')
364 setNotice(t('nav.moveIntoSelf'))
363365 return
364366 }
365367
@@ -508,7 +510,7 @@
508510 </p>
509511 ) : null}
510512 <div {...rootProps}>{renderLevel(null, 0, new Set())}</div>
511 {!levels.size ? <p className="px-2 py-5 text-sm leading-6 text-shell-ink-muted">这里还没有内容。使用空间菜单创建频道,开始一起协作。</p> : null}
513 {!levels.size ? <p className="px-2 py-5 text-sm leading-6 text-shell-ink-muted">{t('nav.empty')}</p> : null}
512514 {dragKey ? (
513515 <div
514516 onDragOver={event => {
@@ -522,12 +524,12 @@
522524 }}
523525 className="mx-2 my-1 flex h-9 items-center justify-center rounded-md border-2 border-dashed border-shell-hairline text-[12px] text-shell-ink-muted"
524526 >
525 放到这里:移到根目录
527 {t('nav.dropHere')}
526528 </div>
527529 ) : null}
528530 {busy ? (
529531 <p role="status" className="px-2 py-1 text-[11px] text-shell-ink-muted">
530 正在保存位置…
532 {t('nav.savingPosition')}
531533 </p>
532534 ) : null}
533535 </>
@@ -575,6 +577,7 @@
575577 /** Live participants in this voice channel, when > 0. */
576578 voiceCount?: number
577579}) {
580 const { t } = useT()
578581 const hoverTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
579582 const cancelHover = () => clearTimeout(hoverTimer.current)
580583 const startHover = () => {
@@ -637,14 +640,14 @@
637640 </span>
638641 <span className="truncate">{view.label}</span>
639642 {view.favorite ? (
640 <Star className="ml-auto h-3 w-3 shrink-0 fill-[#f0b232] text-[#f0b232]" aria-label="已收藏" />
643 <Star className="ml-auto h-3 w-3 shrink-0 fill-[#f0b232] text-[#f0b232]" aria-label={t('nav.favorite')} />
641644 ) : null}
642645 {view.voice && (voiceCount ?? 0) > 0 ? (
643646 <span
644647 className={`shrink-0 rounded-full bg-[#2ecc71]/18 px-1.5 text-[11px] font-semibold leading-[20px] text-[#1a7f37] ${
645648 view.favorite ? '' : 'ml-auto'
646649 }`}
647 title={`${voiceCount} 人在通话`}
650 title={t('nav.inCall', { count: voiceCount ?? 0 })}
648651 >
649652 {voiceCount}
650653 </span>
Modified web/src/pages/workspace/ui/space-rail.tsx View web/src/pages/workspace/ui/space-rail.tsx
@@ -1,6 +1,7 @@
11import { useEffect, useRef, type ReactNode } from 'react'
22import { Link } from 'react-router-dom'
33import { Compass, Layers3, Plus } from 'lucide-react'
4import { useT } from '@/shared/i18n'
45import { initial } from '../model/initial'
56import { prefetchSpaceLists } from '../model/prefetch'
67import type { MySpace } from '../model/workspace'
@@ -16,9 +17,10 @@
1617 /** Opens the create-space modal; falls back to a link when absent. */
1718 onAddSpace?: () => void
1819}) {
20 const { t } = useT()
1921 return (
2022 <>
21 <RailLink to="/app/@me" active={!activeName} label="主页" square>
23 <RailLink to="/app/@me" active={!activeName} label={t('shell.rail.home')} square>
2224 <Layers3 className="h-[26px] w-[26px]" />
2325 </RailLink>
2426 <div className="h-0.5 w-8 rounded-full bg-shell-tile" />
@@ -42,15 +44,15 @@
4244 </RailLink>
4345 ))}
4446 {onAddSpace ? (
45 <RailButton onClick={onAddSpace} label="添加空间">
47 <RailButton onClick={onAddSpace} label={t('shell.rail.addSpace')}>
4648 <Plus className="h-[22px] w-[22px]" />
4749 </RailButton>
4850 ) : (
49 <RailLink to="/spaces/discover" active={false} label="添加空间">
51 <RailLink to="/spaces/discover" active={false} label={t('shell.rail.addSpace')}>
5052 <Plus className="h-[22px] w-[22px]" />
5153 </RailLink>
5254 )}
53 <RailLink to="/spaces/explore" active={false} label="发现">
55 <RailLink to="/spaces/explore" active={false} label={t('shell.rail.discover')}>
5456 <Compass className="h-[22px] w-[22px]" />
5557 </RailLink>
5658 </>
Modified web/src/pages/workspace/ui/user-panel.tsx View web/src/pages/workspace/ui/user-panel.tsx
@@ -1,10 +1,12 @@
11import { Settings } from 'lucide-react'
2import { useT } from '@/shared/i18n'
23import { NotifyInboxButton } from '@/features/notifications'
34import { initial } from '../model/initial'
45import type { Me } from '../model/workspace'
56
67/** AppShell `sideFooter` slot: signed-in identity plus the local device row. */
78export function UserPanel({ me, onSettings }: { me: Me; onSettings?: () => void }) {
9 const { t } = useT()
810 return (
911 <>
1012 {me.user.avatar_url ? (
@@ -30,8 +32,8 @@
3032 <NotifyInboxButton />
3133 <button
3234 type="button"
33 aria-label="用户设置"
34 title="用户设置"
35 aria-label={t('shell.user.settings')}
36 title={t('shell.user.settings')}
3537 onClick={onSettings}
3638 className="workspace-icon-button"
3739 >
Modified web/src/pages/workspace/ui/workspace-shell.tsx View web/src/pages/workspace/ui/workspace-shell.tsx
@@ -6,6 +6,7 @@
66import { ChatView } from './chat/chat-view'
77import { VoiceCallBar } from '@/features/chat'
88import { RepoView } from './repo/repo-view'
9import { useT } from '@/shared/i18n'
910import { ApiFailure } from '@/shared/lib'
1011import { useShellRoute } from '../model/workspace-route'
1112import { useWorkspaceData } from '../model/workspace-data'
@@ -25,6 +26,7 @@
2526 * content column from the route section.
2627 */
2728export function WorkspaceShell() {
29 const { t } = useT()
2830 const route = useShellRoute()
2931 const navigate = useNavigate()
3032 const { mode, spaceName, roomId, boardId, repoName } = route
@@ -76,6 +78,23 @@
7678 prefetchLikelyTargets(spaceName, rooms.data, repos.data, boards.data)
7779 }, [spaceName, rooms.loading, rooms.data, repos.loading, repos.data, boards.loading, boards.data])
7880
81 // Browser tab title mirrors the active section. Derived from loaded data so
82 // it stays correct across language switches (`t` is locale-bound) and across
83 // in-app navigation, which never reloads the document.
84 useEffect(() => {
85 const base = t('shell.title')
86 document.title =
87 mode === 'repo' && repoName
88 ? t('shell.titleRepo', { repo: repoName, space: spaceName })
89 : mode === 'board' && boardId
90 ? t('shell.titleBoard', { board: board?.name || boardId, space: spaceName })
91 : roomId
92 ? t('shell.titleChat', { room: room?.name || dm?.name || roomId, space: spaceName })
93 : spaceName && spaceName !== '@me'
94 ? t('shell.titleSpace', { space: spaceName })
95 : base
96 }, [t, mode, spaceName, repoName, boardId, board?.name, roomId, room?.name, dm?.name])
97
7998 if (me.error instanceof ApiFailure && me.error.code === 401) {
8099 return <Navigate to="/auth/login" replace />
81100 }
@@ -257,7 +276,7 @@
257276 return (
258277 <>
259278 <ChatLayout {...shell}>
260 <PendingPanel label="从左侧选择一个频道" />
279 <PendingPanel label={t('shell.pending.pickChannel')} />
261280 </ChatLayout>
262281 {createSpaceModal}
263282 {settingsModal}
@@ -272,9 +291,10 @@
272291
273292/** Bare waiting screen shown while the shell's frame data is in flight. */
274293function ShellLoader() {
294 const { t } = useT()
275295 return (
276296 <div className="flex h-dvh w-full items-center justify-center bg-shell-surface">
277 <Loader2 className="h-6 w-6 animate-spin text-shell-ink-muted" aria-label="加载中" />
297 <Loader2 className="h-6 w-6 animate-spin text-shell-ink-muted" aria-label={t('ui.loading')} />
278298 </div>
279299 )
280300}
Modified web/src/shared/api/apiDefinitions.ts View web/src/shared/api/apiDefinitions.ts
@@ -201,6 +201,8 @@
201201 'chat.approve_hand': ['POST', '/chat/rooms/{room_id}/voice/hands/{user_id}/approve'],
202202 'chat.deny_hand': ['POST', '/chat/rooms/{room_id}/voice/hands/{user_id}/deny'],
203203 'chat.revoke_hand': ['POST', '/chat/rooms/{room_id}/voice/hands/{user_id}/revoke'],
204 'chat.get_voice_layout': ['GET', '/chat/rooms/{room_id}/voice/layout'],
205 'chat.save_voice_layout': ['PUT', '/chat/rooms/{room_id}/voice/layout'],
204206 'chat.voice_participants': ['GET', '/chat/rooms/{room_id}/voice/participants'],
205207 'chat.kick_participant': ['DELETE', '/chat/rooms/{room_id}/voice/participants/{user_id}'],
206208 'chat.moderate_mute': ['POST', '/chat/rooms/{room_id}/voice/participants/{user_id}/mute'],
Modified web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts

Diff too large to display.

Added web/src/shared/i18n/messages/en/auth.ts View web/src/shared/i18n/messages/en/auth.ts
@@ -0,0 +1,110 @@
1/** Authentication flow: shell chrome plus the six auth screens. */
2export const auth = {
3 'auth.shell.breadcrumb': 'Breadcrumb',
4 'auth.shell.switchToLight': 'Switch to light theme',
5 'auth.shell.switchToDark': 'Switch to dark theme',
6 'auth.shell.operational': 'All systems operational',
7 'auth.shell.region': 'Region {region}',
8 'auth.login.crumb': 'Sign in',
9 'auth.login.hint': 'Proof-of-work solved silently · 5 attempts / 5 min',
10 'auth.login.heading': 'Sign in to Kernel',
11 'auth.login.subheading': 'Authenticate to reach the control plane and your running clusters.',
12 'auth.login.identifier': 'Email or username',
13 'auth.login.passphrase': 'Passphrase',
14 'auth.login.forgot': 'Forgot?',
15 'auth.login.keepSignedIn': 'Keep me signed in',
16 'auth.login.keepSignedInAria': 'Keep me signed in',
17 'auth.login.submit': 'Sign in',
18 'auth.login.losingAccess': 'Losing access?',
19 'auth.login.recoveryCode': 'Recovery code',
20 'auth.login.resendVerify': 'Resend verify email',
21 'auth.login.noAccount': 'No account yet?',
22 'auth.login.requestAccess': 'Request access',
23 'auth.signup.crumb': 'Create account',
24 'auth.signup.hint': 'Password policy & age gate enforced server-side',
25 'auth.signup.heading': 'Create your account',
26 'auth.signup.subheading': 'Provision team access to the Kernel control plane and running clusters.',
27 'auth.signup.username': 'Username (optional)',
28 'auth.signup.email': 'Email',
29 'auth.signup.password': 'Password',
30 'auth.signup.dob': 'Date of birth (optional)',
31 'auth.signup.dobRequired': '{age}+ required',
32 'auth.signup.day': 'Day',
33 'auth.signup.month': 'Month',
34 'auth.signup.year': 'Year',
35 'auth.signup.termsAria': 'Agree to terms',
36 'auth.signup.termsPrefix': 'I agree to the',
37 'auth.signup.terms': 'Terms',
38 'auth.signup.and': 'and',
39 'auth.signup.privacy': 'Privacy Policy',
40 'auth.signup.submit': 'Create account',
41 'auth.signup.haveAccount': 'Already have an account?',
42 'auth.signup.signIn': 'Sign in',
43 'auth.signup.error.terms': 'Please accept the Terms and Privacy Policy to continue.',
44 'auth.signup.error.dobIncomplete': 'Please complete your date of birth.',
45 'auth.signup.error.age': 'You must be at least {age} years old to sign up.',
46 'auth.forgot.crumb': 'Recover access',
47 'auth.forgot.hint': 'Recovery links expire after 15 minutes',
48 'auth.forgot.heading': 'Forgot passphrase?',
49 'auth.forgot.subheading':
50 'Enter your workspace email and we will send a single-use recovery link. It expires after 15 minutes.',
51 'auth.forgot.email': 'Account email',
52 'auth.forgot.privacyNote':
53 'We always confirm delivery — existing accounts are never revealed.',
54 'auth.forgot.submit': 'Send recovery link',
55 'auth.forgot.remembered': 'Remembered it?',
56 'auth.forgot.backToSignIn': 'Back to sign in',
57 'auth.reset.crumb': 'New password',
58 'auth.reset.hint': 'All other sessions will be signed out',
59 'auth.reset.heading': 'Set a new password',
60 'auth.reset.subheading':
61 'Recovery token verified. Your new password is checked against the server-side policy.',
62 'auth.reset.noAddress': 'no address',
63 'auth.reset.tokenValid': 'Token valid',
64 'auth.reset.noToken': 'No token',
65 'auth.reset.newPassword': 'New password',
66 'auth.reset.confirmPassword': 'Confirm new password',
67 'auth.reset.strength.strong': 'Strong',
68 'auth.reset.strength.fair': 'Fair',
69 'auth.reset.strength.weak': 'Weak',
70 'auth.reset.error.mismatch': 'The two passwords do not match.',
71 'auth.reset.noTokenNote': 'Open the recovery link from your email on this device, or',
72 'auth.reset.requestNewLink': 'request a new link',
73 'auth.reset.done': 'Passphrase updated — redirecting to sign in…',
74 'auth.reset.submit': 'Update passphrase',
75 'auth.verify.crumb': 'Verify email',
76 'auth.verify.hint': 'Check spam folder · SMTP delivery ~5s',
77 'auth.verify.heading': 'Check your inbox',
78 'auth.verify.subheading': 'We sent a verification link to',
79 'auth.verify.purpose': 'purpose · {purpose}',
80 'auth.verify.linkOpened': 'Link opened on this device. Confirm below to continue.',
81 'auth.verify.linkExpiry': 'The link expires in 24 hours. Open it on this device to continue.',
82 'auth.verify.verified': 'Email verified — redirecting to sign in…',
83 'auth.verify.resent': 'A fresh link is on its way to {email}.',
84 'auth.verify.waiting':
85 'Waiting for the link — once you open it on this device, press the button below to confirm.',
86 'auth.verify.confirm': 'I have verified my email',
87 'auth.verify.divider': 'Did not arrive?',
88 'auth.verify.resend': 'Resend email',
89 'auth.verify.resendCountdown': 'Resend email ({time})',
90 'auth.verify.wrongAddress': 'Wrong address?',
91 'auth.verify.changeEmail': 'Change email',
92 'auth.twofa.crumb': 'Two-factor',
93 'auth.twofa.hint': 'Challenge expires in 5 min · 5 attempts max',
94 'auth.twofa.heading': 'Two-factor code',
95 'auth.twofa.subheading':
96 'Enter the 6-digit code from your authenticator app for the signed-in account.',
97 'auth.twofa.recoveryCode': 'Recovery code',
98 'auth.twofa.authCode': 'Authentication code',
99 'auth.twofa.expiresIn': 'Expires in {time}',
100 'auth.twofa.noChallengeError':
101 'No pending sign-in on this device. Start again from the sign-in screen.',
102 'auth.twofa.noChallengeNote': 'No pending challenge —',
103 'auth.twofa.signIn': 'sign in',
104 'auth.twofa.submit': 'Verify',
105 'auth.twofa.trouble': 'Trouble?',
106 'auth.twofa.useAuthenticator': 'Use authenticator app',
107 'auth.twofa.useRecovery': 'Use a recovery code',
108 'auth.error.network': 'Network error — check your connection and retry.',
109 'auth.error.unknown': 'Something went wrong. Please try again.',
110} as const
Added web/src/shared/i18n/messages/en/chat.ts View web/src/shared/i18n/messages/en/chat.ts
@@ -0,0 +1,314 @@
1/** Chat: sidebar lists, composer, transcript, threads and voice. */
2export const chat = {
3 'chat.rooms.title': 'Channels',
4 'chat.rooms.empty': 'No channels yet',
5 'chat.rooms.create': 'New channel',
6 'chat.rooms.name': 'Channel name',
7 'chat.rooms.topic': 'Topic',
8 'chat.rooms.private': 'Private channel',
9 'chat.rooms.created': 'Channel created.',
10 'chat.rooms.updated': 'Channel updated.',
11 'chat.rooms.deleted': 'Channel deleted.',
12 'chat.dms.title': 'Direct messages',
13 'chat.dms.empty': 'No direct messages yet',
14 'chat.upload.failedStatus': 'Upload failed ({status})',
15 'chat.composer.placeholder': 'Message #{room}',
16 'chat.composer.send': 'Send',
17 'chat.composer.attach': 'Add attachment',
18 'chat.composer.emoji': 'Emoji',
19 'chat.composer.tooLong': 'Message is too long (maximum {max} characters).',
20 'chat.composer.empty': 'Message cannot be empty.',
21 'chat.composer.sent': 'Message sent.',
22 'chat.composer.failed': 'Sending failed. Please try again.',
23 'chat.composer.unauthorized': 'Your session expired — sign in again',
24 'chat.composer.forbidden': 'You cannot post in this channel',
25 'chat.composer.notFound': 'That channel or message no longer exists',
26 'chat.composer.rateLimited': 'Sending too fast — try again shortly',
27 'chat.composer.unavailable': 'No response from the service; the message was not sent',
28 'chat.message.edited': 'edited',
29 'chat.message.deleted': 'Message deleted',
30 'chat.message.edit': 'Edit message',
31 'chat.message.delete': 'Delete message',
32 'chat.message.reply': 'Reply',
33 'chat.message.thread': 'Thread',
34 'chat.message.save': 'Save',
35 'chat.message.unsave': 'Unsave',
36 'chat.message.pin': 'Pin',
37 'chat.message.unpin': 'Unpin',
38 'chat.message.copyLink': 'Copy link',
39 'chat.message.saved': 'Message saved.',
40 'chat.message.unsaved': 'Message removed from saved.',
41 'chat.message.deletedNotice': 'Message deleted.',
42 'chat.message.editedNotice': 'Message updated.',
43 'chat.message.deleteConfirm': 'Delete this message? This cannot be undone.',
44 'chat.day.today': 'Today',
45 'chat.day.yesterday': 'Yesterday',
46 'chat.day.weekday': '{weekday}',
47 'chat.thread.title': 'Threads',
48 'chat.thread.empty': 'No threads yet',
49 'chat.thread.create': 'New thread',
50 'chat.thread.replies': '{count} replies',
51 'chat.thread.archived': 'Archived',
52 'chat.thread.archivedNotice': 'Thread archived.',
53 'chat.attachment.image': 'Image',
54 'chat.attachment.video': 'Video',
55 'chat.attachment.audio': 'Audio',
56 'chat.attachment.pdf': 'PDF',
57 'chat.attachment.text': 'Text',
58 'chat.attachment.file': 'File',
59 'chat.attachment.download': 'Download attachment',
60 'chat.attachment.tooLarge': 'The attachment exceeds the size limit.',
61 'chat.attachment.uploadFailed': 'Attachment upload failed. Please retry.',
62 'chat.voice.join': 'Join voice',
63 'chat.voice.leave': 'Leave voice',
64 'chat.voice.mute': 'Mute',
65 'chat.voice.unmute': 'Unmute',
66 'chat.voice.stage': 'Stage',
67 'chat.voice.connected': 'Connected to the voice channel',
68 'chat.voice.disconnected': 'Left the voice channel',
69 'chat.voice.restore': 'Restore sound',
70 'chat.voice.failed': 'Could not join the voice channel. Please retry.',
71 'chat.voice.members': 'Members online',
72 'chat.voice.empty': 'Nobody else is in this channel yet',
73 'chat.search.placeholder': 'Search messages',
74 'chat.search.empty': 'No matching messages',
75 'chat.member.list': 'Members',
76 'chat.member.invite': 'Invite members',
77 'chat.member.online': 'Online',
78 'chat.mention.everyone': 'Mention everyone',
79 'chat.notification.newMessage': '{author} mentioned you in #{room}',
80 'chat.notification.reply': '{author} replied to your message',
81 'chat.notification.viewChannel': 'View channel',
82 // Room header, connection banner and room lifecycle.
83 'chat.room.slowModeTitle': 'Slow mode — one message every {seconds}s',
84 'chat.room.dmTopicPlaceholder': 'Every conversation starts with a hello',
85 'chat.room.topicPlaceholder': 'Share what you are thinking and get things done together',
86 'chat.room.transcriptPane': 'Transcript',
87 'chat.room.callPane': 'Call view',
88 'chat.room.pins': 'Pinned messages',
89 'chat.room.threads': 'Threads',
90 'chat.room.members': 'Channel members',
91 'chat.room.membersWithCount': 'Channel members · {count}',
92 'chat.room.options': 'Channel options',
93 'chat.room.mute': 'Mute this channel',
94 'chat.room.unmute': 'Unmute',
95 'chat.room.favorite': 'Favourite this channel',
96 'chat.room.unfavorite': 'Remove from favourites',
97 'chat.room.clearDraft': 'Clear draft',
98 'chat.room.closeDm': 'Close this conversation',
99 'chat.room.settings': 'Channel settings',
100 'chat.room.info': 'Channel info',
101 'chat.room.dmSettings': 'Conversation settings',
102 'chat.room.ownedByViewer': 'You own this channel',
103 'chat.room.leave': 'Leave channel',
104 'chat.room.details': 'Channel details',
105 'chat.room.accessDenied': 'You cannot view this channel',
106 'chat.room.gone': 'This channel no longer exists or was deleted',
107 'chat.room.goneHint': 'The link may have expired, or the owner deleted the channel. Pick another one from the list on the left.',
108 'chat.room.settingsNoteDm': 'A direct message only exposes a few settings beyond its name; closing it removes it from the list.',
109 'chat.room.settingsNoteManage': 'These fields are written to the room itself, so every member sees the change.',
110 'chat.room.settingsNoteView': 'You have read access only — the settings below are read-only.',
111 'chat.room.nameHint': 'Lowercase letters, digits and hyphens are the safest choice',
112 'chat.room.topicHint': 'Shown in the title bar; up to 1024 characters',
113 'chat.room.private': 'Private channel (members only)',
114 'chat.room.rateLimit': 'Slow mode (seconds)',
115 'chat.room.rateLimitHint': '0 disables it; the shortest gap between two messages from one member',
116 'chat.room.deleteNote': 'The channel stops being visible and its messages will not come back.',
117 'chat.room.deleteConfirm': 'Delete the channel',
118 'chat.room.deleteAction': 'Delete this channel',
119 'chat.room.saved': 'Saved',
120 'chat.room.slowModeShort': 'Slow mode {seconds}s',
121
122 // Realtime connection banner.
123 'chat.connection.authRequired': 'The realtime connection needs a fresh sign-in',
124 'chat.connection.stopped': 'Realtime stopped; messages will not update automatically',
125 'chat.connection.connecting': 'Connecting to the realtime channel…',
126 'chat.connection.closed': 'Realtime disconnected; messages will not update automatically',
127 'chat.connection.retry': 'Reconnect',
128
129 // Transcript.
130 'chat.transcript.aria': 'Chat messages',
131 'chat.transcript.accessDenied': 'You no longer have access to this channel',
132 'chat.transcript.accessDeniedHint': 'The server revoked read access here. Pick another channel from the left.',
133 'chat.transcript.loadingOlder': 'Loading earlier messages…',
134 'chat.transcript.threadStart': 'The thread starts here',
135 'chat.transcript.start': 'Every good conversation starts somewhere',
136 'chat.transcript.loadFailed': 'Messages failed to load',
137 'chat.transcript.seedFailed': 'The latest messages could not be loaded',
138 'chat.transcript.threadEmptyTitle': 'Keep the discussion going',
139 'chat.transcript.threadEmptyBody': 'Share your take and be the first to reply.',
140 'chat.transcript.roomEmptyTitle': 'Welcome to {room}',
141 'chat.transcript.roomEmptyBody': 'A new topic, a fresh idea, or just a hello. Let us start here.',
142 'chat.transcript.newMessages': '{count} new messages',
143 'chat.transcript.newMessages_one': '{count} new message',
144 'chat.transcript.newMessages_other': '{count} new messages',
145 'chat.transcript.backToLatest': 'Back to the latest',
146 'chat.transcript.pinned': 'Pinned',
147 'chat.transcript.replyDeleted': 'Replying to a deleted message',
148 'chat.transcript.thisDeleted': '(this message was deleted)',
149 'chat.transcript.deletedShort': '(deleted)',
150 'chat.transcript.jumpToOriginal': 'Jump to the original message',
151 'chat.transcript.actions': 'Message actions',
152 'chat.transcript.typingOne': '{name} is typing…',
153 'chat.transcript.typingTwo': '{first} and {second} are typing…',
154 'chat.transcript.typingMany': '{names} are typing…',
155 'chat.transcript.mine': 'You',
156 'chat.transcript.unsent': 'Not sent',
157 'chat.transcript.sending': 'Sending',
158 'chat.transcript.sendFailed': 'Sending failed',
159 'chat.transcript.editHint': 'Enter to save · Shift+Enter for a new line · Esc to cancel',
160 'chat.transcript.react': 'Add a reaction',
161 'chat.transcript.replyInThread': 'Reply in a thread',
162 'chat.transcript.quoteReply': 'Quote reply',
163 'chat.transcript.copied': 'Copied',
164 'chat.transcript.copyText': 'Copy text',
165 'chat.transcript.more': 'More actions',
166 'chat.transcript.lastReply': 'Last reply {time}',
167 'chat.transcript.locked': 'Locked',
168
169 // Reactions and unread markers.
170 'chat.reaction.aria': '{emoji}, {count} reacted',
171 'chat.reaction.ariaMine': '{emoji}, {count} reacted, including you',
172 'chat.reaction.title': '{count} reacted',
173 'chat.unread.count': '{count} unread',
174 'chat.unread.label': 'Unread',
175
176 // System lines written by the backend into a room.
177 'chat.system.integration': 'Integration',
178 'chat.system.postWithContent': '{author}: {content}',
179 'chat.system.post': '{author} posted a message',
180 'chat.system.replied': '{author} replied to a message',
181 'chat.system.threadCreated': '{author} started a thread',
182 'chat.system.joined': '{author} joined this channel',
183 'chat.system.left': '{author} left this channel',
184 'chat.system.topicChanged': '{author} changed the topic to "{topic}"',
185 'chat.system.topicUpdated': '{author} updated the channel topic',
186 'chat.system.pinned': '{author} pinned a message',
187 'chat.system.reminder': '{author} set a reminder: {content}',
188 'chat.system.reminderSet': '{author} set a reminder',
189 'chat.system.message': 'System message',
190 'chat.system.sent': '{author} sent a message',
191
192 // Thread panels.
193 'chat.panel.close': 'Close panel',
194 'chat.panel.loadedMembers': 'Loading space members…',
195 'chat.panel.searching': 'Searching…',
196 'chat.thread.lockedNotice': 'This thread is locked and cannot be replied to',
197 'chat.thread.archivedReadOnly': 'This thread is archived and read-only',
198 'chat.thread.byOwner': "{name}'s thread",
199 'chat.thread.fromOwner': 'Thread from {name}',
200 'chat.thread.summary': '{replies} replies · {participants} participants',
201 'chat.thread.participants': '{count} people',
202 'chat.thread.participants_one': '{count} participant',
203 'chat.thread.participants_other': '{count} participants',
204 'chat.thread.count': '{count} threads',
205 'chat.thread.follow': 'Follow',
206 'chat.thread.followTitle': 'Notify me about every new reply in this thread',
207 'chat.thread.unfollow': 'Unfollow',
208 'chat.thread.unfollowTitle': 'Stop notifying me about this thread',
209 'chat.thread.lock': 'Lock the thread (no new replies)',
210 'chat.thread.unlock': 'Unlock the thread',
211 'chat.thread.locked': 'Locked',
212 'chat.thread.missing': 'Not found',
213 'chat.thread.missingHint': 'This thread may have been deleted, or it is still loading. Close this and reopen it from the thread list.',
214 'chat.thread.emptyHint': 'No threads yet. Choose "Reply in a thread" from a message toolbar to keep the discussion organised.',
215
216 // Pin list.
217 'chat.pins.count': '{count} pinned',
218 'chat.pins.empty': 'Nothing is pinned yet. Hover a message and pin it from "More actions".',
219 'chat.pins.jump': 'Jump to this message',
220 'chat.pins.jumpShort': 'Jump',
221
222 // Member roster.
223 'chat.member.count': '{count} people',
224 'chat.member.count_one': '{count} person',
225 'chat.member.count_other': '{count} people',
226 'chat.member.owner': 'Owner',
227 'chat.member.forbidden': 'This is a private channel; its member list is not shared with you.',
228 'chat.member.loadFailed': 'The member list failed to load.',
229 'chat.member.self': 'This is you',
230 'chat.member.dmTitle': 'Direct message @{username}',
231 'chat.member.addCount': 'Add ({count})',
232 'chat.member.allInRoom': 'Everyone in this space is already in this channel.',
233 'chat.member.listFailed': 'Space members could not be read',
234
235 // Search panel.
236 'chat.search.aria': 'Search chat messages',
237 'chat.search.inputPlaceholder': 'Type a keyword to search',
238 'chat.search.onlyRoom': 'This channel only (clear to search the whole space)',
239 'chat.search.unsupported': 'There is nothing searchable here: direct messages and the personal scope have no full-text search.',
240 'chat.search.emptyFor': 'No messages match "{query}".',
241 'chat.search.hint': 'Type a keyword to start searching',
242
243 // Attachments.
244 'chat.attachment.unknownType': 'Unknown type',
245 'chat.attachment.preview': 'Preview',
246 'chat.attachment.closePreview': 'Close preview',
247
248 // Emoji picker.
249 'chat.emoji.common': 'Frequent',
250 'chat.emoji.gestures': 'Gestures',
251 'chat.emoji.symbols': 'Symbols',
252 'chat.emoji.custom': 'Space',
253
254 // Voice surface.
255 'chat.voice.channelFallback': 'Voice channel',
256 'chat.voice.callAria': 'Call',
257 'chat.voice.barDragHint': 'Drag the empty area to move it; double-click to reset',
258 'chat.voice.mic': 'Microphone',
259 'chat.voice.camera': 'Camera',
260 'chat.voice.screenShare': 'Share screen',
261 'chat.voice.noPublish': 'You cannot speak right now',
262 'chat.voice.return': 'Back to the voice channel',
263 'chat.voice.hangUp': 'Leave the call',
264 'chat.voice.disabled': 'Voice is not enabled',
265 'chat.voice.joinFailed': 'Could not join the voice channel',
266 'chat.voice.handFailed': 'Raising your hand failed',
267 'chat.voice.handCancelFailed': 'Withdrawing your hand failed',
268 'chat.voice.handDecideFailed': 'The approval failed',
269 'chat.voice.sharing': '{name} is sharing their screen',
270 'chat.voice.stageHint': 'This is a stage channel: join to listen, raise your hand to speak, and the microphone opens once a host approves.',
271 'chat.voice.joinHint': 'Joining turns on your microphone; you can mute it or turn the camera off at any time.',
272 'chat.voice.handPending': 'Hand raised — waiting for a host to approve',
273 'chat.voice.cancelHand': 'Withdraw your hand',
274 'chat.voice.joining': 'Joining…',
275 'chat.voice.tilePlaced': 'Drag to park it, resize from the bottom-right, double-click to return to the grid',
276 'chat.voice.tileHint': 'Press and drag to any free spot',
277 'chat.voice.forceUnmute': 'Unmute',
278 'chat.voice.forceMute': 'Mute',
279 'chat.voice.kick': 'Remove from the call',
280 'chat.voice.solo': 'Nobody is here with you yet — invite members to join.',
281 'chat.voice.hands': 'Speaking requests',
282 'chat.voice.canSpeak': 'You may speak now',
283 'chat.voice.raiseHand': 'Raise your hand to speak',
284 'chat.voice.pendingCount': '{count} pending',
285 'chat.voice.noHands': 'No pending requests',
286 'chat.voice.revoke': 'Revoke',
287 'chat.voice.speaking': '{name} · speaking',
288 'chat.voice.notice.micDenied': 'Microphone access was denied; joining in listen-only mode',
289 'chat.voice.notice.connectFailed': 'The voice connection failed',
290 'chat.voice.notice.kicked': 'A moderator removed you from the call',
291 'chat.voice.notice.duplicate': 'This account joined the call somewhere else',
292 'chat.voice.notice.dropped': 'The voice connection dropped — click to rejoin',
293 'chat.voice.notice.micUnavailable': 'The microphone is unavailable',
294 'chat.voice.notice.cameraUnavailable': 'The camera is unavailable',
295 'chat.voice.notice.screenUnavailable': 'Screen sharing is unavailable',
296 'chat.voice.notice.audioBlocked': 'The browser still blocks audio playback — check the sound permission for this site',
297 'chat.voice.notice.mediaUnreachable': 'The voice channel could not be established: the browser cannot reach the LiveKit media ports (UDP 50000 / TCP 7881). Make sure LiveKit is running and retry',
298 'chat.voice.notice.permissionDenied': 'The browser denied microphone/camera permission — restore it from the address bar and retry',
299 'chat.voice.notice.tokenInvalid': 'The voice token is invalid or expired — join again',
300
301 // Bullet comments (danmaku).
302 'chat.danmaku.title': 'Bullet comments',
303 'chat.danmaku.count': '{count} comments',
304 'chat.danmaku.loadOlder': 'Load earlier',
305 'chat.danmaku.empty': 'No bullet comments yet',
306 'chat.danmaku.flying': 'Fly across the screen',
307 'chat.danmaku.placeholder': 'Fire a bullet comment…',
308 'chat.danmaku.label': 'Bullet comment',
309
310 // Composer leftovers.
311 'chat.composer.attachmentLimit': 'A message takes at most {max} attachments; the extra ones were not added',
312 'chat.composer.blockedUploading': 'An attachment is uploading; sending unlocks automatically when it finishes',
313 'chat.composer.blockedFailed': 'An attachment failed to upload — retry or remove it before sending',
314} as const
Added web/src/shared/i18n/messages/en/chatComposer.ts View web/src/shared/i18n/messages/en/chatComposer.ts
@@ -0,0 +1,30 @@
1/** Chat composer: entry hints, mention list, attachment strip, send states. */
2export const chatComposer = {
3 'chat.composer.editLabel': 'Edit message',
4 'chat.composer.replyLabel': 'Reply to {author}',
5 'chat.composer.someMessage': 'a message',
6 'chat.composer.escCancel': 'Esc to cancel',
7 'chat.composer.uploadFailedShort': 'Failed',
8 'chat.composer.uploading': 'Uploading…',
9 'chat.composer.retryUpload': 'Retry upload',
10 'chat.composer.removeFile': 'Remove {name}',
11 'chat.composer.entryHint': 'Message {room} — share what you are thinking…',
12 'chat.composer.inputAria': 'Message input',
13 'chat.composer.mentionMembers': 'Mention members',
14 'chat.composer.addAttachmentHint': 'Add an attachment (or drop and paste a file)',
15 'chat.composer.addAttachment': 'Add attachment',
16 'chat.composer.insertEmoji': 'Insert emoji',
17 'chat.composer.enterSends': 'send',
18 'chat.composer.shiftEnterNewline': 'new line',
19 'chat.composer.sendAria': 'Send',
20 'chat.composer.slowModeWait': 'Slow mode — send again in {seconds}s',
21 'chat.composer.tooLongBy': '{count} characters over',
22 'chat.composer.sendWithEnter': 'Send (Enter)',
23 'chat.composer.save': 'Save',
24 'chat.composer.tooLongDetail': '{over} characters over the {max} limit',
25 'chat.composer.slowModeDetail': 'Slow mode is on in this channel: one message every {seconds}s',
26 'chat.composer.attachmentPlaceholder': '[attachment]',
27 'chat.composer.cancel': 'Cancel',
28 'chat.composer.draftSaveFailed': 'Draft could not be saved (the message itself is unaffected)',
29 'chat.composer.overLimit': 'Over the {mb} MB limit',
30} as const
Added web/src/shared/i18n/messages/en/common.ts View web/src/shared/i18n/messages/en/common.ts
@@ -0,0 +1,51 @@
1/** Brand and language-neutral copy shared by every screen. */
2export const common = {
3 'common.app.name': 'GitDataAI',
4 'common.action.save': 'Save',
5 'common.action.cancel': 'Cancel',
6 'common.action.close': 'Close',
7 'common.action.confirm': 'Confirm',
8 'common.action.create': 'Create',
9 'common.action.delete': 'Delete',
10 'common.action.edit': 'Edit',
11 'common.action.remove': 'Remove',
12 'common.action.retry': 'Retry',
13 'common.action.copy': 'Copy',
14 'common.action.copied': 'Copied',
15 'common.action.refresh': 'Refresh',
16 'common.action.loading': 'Loading…',
17 'common.action.working': 'Working…',
18 'common.action.back': 'Back',
19 'common.action.next': 'Next',
20 'common.action.search': 'Search',
21 'common.action.upload': 'Upload',
22 'common.action.download': 'Download',
23 'common.action.enable': 'Enable',
24 'common.action.disable': 'Disable',
25 'common.state.none': 'None',
26 'common.state.all': 'All',
27 'common.state.empty': 'Nothing here yet',
28 'common.state.unknown': 'Unknown',
29 'common.state.enabled': 'Enabled',
30 'common.state.disabled': 'Disabled',
31 'common.state.optional': 'Optional',
32 'common.state.required': 'Required',
33 'common.state.busy': 'Working…',
34 'common.time.justNow': 'Just now',
35 'common.time.expiresIn': 'Expires in {time}',
36 'common.theme.light': 'Light theme',
37 'common.theme.dark': 'Dark theme',
38 'common.language.label': 'Interface language',
39 'common.language.system': 'Follow system',
40 'common.language.saved': 'Interface language updated.',
41 'common.confirm.unsaved': 'You have unsaved changes. Leave anyway?',
42 'common.copy.failed': 'Copy failed — select the text and copy manually.',
43 'common.state.unknownUser': 'Unknown user',
44 'common.state.noSelection': 'No options available',
45 'common.action.dismiss': 'Discard',
46 'common.action.ignore': 'Dismiss',
47 'common.action.add': 'Add',
48 'common.action.clear': 'Clear',
49 'common.action.approve': 'Approve',
50 'common.action.reject': 'Reject',
51} as const
Added web/src/shared/i18n/messages/en/errors.ts View web/src/shared/i18n/messages/en/errors.ts
@@ -0,0 +1,44 @@
1/** Shared error surface plus HTTP/status fallbacks for localized API errors. */
2export const errors = {
3 'error.status.400': 'The request was invalid. Check the values and try again.',
4 'error.status.401': 'Your session has expired. Please sign in again.',
5 'error.status.403': 'You do not have permission to do that.',
6 'error.status.404': 'That resource does not exist or has been removed.',
7 'error.status.409': 'That action conflicts with the current state. Refresh and retry.',
8 'error.status.429': 'Too many requests — please slow down and retry shortly.',
9 'error.status.500': 'The server hit an error. Please try again later.',
10 'error.status.502': 'The service is temporarily unavailable. Please try again later.',
11 'error.status.503': 'The service is temporarily unavailable. Please try again later.',
12 'error.status.504': 'The service timed out. Please try again later.',
13 'error.status.0': 'Network error — no response.',
14 'error.status.unknown': 'The request failed. Please try again later.',
15 'error.code.bad_request': 'The request was invalid. Check the values and try again.',
16 'error.code.unauthorized': 'Your session has expired. Please sign in again.',
17 'error.code.forbidden': 'You do not have permission to do that.',
18 'error.code.not_found': 'That resource does not exist or has been removed.',
19 'error.code.conflict': 'That action conflicts with the current state. Refresh and retry.',
20 'error.code.rate_limited': 'Too many requests — please slow down and retry shortly.',
21 'error.code.invalid_credentials': 'Incorrect account or password.',
22 'error.code.pow_failed': 'Proof-of-work verification failed. Please retry.',
23 'error.code.invalid_request_body': 'The request body is malformed.',
24 'error.code.merge_blocked': 'Branch protection rules blocked this merge.',
25 'error.code.import_disabled': 'Repository import is currently disabled.',
26 'error.code.import_upstream_failed': 'Could not reach the upstream repository. Check the URL or credentials.',
27 'error.code.password_change_required': 'You must change your password before continuing.',
28 'error.gate.title': 'Service unavailable',
29 'error.gate.body':
30 'API calls keep failing ({count} consecutive requests without a success), so rendering is paused. Check that the backend is running.',
31 'error.gate.lastFailure':
32 'Last failure: {reason}{path} | window failure rate {percent}% ({failures}/{samples})',
33 'error.gate.noResponse': 'No response',
34 'error.gate.networkError': 'Network error (no response)',
35 'error.gate.serverError': 'HTTP {status} · server error',
36 'error.gate.retry': 'Retry connection',
37 'error.gate.connecting': 'Connecting…',
38 'error.gate.probeInterval': 'Probing every {seconds}s',
39 'error.gate.lastProbe': 'last {time}',
40 'error.gate.imageAlt': 'Service unavailable',
41 'error.notFound.title': 'Page not found',
42 'error.notFound.body': 'The page you requested does not exist or has moved.',
43 'error.notFound.back': 'Back to workspace',
44} as const
Added web/src/shared/i18n/messages/en/index.ts View web/src/shared/i18n/messages/en/index.ts
@@ -0,0 +1,40 @@
1import type { MessageKey } from '../zh-CN'
2import { auth } from './auth'
3import { chat } from './chat'
4import { chatComposer } from './chatComposer'
5import { common } from './common'
6import { errors } from './errors'
7import { issueEvents, issues } from './issues'
8import { labels } from './labels'
9import { misc } from './misc'
10import { nav } from './nav'
11import { ui } from './ui'
12import { userSettings } from './userSettings'
13import { repo } from './repo'
14import { settings } from './settings'
15import { shell } from './shell'
16import { space } from './space'
17
18/**
19 * The English dictionary, typed as a total map over the reference key set. A
20 * key that exists in zh-CN but not here — or a typo in a key name — is a
21 * compile error rather than a silent runtime fallback.
22 */
23export const en: Record<MessageKey, string> = {
24 ...common,
25 ...auth,
26 ...errors,
27 ...shell,
28 ...labels,
29 ...settings,
30 ...chat,
31 ...chatComposer,
32 ...repo,
33 ...issues,
34 ...issueEvents,
35 ...space,
36 ...misc,
37 ...nav,
38 ...ui,
39 ...userSettings,
40}
Added web/src/shared/i18n/messages/en/issues.ts View web/src/shared/i18n/messages/en/issues.ts
@@ -0,0 +1,546 @@
1/** Issue board and pull requests. */
2export const issues = {
3 'issue.board.new': 'New issue',
4 'issue.board.manage': 'Manage board',
5 'issue.board.empty': 'No issues yet',
6 'issue.board.emptyNote': 'Create an issue to start tracking work.',
7 'issue.board.viewBoard': 'Board',
8 'issue.board.viewList': 'List',
9 'issue.board.filter.state': 'State',
10 'issue.board.filter.priority': 'Priority',
11 'issue.board.filter.assignee': 'Assignee',
12 'issue.board.filter.label': 'Label',
13 'issue.board.filter.milestone': 'Milestone',
14 'issue.board.filter.sort': 'Sort',
15 'issue.board.filter.search': 'Search issues',
16 'issue.board.filter.reset': 'Reset filters',
17 'issue.detail.title': 'Title',
18 'issue.detail.description': 'Description',
19 'issue.detail.assignee': 'Assignee',
20 'issue.detail.labels': 'Labels',
21 'issue.detail.milestone': 'Milestone',
22 'issue.detail.dueDate': 'Due date',
23 'issue.detail.comments': 'Comments',
24 'issue.detail.commentPlaceholder': 'Write a comment…',
25 'issue.detail.commentSubmit': 'Comment',
26 'issue.detail.noComments': 'No comments yet',
27 'issue.detail.activity': 'Activity',
28 'issue.detail.close': 'Close issue',
29 'issue.detail.reopen': 'Reopen',
30 'issue.detail.closed': 'Issue closed.',
31 'issue.detail.reopened': 'Issue reopened.',
32 'issue.detail.created': 'Issue created.',
33 'issue.detail.updated': 'Issue updated.',
34 'issue.detail.deleted': 'Issue deleted.',
35 'issue.detail.deleteConfirm': 'Delete this issue?',
36 'issue.board.name': 'Board name',
37 'issue.board.created': 'Board created.',
38 'issue.board.updated': 'Board updated.',
39 'issue.board.deleted': 'Board deleted.',
40 'issue.label.create': 'New label',
41 'issue.label.color': 'Color',
42 'issue.label.name': 'Label name',
43 'issue.milestone.create': 'New milestone',
44 'issue.milestone.title': 'Milestone title',
45 'issue.milestone.due': 'Due date',
46 'issue.watch.watching': 'Watching',
47 'issue.watch.unwatched': 'Default',
48 'issue.watch.ignore': 'Ignoring',
49 'pull.ai.autoLanguage': 'Follow the content language',
50 'pull.ai.outputLanguage': 'Output language',
51 'pull.ai.followPreference': 'Follow my preference',
52 'pull.ai.defaultOption': ' (default)',
53 'pull.ai.verdict.changesRequested': 'Changes requested',
54 'pull.ai.verdict.commented': 'Comment only',
55 'pull.new': 'New pull request',
56 'pull.title': 'Title',
57 'pull.description': 'Description',
58 'pull.base': 'Base branch',
59 'pull.head': 'Compare branch',
60 'pull.created': 'Pull request created.',
61 'pull.updated': 'Pull request updated.',
62 'pull.empty': 'No pull requests yet',
63 'pull.tabs.conversation': 'Conversation',
64 'pull.tabs.files': 'Files changed',
65 'pull.tabs.commits': 'Commits',
66 'pull.tabs.ai': 'AI review',
67 'pull.state.open': 'Open',
68 'pull.state.merged': 'Merged',
69 'pull.state.closed': 'Closed',
70 'pull.merge': 'Merge',
71 'pull.mergeMethod': 'Merge method',
72 'pull.mergeMethod.merge': 'Create a merge commit',
73 'pull.mergeMethod.squash': 'Squash and merge',
74 'pull.mergeMethod.rebase': 'Rebase and merge',
75 'pull.merged': 'Pull request merged.',
76 'pull.closedNotice': 'Pull request closed.',
77 'pull.reopened': 'Pull request reopened.',
78 'pull.draft': 'Draft',
79 'pull.readyForReview': 'Ready for review',
80 'pull.blockedHint': 'Branch protection rules blocked this merge.',
81 'pull.commentPlaceholder': 'Leave a comment…',
82 'pull.approve': 'Approve',
83 'pull.requestChanges': 'Request changes',
84 'pull.reviewSubmitted': 'Review submitted.',
85 'pull.filesChanged': 'Files changed',
86} as const
87
88/** Issue activity-line verbs, keyed by the backend's event type. */
89export const issueEvents = {
90 'issue.event.unknown': 'Updated the issue',
91 'issue.event.fallback': 'Updated the issue ({type})',
92 'issue.event.created': 'Created the issue',
93 'issue.event.title_updated': 'Changed the title',
94 'issue.event.description_updated': 'Changed the summary',
95 'issue.event.content_updated': 'Changed the description',
96 'issue.event.milestone_updated': 'Changed the milestone',
97 'issue.event.priority_updated': 'Changed the priority',
98 'issue.event.due_updated': 'Changed the due date',
99 'issue.event.closed': 'Closed the issue',
100 'issue.event.reopened': 'Reopened the issue',
101 'issue.event.comment_created': 'Added a comment',
102 'issue.event.comment_updated': 'Edited a comment',
103 'issue.event.comment_deleted': 'Deleted a comment',
104 'issue.event.member_added': 'Added a member',
105 'issue.event.member_removed': 'Removed a member',
106 'issue.event.label_added': 'Added a label',
107 'issue.event.label_removed': 'Removed a label',
108 'issue.event.dependency_added': 'Added a dependency',
109 'issue.event.dependency_removed': 'Removed a dependency',
110 'issue.event.pinned': 'Pinned the issue',
111 'issue.event.unpinned': 'Unpinned the issue',
112 'issue.event.repo_relation_added': 'Linked a repository',
113 'issue.event.repo_relation_removed': 'Unlinked a repository',
114 'issue.filter.searchPlaceholder': 'Search issues…',
115 'issue.filter.searchAria': 'Search issue titles',
116 'issue.filter.clearSearch': 'Clear search',
117 'issue.filter.statusGroup': 'State filter',
118 'issue.filter.allPriorities': 'All priorities',
119 'issue.filter.labelsFailed': 'Could not load labels',
120 'issue.filter.allLabels': 'All labels',
121 'issue.filter.allMilestones': 'All milestones',
122 'issue.filter.noMilestone': 'No milestone',
123 'issue.filter.allAssignees': 'All assignees',
124 'issue.filter.sortActive': 'Sort: {label}',
125 'issue.filter.viewGroup': 'View switch',
126 'issue.filter.clear': 'Clear filters',
127 'issue.filter.chip.state': 'State: {value}',
128 'issue.filter.chip.query': 'Keyword: {value}',
129 'issue.filter.chip.priority': 'Priority: {value}',
130 'issue.filter.chip.label': 'Label: {value}',
131 'issue.filter.chip.milestone': 'Milestone: {value}',
132 'issue.filter.chip.assignee': 'Assignee: {value}',
133 'issue.filter.unknownLabel': 'Unknown label',
134 'issue.filter.noMilestoneShort': 'None',
135 'issue.filter.unknownMilestone': 'Unknown',
136 'issue.filter.remove': 'Remove filter {text}',
137 'issue.board.manageTitle': 'Board settings, labels and milestones',
138 'issue.board.manageButton': 'Manage',
139 'issue.board.errorList': 'Issue list',
140 'issue.board.pageOutOfRange': 'Page {page} is out of range: {total} issues in total, {limit} per page.',
141 'issue.board.backToFirst': 'Back to page 1',
142 'issue.board.emptyFilteredTitle': 'No issues match',
143 'issue.board.emptyFilteredHint': 'Adjust or clear the filters above and try again.',
144 'issue.board.emptyTitle': 'This board has no issues yet',
145 'issue.board.emptyHint': 'Turn an idea into a clear next step — start with one issue.',
146 'issue.board.milestoneClipped': '"No milestone" is filtered on the current page (the backend cannot express it): {hidden} of {total} issues are hidden.',
147 'issue.board.refreshing': 'Refreshing…',
148 'issue.board.moveFailed': 'Could not move to "{priority}": {reason}',
149 'issue.board.columnAria': '{priority} priority, {count} issues',
150 'issue.board.addInColumn': 'New issue in "{column}"',
151 'issue.board.addCard': 'Add an issue',
152 'issue.board.addCardHint': 'You can also drop a card here',
153 'issue.board.cardAria': 'Issue #{number} {title}',
154 'issue.board.milestoneTitle': 'Milestone: {name}',
155 'issue.board.tableCaption': 'Issue list; select a title to see the detail',
156 'issue.board.col.state': 'State',
157 'issue.board.col.number': 'No.',
158 'issue.board.col.title': 'Title',
159 'issue.board.col.priority': 'Priority',
160 'issue.board.col.milestone': 'Milestone',
161 'issue.board.col.assignee': 'Assignee',
162 'issue.board.col.updated': 'Updated',
163 'issue.board.changePriority': 'Change priority',
164 'issue.board.pager': 'Page {page} / {pages} · {total} issues',
165 'issue.board.prev': 'Previous',
166 'issue.board.next': 'Next',
167 'issue.board.loading': 'Loading issues',
168 'issue.board.backToBoard': 'Back to board',
169 'issue.board.errorIssue': 'Issue',
170 'issue.board.loadingIssue': 'Loading issue…',
171 'issue.board.actionFailed': 'Operation failed: {reason}',
172 'issue.board.actionFailedGeneric': 'The operation failed. Please try again later.',
173 'issue.board.edit': 'Edit',
174 'issue.board.closeIssue': 'Close issue',
175 'issue.board.reopen': 'Reopen',
176 'issue.board.closedIssue': 'Issue closed.',
177 'issue.board.reopenedIssue': 'Issue reopened.',
178 'issue.board.noBody': 'This issue has no description.',
179 'issue.board.unknownAuthor': 'Unknown author',
180 'issue.board.createdAt': '{author} created {time}',
181 'issue.board.updatedAt': 'updated {time}',
182 'issue.board.closedAt': 'closed {time}',
183 'issue.board.activity': 'Activity',
184 'issue.board.activityCount': '{count} entries',
185 'issue.board.collapse': 'Collapse',
186 'issue.board.expand': 'Expand',
187 'issue.board.priorityField': 'Priority',
188 'issue.board.milestoneField': 'Milestone',
189 'issue.board.dueField': 'Due date',
190 'issue.board.unset': 'Not set',
191 'issue.board.clearDate': 'Clear date',
192 'issue.board.assigneeField': 'Assignee',
193 'issue.board.labelsField': 'Labels',
194 'issue.board.labelsFailed': 'Could not load labels',
195 'issue.board.noLabels': 'No labels',
196 'issue.board.linkedPulls': 'Linked pull requests',
197 'issue.board.notifyField': 'Notifications',
198 'issue.board.milestoneUnknown': 'Closed or deleted',
199 'issue.board.deleteFailed': 'Delete failed: {reason}',
200 'issue.board.linkFailed': 'Could not link',
201 'issue.board.unlink': 'Unlink',
202 'issue.board.stateOpen': 'Open',
203 'issue.board.noPullLinks': 'No pull requests linked yet.',
204 'issue.board.repoPlaceholder': 'Repository',
205 'issue.board.pullPlaceholder': 'PR no.',
206 'issue.board.link': 'Link',
207 'issue.board.noMembers': 'No members available',
208 'issue.board.assignAria': 'Assign to {name}',
209 'issue.board.done': 'Done',
210 'issue.board.addLabel': 'Add label',
211 'issue.board.allLabelsBound': 'Every label is already attached.',
212 'issue.board.comments': 'Comments',
213 'issue.board.loadingComments': 'Loading comments…',
214 'issue.board.commentsFailed': 'Could not load comments; reopen the issue to retry.',
215 'issue.board.noComments': 'No comments yet.',
216 'issue.board.commentSendFailed': 'Could not send the comment.',
217 'issue.board.commentPlaceholder': 'Add a comment… (Ctrl+Enter to send)',
218 'issue.board.newCommentAria': 'New comment',
219 'issue.board.submitComment': 'Comment',
220 'issue.board.unknownUser': 'Unknown user',
221 'issue.board.edited': 'edited',
222 'issue.board.editCommentAria': 'Edit comment',
223 'issue.board.replyFailed': 'Could not reply.',
224 'issue.board.replyPlaceholder': 'Reply… (Enter to send)',
225 'issue.board.replyAria': 'Reply to {name}',
226 'issue.board.commentFallback': 'comment',
227 'issue.board.loadingActivity': 'Loading activity…',
228 'issue.board.activityFailed': 'Could not load activity.',
229 'issue.board.noActivity': 'No activity yet.',
230 'issue.board.system': 'System',
231 'issue.board.form.createTitle': 'New issue',
232 'issue.board.form.editTitle': 'Edit issue #{number}',
233 'issue.board.form.editDescription': 'Clear information keeps collaboration fast.',
234 'issue.board.form.validationTitle': 'Enter a title.',
235 'issue.board.form.validationTitleMax': 'The title may be at most {max} characters.',
236 'issue.board.form.saveFailed': 'Could not save. Please try again later.',
237 'issue.board.form.title': 'Title',
238 'issue.board.form.titlePlaceholder': 'Give this issue a clear title',
239 'issue.board.form.summary': 'Summary',
240 'issue.board.form.summaryOptional': '(optional)',
241 'issue.board.form.summaryPlaceholder': 'One sentence on the impact',
242 'issue.board.form.content': 'Details',
243 'issue.board.form.contentPlaceholder': 'Steps to reproduce:\n1. …\n2. …\n\nExpected:\nActual:',
244 'issue.board.form.noLabels': 'This board has no labels yet — create them under Manage · Labels.',
245 'issue.board.form.noAssignees': 'No members can be assigned yet; you can add an assignee later.',
246 'issue.board.manage.title': 'Board management',
247 'issue.board.manage.description': 'Rename the board, maintain its labels and milestones, or delete it.',
248 'issue.board.manage.general': 'General',
249 'issue.board.manage.labels': 'Labels',
250 'issue.board.manage.milestones': 'Milestones',
251 'issue.board.manage.runFailed': 'The operation failed. Please try again later.',
252 'issue.board.manage.processing': 'Working…',
253 'issue.board.manage.name': 'Name',
254 'issue.board.manage.descriptionField': 'Description',
255 'issue.board.manage.stats': 'Totals: {open} open · {closed} closed',
256 'issue.board.manage.deleteBoard': 'Delete board',
257 'issue.board.manage.labelsFailed': 'Could not load the label list.',
258 'issue.board.manage.noLabels': 'No labels yet.',
259 'issue.board.manage.newLabel': 'New label name',
260 'issue.board.manage.useColor': 'Use colour {color}',
261 'issue.board.manage.selectColor': 'Pick a colour',
262 'issue.board.manage.labelName': 'Label name',
263 'issue.board.manage.saving': 'Saving…',
264 'issue.board.manage.saved': 'Saved',
265 'issue.board.manage.milestonesFailed': 'Could not load the milestone list.',
266 'issue.board.manage.noMilestones': 'No milestones yet.',
267 'issue.board.manage.milestoneName': 'Milestone name',
268 'issue.board.manage.due': 'Due',
269 'issue.board.manage.close': 'Close',
270 'issue.board.manage.newMilestone': 'New milestone name',
271 'issue.board.manage.milestoneDue': 'Milestone due date',
272 'issue.board.manage.add': 'Add',
273 'issue.board.closeDialog': 'Close',
274 'issue.board.noOptions': 'No options available',
275 'issue.board.confirmDiscard': 'Click again to confirm',
276 'pull.detail.tab.conversation': 'Conversation',
277 'pull.detail.tab.checks': 'Checks',
278 'pull.detail.loading': 'Loading pull request…',
279 'pull.detail.errorPull': 'Pull request',
280 'pull.detail.notFound': 'Pull request not found.',
281 'pull.detail.crumb': 'Pull requests',
282 'pull.detail.requestsMerge': 'wants to merge {head} into {base}',
283 'pull.detail.updatedAt': 'updated {time}',
284 'pull.detail.locked': 'Locked',
285 'pull.detail.autoMergeOn': 'Auto-merge on',
286 'pull.detail.complete': '{label} done.',
287 'pull.detail.failed': '{label} failed: {reason}',
288 'pull.detail.actionFailed': '{label} failed: {reason}',
289 'pull.detail.close': 'Close',
290 'pull.detail.reopen': 'Reopen',
291 'pull.detail.unlock': 'Unlock',
292 'pull.detail.lock': 'Lock',
293 'pull.detail.unlockDiscussion': 'Unlock discussion',
294 'pull.detail.lockDiscussion': 'Lock discussion',
295 'pull.detail.refreshSummary': 'Refresh summary',
296 'pull.detail.patch': 'Patch',
297 'pull.detail.deleteConfirm': 'Delete #{number}? This cannot be undone.',
298 'pull.detail.deleteFailed': 'Delete failed: {reason}',
299 'pull.detail.actions': 'Actions',
300 'pull.detail.reviewers': 'Reviewers',
301 'pull.detail.required': 'Required',
302 'pull.detail.noReviewers': 'No review requested yet.',
303 'pull.detail.reviewRequestPlaceholder': 'Username or UUID to request a review',
304 'pull.detail.assignees': 'Assignees',
305 'pull.detail.noAssignees': 'No assignees yet.',
306 'pull.detail.assigneePlaceholder': 'Username to add as assignee',
307 'pull.detail.labels': 'Labels',
308 'pull.detail.noLabels': 'No labels.',
309 'pull.detail.labelPlaceholder': 'Label name or UUID to attach',
310 'pull.detail.stats': 'Stats',
311 'pull.detail.aheadBehind': 'Ahead / behind',
312 'pull.detail.filesChanged': 'Files changed',
313 'pull.detail.lines': 'Lines',
314 'pull.detail.created': 'Created',
315 'pull.detail.mergedAt': 'Merged',
316 'pull.detail.mergeCommit': 'Merge commit',
317 'pull.detail.merge': 'Merge',
318 'pull.detail.draftMergeHint': 'This is a draft PR — use Edit in the top right to mark it ready before reviewing and merging.',
319 'pull.detail.conflicts': '{count} conflicting files cannot be merged directly:',
320 'pull.detail.blockedByRules': 'Blocked by protection rules: {reasons}',
321 'pull.detail.needsApprovals': '{required} approvals required (currently {current})',
322 'pull.detail.canBypass': '(you can bypass)',
323 'pull.detail.staleBranch': 'The head branch is {count} commits behind the base branch.',
324 'pull.detail.readyToMerge': 'Ready to merge',
325 'pull.detail.approvals': '({current}/{required} approvals)',
326 'pull.detail.syncBranch': 'Sync base branch into head branch',
327 'pull.detail.syncFailed': 'Could not sync the branch: {reason}',
328 'pull.detail.mergeTitlePlaceholder': 'Merge title (defaults to the PR title)',
329 'pull.detail.mergeMessagePlaceholder': 'Merge message (defaults to the commit message)',
330 'pull.detail.deleteBranchAfter': 'Delete the head branch after merging',
331 'pull.detail.merging': 'Merging…',
332 'pull.detail.autoMerge': 'Auto-merge once checks pass',
333 'pull.detail.autoMergeFailed': 'Could not change auto-merge: {reason}',
334 'pull.detail.mergeSucceeded': 'Merged successfully: {sha}',
335 'pull.detail.mergeFailed': 'Merge failed: {reason}',
336 'pull.detail.viewConflict': 'Click to see the conflict',
337 'pull.detail.noConflictText': '(no conflict text to show)',
338 'pull.detail.conflictContent': 'Conflict · {path}',
339 'pull.detail.loadingConflict': 'Loading conflict text…',
340 'pull.detail.updateFailed': 'Update failed: {reason}',
341 'pull.detail.editNumber': 'Edit #{number}',
342 'pull.detail.draftCheckbox': 'Draft (no reviews or merges)',
343 'pull.detail.requestReviewFailed': 'Could not request a review: {reason}',
344 'pull.detail.remove': 'Remove {name}',
345 'pull.detail.addAssigneeFailed': 'Could not add the assignee: {reason}',
346 'pull.detail.attachLabelFailed': 'Could not attach the label: {reason}',
347 'pull.conv.locked': 'This PR discussion is locked; writes may be rejected by the server.',
348 'pull.conv.saveFailed': 'Could not save',
349 'pull.conv.deleteConfirm': 'Delete this comment?',
350 'pull.conv.edited': '(edited)',
351 'pull.conv.edit': 'Edit',
352 'pull.conv.delete': 'Delete',
353 'pull.conv.inline': 'Inline review comment',
354 'pull.conv.thread': 'Conversation comment',
355 'pull.conv.resolved': 'Resolved',
356 'pull.conv.markResolved': 'Resolve',
357 'pull.conv.replyPlaceholder': 'Reply to this comment…',
358 'pull.conv.send': 'Send',
359 'pull.conv.reply': 'Reply',
360 'pull.conv.official': 'Official',
361 'pull.conv.inlineCount': '{count} inline comments',
362 'pull.conv.publishApprove': 'Approve and publish',
363 'pull.conv.publishRequestChanges': 'Request changes and publish',
364 'pull.conv.publishComment': 'Publish comment only',
365 'pull.conv.dismiss': 'Dismiss review',
366 'pull.conv.loadDetails': 'Load review details ({count} inline comments with authors and state)',
367 'pull.conv.mode.comment': 'Comment',
368 'pull.conv.mode.approve': 'Approve',
369 'pull.conv.mode.requestChanges': 'Request changes',
370 'pull.conv.empty': 'The body cannot be empty.',
371 'pull.conv.submitFailed': 'Could not submit',
372 'pull.conv.append': 'Add a note',
373 'pull.conv.write': 'Comment / submit a review',
374 'pull.conv.commentPlaceholder': 'Comment on this pull request…',
375 'pull.conv.reviewPlaceholder': 'Add your review notes…',
376 'pull.conv.draftReviewHint': 'Draft PRs usually do not accept reviews; the server may reject this.',
377 'pull.conv.submitting': 'Submitting…',
378 'pull.conv.post': 'Comment',
379 'pull.conv.submitReview': 'Submit review',
380 'pull.conv.clear': 'Clear',
381 'pull.files.commentFailed': 'Could not comment',
382 'pull.files.reviewFailed': 'Could not submit the review',
383 'pull.files.loading': 'Loading changed files…',
384 'pull.files.errorFiles': 'Changed files',
385 'pull.files.empty': 'No file changes.',
386 'pull.files.summary': '{count} files changed',
387 'pull.files.compareVersion': 'Comparing',
388 'pull.files.linePlaceholder': 'Comment on {path}:{line} ({side})…',
389 'pull.files.sideOld': 'old line',
390 'pull.files.sideNew': 'new line',
391 'pull.files.postLine': 'Comment on this line',
392 'pull.files.queueReview': 'Add to pending review',
393 'pull.files.pendingTitle': 'Pending review ({count} inline comments)',
394 'pull.files.removeDraft': 'Remove this comment',
395 'pull.files.reviewSummaryPlaceholder': 'Review summary (optional)…',
396 'pull.files.approve': 'Approve',
397 'pull.files.requestChanges': 'Request changes',
398 'pull.files.commentOnly': 'Comment only',
399 'pull.files.saveAsPending': 'Save as pending review',
400 'pull.files.discard': 'Discard',
401 'pull.files.status.added': 'Added',
402 'pull.files.status.deleted': 'Deleted',
403 'pull.files.status.renamed': 'Renamed',
404 'pull.files.status.copied': 'Copied',
405 'pull.files.status.binary': 'Binary',
406 'pull.files.status.modified': 'Modified',
407 'pull.mergeable.clean': 'Mergeable',
408 'pull.mergeable.blocked': 'Blocked',
409 'pull.mergeable.dirty': 'Conflicts',
410 'pull.mergeable.unstable': 'Checks failing',
411 'pull.mergeable.hasHooks': 'Waiting on hooks',
412 'pull.mergeable.unknown': 'Unknown state',
413 'pull.parts.binaryDiff': 'Binary file — no diff is shown.',
414 'pull.parts.metaOnly': 'Metadata-only change.',
415 'pull.parts.addComment': 'Add a review comment on this line',
416 'pull.parts.truncated': 'The diff is long; showing the first 1500 lines.',
417 'pull.ai.notice': 'Review job submitted ({model}{scope}); refresh once it finishes.',
418 'pull.ai.triggerFailed': 'Could not trigger the AI review',
419 'pull.ai.loading': 'Loading AI reviews…',
420 'pull.ai.errorAi': 'AI review',
421 'pull.ai.rerun': 'Review again',
422 'pull.ai.refresh': 'Refresh',
423 'pull.ai.currentVersion': 'Current revision',
424 'pull.ai.oldVersion': 'Based on an older revision {sha}',
425 'pull.ai.truncated': 'The diff was truncated',
426 'pull.ai.noSummary': '(no summary)',
427 'pull.ai.jobError': 'Job error: {error}',
428 'pull.ai.locate': 'Locate this line in the changed files',
429 'pull.ai.oldLine': 'old line',
430 'pull.ai.noFindings': 'Nothing needs changing.',
431 'pull.ai.empty': 'This pull request has no AI review yet. With [ai] enabled, opening or updating the PR reviews it automatically; you can also trigger one with the button above.',
432 'pull.checks.pending': 'Queued',
433 'pull.checks.running': 'Running',
434 'pull.checks.success': 'Passed',
435 'pull.checks.failed': 'Failed',
436 'pull.checks.cancelled': 'Cancelled',
437 'pull.checks.waiting': 'Waiting',
438 'pull.checks.error': 'Error',
439 'pull.checks.loading': 'Loading checks…',
440 'pull.checks.errorChecks': 'Checks',
441 'pull.checks.empty': 'No check data.',
442 'pull.checks.versionEmpty': 'Revision {sha} has no pipeline runs or commit statuses.',
443 'pull.checks.version': 'Revision',
444 'pull.checks.pipeline': 'Pipeline #{id}',
445 'pull.checks.details': 'Details',
446 'pull.commits.loading': 'Loading commits…',
447 'pull.commits.errorList': 'Commit list',
448 'pull.commits.empty': 'This pull request has no commits.',
449 'pull.commits.noSubject': '(no subject)',
450 'pull.commits.unknownAuthor': 'Unknown',
451 'pull.commits.mergeCommit': 'Merge commit ({count} parents)',
452 'pull.commits.view': 'View commit',
453 'pull.list.scope.created': 'Created by me',
454 'pull.list.scope.reviewRequested': 'Awaiting my review',
455 'pull.list.scope.reviewed': 'Reviewed by me',
456 'pull.list.repo': 'This repository',
457 'pull.list.mine': 'My PRs',
458 'pull.list.searchPlaceholder': 'Search title/description',
459 'pull.list.new': 'New pull request',
460 'pull.list.loading': 'Loading pull requests…',
461 'pull.list.errorList': 'Pull request list',
462 'pull.list.emptyInbox': 'This inbox is empty.',
463 'pull.list.empty': 'No pull requests match.',
464 'pull.list.pager': '{total} entries',
465 'pull.list.total': '{total} entries',
466 'pull.form.title': 'New pull request',
467 'pull.form.description': '{repo} · Merge the head branch into the base branch',
468 'pull.form.missingFields': 'Title, head branch and base branch are all required.',
469 'pull.form.sameBranch': 'The head and base branches must differ.',
470 'pull.form.createFailed': 'Could not create',
471 'pull.form.creating': 'Creating…',
472 'pull.form.createDraft': 'Create draft',
473 'pull.form.create': 'Create pull request',
474 'pull.form.head': 'Head branch',
475 'pull.form.base': 'Base branch',
476 'pull.form.comparing': 'Comparing {base} … {head}…',
477 'pull.form.fastForward': 'Fast-forward merge possible, no conflicts',
478 'pull.form.willMergeCommit': '(a merge commit will be created)',
479 'pull.form.conflicts': '{count} conflicts',
480 'pull.form.conflictsHint': ' — you can still open the PR, but they must be resolved before merging.',
481 'pull.form.titlePlaceholder': 'Merge {head} into {base}',
482 'pull.form.titlePlaceholderFallback': 'Briefly describe this change',
483 'pull.form.bodyPlaceholder': 'Explain the motivation, how you verified it, and the risks…',
484 'pull.form.draftCheckbox': 'Create as draft (mark ready before requesting reviews)',
485 'pull.form.descriptionField': 'Description (plain text, line breaks kept)',
486 'issue.board.removeLabel': 'Remove label {name}',
487 'issue.board.filterLabelPrefix': 'Priority',
488 'issue.board.form.createDescription': 'Record the problem so collaboration has something to stand on.',
489 'issue.board.filter.due': 'Due date',
490 'issue.board.milestoneLabel': 'Milestone {name}',
491 'issue.board.pullState': '({state})',
492 'issue.filter.triggerAria': '{label}: {value}',
493 'pull.reviewState.approved': 'Approved',
494 'pull.reviewState.changesRequested': 'Changes requested',
495 'pull.reviewState.commented': 'Commented',
496 'pull.reviewState.pending': 'Pending',
497 'pull.reviewState.dismissed': 'Dismissed',
498 'pull.reviewerState.requested': 'Review requested',
499 'pull.reviewerState.approved': 'Approved',
500 'pull.reviewerState.changesRequested': 'Changes requested',
501 'pull.reviewerState.commented': 'Commented',
502 'pull.reviewerState.dismissed': 'Dismissed',
503 'pull.event.created': 'created this pull request',
504 'pull.event.updated': 'updated the pull request',
505 'pull.event.titleChanged': 'changed the title',
506 'pull.event.bodyEdited': 'edited the description',
507 'pull.event.baseChanged': 'changed the base branch',
508 'pull.event.markedReady': 'marked it ready for review',
509 'pull.event.markedDraft': 'converted it to a draft',
510 'pull.event.closed': 'closed the pull request',
511 'pull.event.reopened': 'reopened the pull request',
512 'pull.event.merged': 'merged this pull request',
513 'pull.event.reviewRequested': 'requested a review',
514 'pull.event.reviewRequestRemoved': 'removed a review request',
515 'pull.event.reviewSubmitted': 'submitted a review',
516 'pull.event.approved': 'approved this pull request',
517 'pull.event.changesRequested': 'requested changes',
518 'pull.event.comment': 'commented',
519 'pull.event.labeled': 'added a label',
520 'pull.event.unlabeled': 'removed a label',
521 'pull.event.assigned': 'added an assignee',
522 'pull.event.unassigned': 'removed an assignee',
523 'pull.event.locked': 'locked the discussion',
524 'pull.event.unlocked': 'unlocked the discussion',
525 'pull.event.forcePush': 'force-pushed to the head branch',
526 'pull.event.branchUpdated': 'synced the base branch',
527 'pull.event.headRefUpdated': 'updated the head branch',
528 'pull.event.autoMergeEnabled': 'enabled auto-merge',
529 'pull.event.autoMergeDisabled': 'disabled auto-merge',
530 'pull.badge.draft': 'Draft',
531 'pull.ai.matchesCurrent': 'Matches the current revision',
532 'pull.files.summary_one': '{count} file changed',
533 'pull.files.summary_other': '{count} files changed',
534 'issue.board.pager_one': 'Page {page} / {pages} · {total} issue',
535 'issue.board.pager_other': 'Page {page} / {pages} · {total} issues',
536 'issue.board.activityCount_one': '{count} entry',
537 'issue.board.activityCount_other': '{count} entries',
538 'issue.board.columnAria_one': '{priority} priority, {count} issue',
539 'issue.board.columnAria_other': '{priority} priority, {count} issues',
540 'issue.board.pageOutOfRange_one': 'Page {page} is out of range: {total} issue in total, {limit} per page.',
541 'issue.board.pageOutOfRange_other': 'Page {page} is out of range: {total} issues in total, {limit} per page.',
542 'pull.conv.inlineCount_one': '{count} inline comment',
543 'pull.conv.inlineCount_other': '{count} inline comments',
544 'pull.list.total_one': '{total} entry',
545 'pull.list.total_other': '{total} entries',
546} as const
Added web/src/shared/i18n/messages/en/labels.ts View web/src/shared/i18n/messages/en/labels.ts
@@ -0,0 +1,44 @@
1/** Domain label tables: status/priority/role names reused by tables and filters. */
2export const labels = {
3 'label.priority.urgent': 'Urgent',
4 'label.priority.high': 'High',
5 'label.priority.medium': 'Medium',
6 'label.priority.low': 'Low',
7 'label.priority.none': 'No priority',
8 'label.issueState.open': 'Open',
9 'label.issueState.closed': 'Closed',
10 'label.issueState.all': 'All',
11 'label.issueSort.created': 'Newest',
12 'label.issueSort.updated': 'Recently updated',
13 'label.issueSort.due': 'Due date',
14 'label.issueSort.priority': 'Priority',
15 'label.issueSort.number': 'Number',
16 'label.issueView.board': 'Board view',
17 'label.issueView.list': 'List view',
18 'label.role.owner': 'Owner',
19 'label.role.admin': 'Admin',
20 'label.role.member': 'Member',
21 'label.role.guest': 'Guest',
22 'label.visibility.public': 'Public',
23 'label.visibility.registered': 'Registered users',
24 'label.visibility.members': 'Members only',
25 'label.visibility.private': 'Private',
26 'label.audience.everyone': 'Everyone',
27 'label.audience.following': 'People I follow',
28 'label.audience.nobody': 'Nobody',
29 'label.runStatus.queued': 'Queued',
30 'label.runStatus.running': 'Running',
31 'label.runStatus.success': 'Succeeded',
32 'label.runStatus.failed': 'Failed',
33 'label.runStatus.cancelled': 'Cancelled',
34 'label.runStatus.skipped': 'Skipped',
35 'label.authEvent.push': 'Push',
36 'label.authEvent.pullRequest': 'Pull request',
37 'label.authEvent.comment': 'Comment',
38 'label.authEvent.issue': 'Issue',
39 'label.presence.online': 'Online',
40 'label.presence.away': 'Away',
41 'label.presence.busy': 'Busy',
42 'label.presence.offline': 'Offline',
43 'label.presence.invisible': 'Invisible',
44} as const
Added web/src/shared/i18n/messages/en/misc.ts View web/src/shared/i18n/messages/en/misc.ts
@@ -0,0 +1,56 @@
1/** Notifications, search results and miscellany that lacks a home domain. */
2export const misc = {
3 'notifications.title': 'Notifications',
4 'notifications.empty': 'No notifications yet',
5 'notifications.markAllRead': 'Mark all as read',
6 'notifications.markedAll': 'All notifications marked as read.',
7 'notifications.unread': 'Unread',
8 'notifications.filter.all': 'All',
9 'notifications.filter.unread': 'Unread',
10 'notifications.filter.mentions': 'Mentions',
11 'notifications.delete': 'Delete notification',
12 'notifications.deleted': 'Notification deleted.',
13 'search.requestFailed': 'Search failed',
14 'search.title': 'Search',
15 'search.placeholder': 'Search code, issues, messages…',
16 'search.results': 'Search results',
17 'search.empty': 'No matching results',
18 'search.failed': 'Search failed. Please try again later.',
19 'search.tab.code': 'Code',
20 'search.tab.issues': 'Issues',
21 'search.tab.messages': 'Messages',
22 'search.tab.repos': 'Repositories',
23 'notifications.sub': 'Invites, assignments and system reminders live here; mentions and replies take you straight back to their channel or issue.',
24 'notifications.filter': 'Notification filter',
25 'notifications.emptyUnread': 'No unread notifications.',
26 'notifications.emptyAll': 'Nothing needs your attention right now.',
27 'notifications.inviteByEmail': 'Handle this invite from your email.',
28 'notifications.inviteVoid': 'This invite is no longer valid: it may have been accepted elsewhere, revoked or expired.',
29 'notifications.joined': 'Joined',
30 'notifications.reject': 'Decline',
31 'notifications.accept': 'Accept invite',
32 'notifications.type.mention': 'Mention',
33 'notifications.type.reply': 'Reply',
34 'notifications.type.follow': 'Follow',
35 'notifications.type.invite': 'Invite',
36 'notifications.type.assignment': 'Assignment',
37 'notifications.type.system': 'System',
38 'notifications.target.issue': 'View issue #{number}',
39 'notifications.target.room': 'Open channel',
40 'notifications.target.space': 'Enter space',
41 'notifications.bell': 'Notifications',
42 'notifications.bellUnread': 'Notifications, {count} unread',
43 'notifications.region': 'System notifications',
44 'notifications.closeToast': 'Dismiss notification',
45 'search.command.placeholder': 'Search code, symbols, issues and pull requests…',
46 'search.command.open': 'Open global search',
47 'search.command.dialog': 'Global search',
48 'search.command.close': 'Close search',
49 'search.command.types': 'Search types',
50 'search.command.hint': 'Type a keyword to start searching',
51 'search.command.loading': 'Searching…',
52 'search.kind.code': 'Code',
53 'search.kind.symbol': 'Symbols',
54 'search.kind.issue': 'Issues',
55 'search.kind.pull': 'Pull requests',
56} as const
Added web/src/shared/i18n/messages/en/nav.ts View web/src/shared/i18n/messages/en/nav.ts
@@ -0,0 +1,12 @@
1/** Workspace navigation: sidebar rows, drag-and-drop notices, empty states. */
2export const nav = {
3 'nav.empty': 'Nothing here yet. Create a channel from the space menu to start collaborating.',
4 'nav.dropHere': 'Drop here: move to root',
5 'nav.savingPosition': 'Saving position…',
6 'nav.moveFailed': 'Move failed. Please try again.',
7 'nav.moveIntoSelf': 'A folder cannot be moved into itself or one of its subfolders.',
8 'nav.unsupportedFolderMove': 'The backend rejected folder placement for channels (update and restart apihost).',
9 'nav.favorite': 'Favorited',
10 'nav.inCall': '{count} in call',
11 'nav.unreadCount': '{count} unread',
12} as const
Added web/src/shared/i18n/messages/en/repo.ts View web/src/shared/i18n/messages/en/repo.ts
@@ -0,0 +1,663 @@
1/** Repository surfaces: browse, history, branches, releases, pipelines, settings. */
2export const repo = {
3 'repo.tabs.code': 'Code',
4 'repo.tabs.commits': 'Commits',
5 'repo.tabs.branches': 'Branches',
6 'repo.tabs.tags': 'Tags',
7 'repo.tabs.releases': 'Releases',
8 'repo.tabs.pipelines': 'Pipelines',
9 'repo.tabs.settings': 'Settings',
10 'repo.tabs.ai': 'AI',
11 'repo.tabs.pulls': 'Pull requests',
12 'repo.unknownPath': '(unknown path)',
13 'repo.import.failed': 'The import request failed', 'repo.empty': 'This repository has no files',
14 'repo.loading': 'Loading repository…',
15 'repo.notFound': 'That repository does not exist or has been deleted.',
16 'repo.branch.default': 'Default branch',
17 'repo.branch.switch': 'Switch branch',
18 'repo.branch.create': 'New branch',
19 'repo.branch.name': 'Branch name',
20 'repo.branch.created': 'Branch created.',
21 'repo.branch.deleted': 'Branch deleted.',
22 'repo.branch.deleteConfirm': 'Delete branch {branch}?',
23 'repo.branch.empty': 'No branches',
24 'repo.tag.create': 'New tag',
25 'repo.tag.name': 'Tag name',
26 'repo.tag.created': 'Tag created.',
27 'repo.tag.deleted': 'Tag deleted.',
28 'repo.tag.empty': 'No tags',
29 'repo.file.history': 'File history',
30 'repo.file.download': 'Download file',
31 'repo.file.raw': 'Raw view',
32 'repo.file.blame': 'Blame',
33 'repo.file.copyPath': 'Copy path',
34 'repo.file.tooLarge': 'This file is too large to preview.',
35 'repo.file.binary': 'Binary files cannot be previewed.',
36 'repo.clone.title': 'Clone repository',
37 'repo.clone.https': 'HTTPS',
38 'repo.clone.ssh': 'SSH',
39 'repo.clone.copied': 'Clone URL copied.',
40 'repo.commit.message': 'Commit message',
41 'repo.commit.author': 'Author',
42 'repo.commit.date': 'Date',
43 'repo.commit.sha': 'Commit hash',
44 'repo.commit.parent': 'Parent',
45 'repo.commit.empty': 'No commits',
46 'repo.commit.diffEmpty': 'No differences to display',
47 'repo.commit.unified': 'Unified view',
48 'repo.commit.split': 'Split view',
49 'repo.commit.whitespace': 'Ignore whitespace',
50 'repo.commit.filesChanged': '{count} files changed',
51 'repo.commit.insights': 'Commit insights',
52 'repo.search.placeholder': 'Search this repository',
53 'repo.search.empty': 'No matching files',
54 'repo.release.create': 'New release',
55 'repo.release.title': 'Release title',
56 'repo.release.tag': 'Tag',
57 'repo.release.notes': 'Release notes',
58 'repo.release.draft': 'Draft',
59 'repo.release.prerelease': 'Pre-release',
60 'repo.release.published': 'Release published.',
61 'repo.release.deleted': 'Release deleted.',
62 'repo.release.empty': 'No releases yet',
63 'repo.release.downloads': 'Downloads',
64 'repo.pipeline.run': 'Run pipeline',
65 'repo.pipeline.trigger': 'Trigger manually',
66 'repo.pipeline.cancel': 'Cancel run',
67 'repo.pipeline.rerun': 'Re-run',
68 'repo.pipeline.triggered': 'Pipeline triggered.',
69 'repo.pipeline.cancelled': 'Pipeline cancelled.',
70 'repo.pipeline.empty': 'No pipeline runs yet',
71 'repo.pipeline.jobs': 'Jobs',
72 'repo.pipeline.logs': 'Logs',
73 'repo.pipeline.artifacts': 'Artifacts',
74 'repo.settings.general': 'General',
75 'repo.settings.name': 'Repository name',
76 'repo.settings.description': 'Description',
77 'repo.settings.visibility': 'Visibility',
78 'repo.settings.defaultBranch': 'Default branch',
79 'repo.settings.saved': 'Repository settings saved.',
80 'repo.settings.danger': 'Danger zone',
81 'repo.settings.deleteRepo': 'Delete repository',
82 'repo.settings.deleteConfirm': 'Deletion cannot be undone. Type the repository name to confirm: {name}',
83 'repo.settings.deleted': 'Repository deleted.',
84 'repo.settings.sections.protection': 'Branch protection',
85 'repo.settings.sections.archives': 'Archives',
86 'repo.settings.sections.remotes': 'Remote mirrors',
87 'repo.settings.sections.locks': 'File locks',
88 'repo.settings.sections.lfs': 'LFS',
89 'repo.settings.sections.ci': 'CI configuration',
90 'repo.settings.sections.webhooks': 'Webhooks',
91 'repo.settings.sections.collaborators': 'Collaborators',
92 'repo.lfs.enabled': 'LFS enabled',
93 'repo.lfs.patterns': 'Tracked patterns',
94 'repo.lfs.objects': 'LFS objects',
95 'repo.webhook.add': 'New webhook',
96 'repo.webhook.url': 'Payload URL',
97 'repo.webhook.secret': 'Signing secret',
98 'repo.webhook.events': 'Events',
99 'repo.webhook.created': 'Webhook created.',
100 'repo.webhook.deleted': 'Webhook deleted.',
101 'repo.webhook.empty': 'No webhooks yet',
102 'repo.ai.summary': 'Repository summary',
103 'repo.ai.review': 'AI review',
104 'repo.ai.generate': 'Generate summary',
105 'repo.ai.generating': 'Generating summary…',
106 'repo.ai.generated': 'Summary generated.',
107 'repo.ai.failed': 'The AI task failed. Please try again later.',
108 'repo.ai.empty': 'No summary generated yet',
109 'repo.ai.language': 'Output language',
110 'repo.ai.languageAuto': 'Auto (follow the content language)',
111 'repo.action.add': 'Add',
112 'repo.action.browseCode': 'Browse code',
113 'repo.action.confirmDelete': 'Confirm delete',
114 'repo.action.details': 'Details',
115 'repo.action.refreshing': 'Refreshing…',
116 'repo.action.rename': 'Rename',
117 'repo.action.saving': 'Saving…',
118 'repo.action.setDefault': 'Set as default',
119 'repo.action.viewChanges': 'View changes',
120 'repo.error.actionFailed': 'The operation failed. Please try again later.',
121 'repo.error.actionFailedShort': 'The operation failed',
122 'repo.error.loadFailed': 'Failed to load',
123 'repo.error.saveFailed': 'Failed to save',
124 'repo.view.loadFailed': 'Failed to load the repository',
125 'repo.view.notFound': 'This repository does not exist or you do not have access to it',
126 'repo.view.archived': 'This repository is archived (read-only). Restore it in Settings.',
127 'repo.clone.notConfigured': 'Clone URL not configured',
128 'repo.clone.notConfiguredHint': 'The backend has no [git].base_url / http_clone_base_url, so clone URLs cannot be generated',
129 'repo.clone.sshHint': 'SSH clones require a public key registered under User settings → SSH keys',
130 'repo.clone.copiedLabel': 'Clone URL copied',
131 'repo.clone.copy': 'Copy clone URL',
132 'repo.clone.copyFailedHint': 'Copy failed — select the address on the left and copy it manually',
133 'repo.clone.copyManually': 'Copy manually',
134 'repo.clone.copiedStatus': 'Clone URL copied to the clipboard',
135 'repo.clone.copyFailedStatus': 'Copy failed — select the clone URL and copy it manually',
136 'repo.branch.defaultBadge': 'Default',
137 'repo.branch.current': 'Viewing',
138 'repo.branch.protected': 'Protected',
139 'repo.branch.protectedNote': 'Protected branches cannot be deleted, and renaming requires an administrator',
140 'repo.branch.protectedRenameBlocked': 'Protected branches cannot be renamed',
141 'repo.branch.deleteBlocked': 'The default branch and protected branches cannot be deleted',
142 'repo.branch.alreadyDefault': 'Already the default branch',
143 'repo.branch.makeDefaultHint': 'Make this the repository default branch',
144 'repo.branch.defaultSet': '“{name}” is now the default branch.',
145 'repo.branch.deletedName': 'Deleted branch “{name}”.',
146 'repo.branch.updated': 'Updated {time}',
147 'repo.branch.createHint': 'Branch names may contain letters, digits and . - _ / (no .., //, spaces, etc.) and must be at most 250 characters.',
148 'repo.branch.createSubmit': 'Create branch',
149 'repo.branch.startPoint': 'Start point',
150 'repo.branch.startPointHint': 'A branch name, tag or commit; defaults to the current revision.',
151 'repo.branch.createdName': 'Created branch “{name}”.',
152 'repo.branch.rename': 'Rename branch',
153 'repo.branch.renameHint': 'Renaming only affects this repository branch reference; local clones need to fetch again.',
154 'repo.branch.renameSubmit': 'Rename',
155 'repo.branch.newName': 'New branch name',
156 'repo.branch.sameName': 'The new name is the same as the current name.',
157 'repo.branch.renamed': 'Renamed branch “{from}” to “{next}”.',
158 'repo.branch.validate.empty': 'Enter a branch name.',
159 'repo.branch.validate.tooLong': 'A branch name can be at most {count} characters.',
160 'repo.branch.validate.startsWithDash': 'A branch name cannot start with a dash.',
161 'repo.branch.validate.endsWithSlash': 'A branch name cannot end with a slash.',
162 'repo.branch.validate.endsWithLock': 'A branch name cannot end with .lock.',
163 'repo.branch.validate.doubleDot': 'A branch name cannot contain two consecutive dots.',
164 'repo.branch.validate.atBrace': 'A branch name cannot contain the sequence @{.',
165 'repo.branch.validate.doubleSlash': 'A branch name cannot contain two consecutive slashes.',
166 'repo.branch.validate.backslash': 'A branch name cannot contain a backslash.',
167 'repo.branch.validate.whitespace': 'A branch name cannot contain spaces or control characters.',
168 'repo.branches.title': 'Branches',
169 'repo.branches.total': '{count} total',
170 'repo.branches.filter': 'Filter branches',
171 'repo.branches.filterPlaceholder': 'Filter branches…',
172 'repo.branches.listName': 'Branch list',
173 'repo.branches.loading': 'Loading branches…',
174 'repo.branches.noMatch': 'No matching branches.',
175 'repo.branches.none': 'This repository has no branches yet.',
176 'repo.branches.limitNote': 'Showing the first {count} branches; filtering only applies to the loaded branches.',
177 'repo.tags.title': 'Tags',
178 'repo.tags.total': '{count} total',
179 'repo.tags.listName': 'Tag list',
180 'repo.tags.loading': 'Loading tags…',
181 'repo.tags.none': 'This repository has no tags yet.',
182 'repo.tags.filterPlaceholder': 'Filter tags…',
183 'repo.tag.switch': 'Switch tag',
184 'repo.tag.annotated': 'Annotated tag',
185 'repo.tag.lightweight': 'Lightweight tag',
186 'repo.tag.deletedName': 'Deleted tag “{name}”.',
187 'repo.tag.createdName': 'Created tag “{name}”.',
188 'repo.tag.createHint': 'An annotated tag records the tagger and a message; a lightweight tag only points at the revision.',
189 'repo.tag.createSubmit': 'Create tag',
190 'repo.tag.target': 'Target revision',
191 'repo.tag.targetHint': 'A branch name, commit sha or another tag.',
192 'repo.tag.message': 'Message',
193 'repo.tag.messageLabel': 'Message (annotated tags)',
194 'repo.tag.messagePlaceholder': 'Highlights of this release…',
195 'repo.tag.nameRequired': 'The tag name is required.',
196 'repo.tag.detailTitle': 'Tag details · {name}',
197 'repo.tag.type': 'Type',
198 'repo.tag.targetCommit': 'Points at commit',
199 'repo.tag.tagger': 'Tagger',
200 'repo.tag.createdAt': 'Created at',
201 'repo.submodules.title': 'Submodules',
202 'repo.submodules.resolve': 'Resolve repository',
203 'repo.submodules.unresolved': 'Cannot resolve (the submodule may not exist or may not be visible)',
204 'repo.submodules.loadFailed': 'Failed to load the submodule list.',
205 'repo.breadcrumb.label': 'Repository path',
206 'repo.breadcrumb.root': 'Repository root',
207 'repo.commit.history': 'Commit history',
208 'repo.commit.backToHistory': 'Back to commit history',
209 'repo.commit.browseRevision': 'Browse this revision',
210 'repo.commit.loading': 'Loading commit…',
211 'repo.commit.what': 'commit',
212 'repo.commit.missing': 'This commit does not exist or has been rewritten.',
213 'repo.commit.noMessage': '(no commit message)',
214 'repo.commit.unknownAuthor': 'Unknown author',
215 'repo.commit.committer': 'Committer',
216 'repo.commit.root': 'First commit (against the empty tree)',
217 'repo.commit.compareTo': 'Compared to {sha}',
218 'repo.commit.parentChip': 'Parent {sha}',
219 'repo.commit.parents': '{count} parent commits',
220 'repo.commit.parents_one': '{count} parent commit',
221 'repo.commit.parents_other': '{count} parent commits',
222 'repo.commit.latest': 'Latest commit',
223 'repo.commit.collapsedHint': 'Many changes — collapsed by default; click a file to expand it.',
224 'repo.commit.noFiles': 'This commit has no file changes (it may be empty or a merge commit).',
225 'repo.commit.status.added': 'Added',
226 'repo.commit.status.deleted': 'Deleted',
227 'repo.commit.status.renamed': 'Renamed',
228 'repo.commit.status.copied': 'Copied',
229 'repo.commit.status.modified': 'Modified',
230 'repo.commit.status.binary': 'Binary',
231 'repo.commit.filesChanged_one': '{count} file changed',
232 'repo.commit.filesChanged_other': '{count} files changed',
233 'repo.commits.total': '{count} total',
234 'repo.commits.viewAll': 'View all history',
235 'repo.commits.firstParent': 'First-parent only',
236 'repo.commits.loading': 'Loading commit history…',
237 'repo.commits.missing': 'This revision may have been deleted or rewritten.',
238 'repo.commits.none': 'This revision has no commits.',
239 'repo.commits.pageEmpty': 'This page is empty; go back to the previous page.',
240 'repo.diff.followAppearance': 'Currently following User settings · Appearance',
241 'repo.diff.unified': 'Unified',
242 'repo.diff.split': 'Split',
243 'repo.diff.binary': 'Binary file — no diff content is shown.',
244 'repo.diff.metaOnly': 'Only permission or metadata changes.',
245 'repo.diff.lineComments': '{count} comments on this line',
246 'repo.diff.lineComments_one': '{count} comment on this line',
247 'repo.diff.lineComments_other': '{count} comments on this line',
248 'repo.diff.commentOnLine': 'Comment on this line',
249 'repo.diff.truncated': 'The diff is long — truncated to the first {count} lines.',
250 'repo.pager.prev': 'Previous',
251 'repo.pager.next': 'Next',
252 'repo.pager.page': 'Page {current} / {pages}',
253 'repo.tree.loading': 'Loading directory…',
254 'repo.tree.what': 'directory',
255 'repo.tree.empty': 'This directory is empty.',
256 'repo.tree.tableLabel': 'Repository files',
257 'repo.tree.name': 'Name',
258 'repo.tree.size': 'Size',
259 'repo.tree.parent': 'Up to parent directory',
260 'repo.file.backToList': 'Back to file list',
261 'repo.file.loading': 'Loading file…',
262 'repo.file.what': 'file',
263 'repo.file.blameHint': 'Show the last commit that changed each line (blame)',
264 'repo.file.blameJsonHint': 'Available in JSON mode for private repositories',
265 'repo.file.missingOid': 'Missing file identifier; cannot preview in private-repository mode.',
266 'repo.file.binaryDetail': 'Binary file · {size} · Preview not supported',
267 'repo.file.truncated': 'The file is large; the server truncated the response.',
268 'repo.empty.startTitle': 'Start with the first commit',
269 'repo.empty.startNote': 'The repository is ready. Add your project files and let the idea take shape here.',
270 'repo.empty.addFiles': '# add files',
271 'repo.empty.cloneNotConfigured': 'No clone URL is configured yet. Once it is, the push instructions will appear here.',
272 'repo.blame.loading': 'Computing line attribution…',
273 'repo.blame.unsupported': 'Blame is not available for this file (binary or currently unreadable).',
274 'repo.blame.empty': 'The server returned no attribution data (the file may be empty or not present at this revision).',
275 'repo.blame.what': 'Blame',
276 'repo.blame.hint': 'Click the attribution on the left to open the commit.',
277 'repo.revision': 'Revision {rev}',
278 'repo.search.kind.code': 'Code',
279 'repo.search.kind.symbol': 'Symbol',
280 'repo.search.kind.issue': 'Issue',
281 'repo.search.kind.pull': 'PR',
282 'repo.search.failed': 'Search failed',
283 'repo.search.codePlaceholder': 'Search this repository for code or symbols…',
284 'repo.search.codeLabel': 'Search code in the current repository',
285 'repo.search.clear': 'Clear the repository search',
286 'repo.search.defaultBranchNote': 'Results come from the default branch {branch}',
287 'repo.search.searching': 'Searching…',
288 'repo.search.noHits': 'No matching code or symbols',
289 'repo.search.keyboardHint': '↑↓ select · Enter open · Esc close',
290 'repo.release.loading': 'Loading releases…',
291 'repo.release.what': 'releases',
292 'repo.release.backToList': 'Back to list',
293 'repo.release.redraftQueued': 'AI drafting job submitted; refresh when it finishes.',
294 'repo.release.savedDraft': 'Draft saved.',
295 'repo.release.publishedNow': 'Published.',
296 'repo.release.prereleaseUpdated': 'Pre-release flag updated.',
297 'repo.release.deletedNow': 'Release deleted.',
298 'repo.release.notFound': 'That release does not exist.',
299 'repo.release.created': 'Release created.',
300 'repo.release.none': 'No releases yet. Pushing a tag creates a draft automatically, or you can create one manually.',
301 'repo.release.publishedBadge': 'Published',
302 'repo.release.commitCount': '{count} commits',
303 'repo.release.commitCount_one': '{count} commit',
304 'repo.release.commitCount_other': '{count} commits',
305 'repo.release.system': 'System',
306 'repo.release.body': 'Body',
307 'repo.release.saveDraft': 'Save draft',
308 'repo.release.redraft': 'AI re-draft',
309 'repo.release.publish': 'Publish',
310 'repo.release.markPrerelease': 'Mark pre-release',
311 'repo.release.unmarkPrerelease': 'Unmark pre-release',
312 'repo.release.noBody': '(no content)',
313 'repo.release.publishedImmutable': 'A published release’s content can no longer be edited.',
314 'repo.release.new': 'New release',
315 'repo.release.pickTagNote': 'Pick an existing tag; the release will point at that tag’s commit.',
316 'repo.release.tagsFailed': 'Failed to load tags. Please try again later.',
317 'repo.release.noTags': 'No tags available. Create one under Branches & tags first, or push a new tag.',
318 'repo.release.selectTag': 'Select a tag…',
319 'repo.release.tagAge': '({age})',
320 'repo.release.titleOptional': 'Title (optional)',
321 'repo.release.saveAsDraft': 'Save as a draft first',
322 'repo.pipeline.heading': 'Pipelines',
323 'repo.pipeline.subtitle': 'Driven by definitions in .workflows/; runners pick up jobs through the scheduler.',
324 'repo.pipeline.state.pending': 'Waiting on dependencies',
325 'repo.pipeline.state.claimed': 'Claimed',
326 'repo.pipeline.state.timedOut': 'Timed out',
327 'repo.pipeline.state.cancelRequested': 'Cancelling',
328 'repo.pipeline.state.ready': 'Ready',
329 'repo.pipeline.state.uploading': 'Uploading',
330 'repo.pipeline.runsLoading': 'Loading runs…',
331 'repo.pipeline.runsEmpty': 'No runs yet. Push a commit containing .workflows/*.yml, or trigger one here.',
332 'repo.pipeline.triggerFailed': 'Failed to trigger',
333 'repo.pipeline.triggerTitle': 'Manually trigger workflow_dispatch',
334 'repo.pipeline.ref': 'Branch or tag',
335 'repo.pipeline.workflow': 'Workflow',
336 'repo.pipeline.workflowsFailed': 'Failed to load workflows',
337 'repo.pipeline.noDispatchable': 'No manually triggerable workflows at this revision (one must declare on.workflow_dispatch).',
338 'repo.pipeline.triggering': 'Triggering…',
339 'repo.pipeline.triggerConfirm': 'Trigger',
340 'repo.pipeline.detailLoading': 'Loading run details…',
341 'repo.pipeline.attempt': 'Attempt {count}',
342 'repo.pipeline.noLogs': '(no log output yet)',
343 'repo.ai.triggerSummaryFailed': 'Failed to trigger the AI summary',
344 'repo.ai.queued': 'Summary job submitted; click “Load results” once it finishes.',
345 'repo.ai.loading': 'Loading the AI summary…',
346 'repo.ai.what': 'AI summary',
347 'repo.ai.regenerate': 'Regenerate',
348 'repo.ai.generateSummary': 'Generate summary',
349 'repo.ai.loadResults': 'Load results',
350 'repo.ai.branch': 'Branch {branch}',
351 'repo.ai.emptyNote': 'No AI summary for this branch yet. Summaries are generated from recent commits and cached by head, so the same revision never calls the model twice.',
352 'repo.ai.version': 'Version {version}',
353 'repo.ai.commitCount': '{count} commits',
354 'repo.ai.commitCount_one': '{count} commit',
355 'repo.ai.commitCount_other': '{count} commits',
356 'repo.ai.currentBranch': 'Matches the current branch',
357 'repo.ai.stale': 'Based on an older revision',
358 'repo.ai.pending': 'A job is running',
359 'repo.ai.highlights': 'Highlights',
360 'repo.ai.risks': 'Risks',
361 'repo.ai.triggerReviewFailed': 'Failed to trigger the AI review',
362 'repo.ai.rerunReview': 'Re-review',
363 'repo.ai.noReview': 'This commit has no AI review yet.',
364 'repo.ai.verdictChangesRequested': 'Changes requested',
365 'repo.ai.verdictComment': 'Comment only',
366 'repo.ai.truncated': 'Content truncated',
367 'repo.ai.noSummary': '(no summary)',
368 'repo.ai.oldLine': 'old line',
369 'repo.comment.unknownUser': 'Unknown user',
370 'repo.comment.resolved': 'Resolved',
371 'repo.comment.outdated': 'Outdated',
372 'repo.comment.resolve': 'Resolve',
373 'repo.comment.unresolve': 'Unresolve',
374 'repo.comment.updateFailed': 'Failed to update the comment status',
375 'repo.comment.deleteFailed': 'Failed to delete the comment',
376 'repo.comment.delete': 'Delete comment',
377 'repo.comment.createFailed': 'Failed to post the comment',
378 'repo.comment.placeholder': 'Comment on this line…',
379 'repo.comment.submit': 'Post comment',
380 'repo.refs.selectBranch': 'Select branch',
381 'repo.refs.commit': '{sha} (commit)',
382 'repo.refs.current': 'Current revision {rev}; select a branch',
383 'repo.refs.branches': '{count} branches',
384 'repo.refs.branches_one': '{count} branch',
385 'repo.refs.branches_other': '{count} branches',
386 'repo.refs.tags': '{count} tags',
387 'repo.refs.tags_one': '{count} tag',
388 'repo.refs.tags_other': '{count} tags',
389 'repo.refs.commits': '{count} commits',
390 'repo.refs.commits_one': '{count} commit',
391 'repo.refs.commits_other': '{count} commits',
392 'repo.refs.searchBranches': 'Search branches',
393 'repo.refs.searchTags': 'Search tags',
394 'repo.refs.noMatch': 'No matches — try a different keyword.',
395 'repo.refs.noBranches': 'No branches yet',
396 'repo.refs.noTags': 'No tags yet',
397 'repo.aside.starFailed': 'Cannot update the star right now. Please try again later.',
398 'repo.aside.overview': 'Repository overview',
399 'repo.aside.noDescription': 'This repository’s story starts with its code.',
400 'repo.aside.website': 'Website',
401 'repo.aside.openWebsite': 'Open {url} in a new window',
402 'repo.aside.size': 'Repository size',
403 'repo.aside.license': 'License',
404 'repo.aside.starBusy': 'Updating…',
405 'repo.aside.starred': 'Starred · {count}',
406 'repo.aside.star': 'Star repository · {count}',
407 'repo.aside.languages': 'Languages',
408 'repo.aside.primaryLanguage': 'Primary language: {language}',
409 'repo.settings.nav.general': 'General',
410 'repo.settings.nav.topics': 'Topics',
411 'repo.settings.nav.remotes': 'Remotes',
412 'repo.settings.nav.import': 'Import',
413 'repo.settings.nav.archives': 'Archives & downloads',
414 'repo.settings.nav.insights': 'Insights & maintenance',
415 'repo.settings.nav.locks': 'LFS locks',
416 'repo.settings.nav.protection': 'Protection rules',
417 'repo.settings.nav.ci': 'CI/CD',
418 'repo.settings.navLabel': 'Repository settings sections',
419 'repo.settings.generalSub': 'Description, homepage, visibility, Git LFS and the default branch. Space owners only.',
420 'repo.settings.field.description': 'Description',
421 'repo.settings.field.website': 'Website',
422 'repo.settings.field.websiteHint': 'Public homepage; leave blank when unset.',
423 'repo.settings.field.defaultBranchLoadFailed': 'Failed to load branches; only the saved default branch is shown.',
424 'repo.settings.field.privateLabel': 'Private repository',
425 'repo.settings.field.private': 'Private (visible to members only)',
426 'repo.settings.field.enableLfs': 'Enable Git LFS',
427 'repo.settings.generalSaved': 'General settings saved.',
428 'repo.settings.saveSettings': 'Save settings',
429 'repo.settings.unsaved': 'Unsaved changes',
430 'repo.settings.readonlyInfo': 'Repository info (read-only)',
431 'repo.settings.fact.id': 'Repository ID',
432 'repo.settings.fact.objectFormat': 'Object format',
433 'repo.settings.fact.createdAt': 'Created',
434 'repo.settings.fact.updatedAt': 'Last updated',
435 'repo.settings.fact.lastPushed': 'Last push',
436 'repo.settings.fact.never': 'Never',
437 'repo.settings.fact.sizes': 'Git / LFS size',
438 'repo.settings.fact.storageLimit': 'Storage limit',
439 'repo.settings.fact.unlimited': 'Unlimited',
440 'repo.settings.fact.counts': 'Branches / tags / commits',
441 'repo.settings.fact.social': 'PRs / stars / watchers',
442 'repo.settings.fact.license': 'License',
443 'repo.settings.fact.licenseUnknown': 'Not detected',
444 'repo.settings.fact.primaryLanguage': 'Primary language',
445 'repo.settings.fact.languageUnknown': 'Not analyzed',
446 'repo.settings.topicAdded': 'Added topic “{name}”.',
447 'repo.settings.topicRemoved': 'Removed topic “{name}”.',
448 'repo.settings.topicPlaceholder': 'e.g. devops, cli-tool',
449 'repo.settings.topicWillSavePrefix': 'Will be saved as',
450 'repo.settings.topicsSub': 'Topics make a repository discoverable. Only lowercase letters, digits and hyphens are allowed; the server creates missing topics automatically.',
451 'repo.settings.topicsLoading': 'Loading topics…',
452 'repo.settings.topicsEmpty': 'This repository has no topics yet.',
453 'repo.protection.sub': 'Branch protection policies gate pushes, force pushes and merges; whitelisted members can bypass the matching restriction.',
454 'repo.protection.orderNote': 'Matching order: when several policies match a branch, the server picks the more specific pattern.',
455 'repo.protection.create': 'New branch protection policy',
456 'repo.protection.loading': 'Loading protection policies…',
457 'repo.protection.what': 'protection policies',
458 'repo.protection.empty': 'No protection rules yet. At least one is recommended for the default branch.',
459 'repo.protection.deleteConfirm': 'Delete the protection for “{pattern}”?',
460 'repo.protection.deleteLabel': 'Delete policy',
461 'repo.protection.doneSuffix': ' completed.',
462 'repo.protection.failedSuffix': ' failed: ',
463 'repo.protection.unknownError': 'unknown error',
464 'repo.protection.chip.approvals': 'Needs {count} approvals',
465 'repo.protection.chip.noApprovals': 'No approvals required',
466 'repo.protection.chip.restrictPush': 'Restrict pushes',
467 'repo.protection.chip.noForcePush': 'Block force pushes',
468 'repo.protection.chip.pushWhitelist': 'Push whitelist',
469 'repo.protection.chip.mergeWhitelist': 'Merge whitelist',
470 'repo.protection.chip.approvalWhitelist': 'Approval whitelist',
471 'repo.protection.chip.dismissStale': 'Dismiss stale approvals',
472 'repo.protection.chip.blockRejected': 'Block merges on rejected reviews',
473 'repo.protection.chip.blockChangeRequested': 'Block merges on change requests',
474 'repo.protection.chip.blockOutdated': 'Block merges when the branch is behind',
475 'repo.protection.chip.signedCommits': 'Require signed commits',
476 'repo.protection.chip.protectedFiles': 'Protected files: {patterns}',
477 'repo.protection.chip.unprotectedFiles': 'Unprotected files: {patterns}',
478 'repo.protection.edit': 'Edit policy',
479 'repo.protection.deleteTitle': 'Delete policy',
480 'repo.protection.hideWhitelist': 'Hide whitelist',
481 'repo.protection.manageWhitelist': 'Manage whitelist',
482 'repo.protection.kind.push': 'Push',
483 'repo.protection.kind.merge': 'Merge',
484 'repo.protection.kind.approval': 'Approval bypass',
485 'repo.protection.addFailed': 'Failed to add',
486 'repo.protection.removeFailed': 'Failed to remove',
487 'repo.protection.selectUser': 'Select a user…',
488 'repo.protection.userOption': '{name} (@{username})',
489 'repo.protection.groupLabel': '(group)',
490 'repo.protection.whitelistEmpty': 'This policy has no whitelisted members.',
491 'repo.protection.whitelistDisabledNote': 'The matching whitelist switch is off in this policy; entries will have no effect.',
492 'repo.protection.tagWhitelistTitle': 'Tag protection whitelist',
493 'repo.protection.tagWhitelistNote': 'The backend does not yet expose list/create endpoints for tag protection policies; manage the user whitelist by policy UUID here.',
494 'repo.protection.tagPolicyIdPlaceholder': 'Tag protection policy UUID',
495 'repo.protection.branchRequired': 'The branch pattern is required.',
496 'repo.protection.updated': 'Policy updated.',
497 'repo.protection.created': 'Policy created.',
498 'repo.protection.editTitle': 'Edit protection policy',
499 'repo.protection.formDesc': 'Branch patterns support globs (for example main, release/*, *).',
500 'repo.protection.branchPattern': 'Branch pattern',
501 'repo.protection.requiredApprovals': 'Required approvals (0–10)',
502 'repo.protection.field.enablePush': 'Restrict pushes (allowed by default)',
503 'repo.protection.field.enableForcePush': 'Block force pushes',
504 'repo.protection.field.enablePushWhitelist': 'Enable the push whitelist',
505 'repo.protection.field.enableMergeWhitelist': 'Enable the merge whitelist',
506 'repo.protection.field.enableApprovalsWhitelist': 'Enable the approval whitelist',
507 'repo.protection.field.blockOnRejected': 'Block merges on rejected reviews',
508 'repo.protection.field.blockOnChangeRequested': 'Block merges on change-requested reviews',
509 'repo.protection.field.blockOnOutdated': 'Block merges when the source branch is behind',
510 'repo.protection.field.dismissStale': 'Dismiss stale approvals after new commits',
511 'repo.protection.field.requireSigned': 'Require signed commits',
512 'repo.protection.protectedPatterns': 'Protected file patterns (comma-separated globs, optional)',
513 'repo.protection.unprotectedPatterns': 'Unprotected file patterns (comma-separated globs, optional)',
514 'repo.locks.sub': 'Lock large-file paths to avoid concurrent overwrites. Locks held by others need an administrator to force-release.',
515 'repo.locks.disabledNote': 'Git LFS is not enabled, so the server rejects every lock request. Enable it to create and release locks.',
516 'repo.locks.enabled': 'Git LFS enabled — you can now create locks.',
517 'repo.locks.summary': 'You hold {ours} locks; others hold {theirs}.',
518 'repo.locks.pathPlaceholder': 'Path to lock, e.g. assets/video.mp4',
519 'repo.locks.pickFile': 'Choose file',
520 'repo.locks.submitting': 'Submitting…',
521 'repo.locks.create': 'New lock',
522 'repo.locks.unlocked': 'Released {path} (the request fails when it was not locked).',
523 'repo.locks.pathHint': 'Paths are relative to the repository root; press Enter or click “New lock” to submit. Locks are unique per path.',
524 'repo.locks.loading': 'Loading locks…',
525 'repo.locks.empty': 'No LFS locks right now.',
526 'repo.locks.locked': 'Locked {path}.',
527 'repo.locks.released': 'Released {path}.',
528 'repo.locks.forceReleased': 'Force-released {path}.',
529 'repo.locks.confirmRelease': 'Confirm release',
530 'repo.locks.forceRelease': 'Force release',
531 'repo.locks.release': 'Release',
532 'repo.locks.releasePath': 'Release this path',
533 'repo.locks.loadMore': 'Load more',
534 'repo.locks.firstPage': 'Back to the first page',
535 'repo.locks.treeFailed': 'Failed to read the repository tree: {reason} You can also type the path directly.',
536 'repo.locks.parentDir': 'Parent directory',
537 'repo.locks.validate.required': 'Specify the file path to lock first (relative to the repository root).',
538 'repo.locks.validate.nul': 'The path cannot contain null characters.',
539 'repo.locks.validate.tooLong': 'A path can be at most {count} characters.',
540 'repo.locks.validate.trailingSlash': 'Locks target a single file, so the path must not end with a slash.',
541 'repo.locks.validate.dotdot': 'The path cannot contain a .. segment.',
542 'repo.locks.validate.empty': 'The path cannot be empty.',
543 'repo.locks.validate.tooLongDetail': 'The path is too long: {detail}.',
544 'repo.locks.validate.badOid': 'The oid must be `sha256:<64 hex digits>`.',
545 'repo.locks.explanation.401': 'Your session has expired; sign in again and retry.',
546 'repo.locks.explanation.403': 'Your account cannot write to this repository (Write/Admin or a space owner is required).',
547 'repo.locks.explanation.404': 'The repository does not exist, or Git LFS is not enabled. Locks require it.',
548 'repo.locks.explanation.409': 'This path already has a lock, or conflicts with another lock.',
549 'repo.locks.explanation.500': 'The backend failed; retry later or try another path.',
550 'repo.locks.rejected': 'The server rejected this path: {detail}',
551 'repo.locks.invalid': 'The submitted value failed server validation.',
552 'repo.locks.explainedWithDetail': '{explanation} ({detail})',
553 'repo.locks.failedWithCode': 'The operation failed ({code}).',
554 'repo.archives.formatTar': 'tar (uncompressed)',
555 'repo.archives.archivedNote': 'The repository is archived; writes will be rejected.',
556 'repo.archives.restored': 'The repository is writable again.',
557 'repo.archives.sub': 'Archive state, on-demand snapshot archives and full-reference bundle packaging.',
558 'repo.archives.status': 'Archive state: {state}',
559 'repo.archives.statusArchived': 'Archived (read-only)',
560 'repo.archives.statusWritable': 'Active (writable)',
561 'repo.archives.archiveNote': 'Once archived, the repository is no longer a regular write target and pushes are rejected; you can restore it at any time.',
562 'repo.archives.restore': 'Restore repository',
563 'repo.archives.confirmArchive': 'Confirm archive',
564 'repo.archives.archive': 'Archive repository',
565 'repo.archives.snapshotTitle': 'Create a snapshot archive',
566 'repo.archives.revision': 'Revision',
567 'repo.archives.format': 'Format',
568 'repo.archives.pathPrefix': 'Path prefix (optional)',
569 'repo.archives.pathPrefixHint': 'Package only the contents of this directory, e.g. app/.',
570 'repo.archives.created': 'Archive created.',
571 'repo.archives.createAndLink': 'Create and get the download link',
572 'repo.archives.reuseHint': 'Do not repackage; just issue a fresh short-lived link for an existing archive. It fails when that revision has no archive yet — use “Create and get the download link” instead.',
573 'repo.archives.linkRefreshed': 'A fresh download link was issued.',
574 'repo.archives.reuse': 'Reuse an existing archive',
575 'repo.archives.bundleLabel': 'git bundle (all refs)',
576 'repo.archives.bundleCreated': 'Bundle packaged.',
577 'repo.archives.bundle': 'Package all refs as a bundle',
578 'repo.archives.factContent': 'Content',
579 'repo.archives.factSize': 'Size',
580 'repo.archives.openDownload': 'Download in a new tab',
581 'repo.archives.linkNote': 'Links are short-lived; click “Reuse an existing archive” to get a new one.',
582 'repo.ci.secretSaved': 'Secret saved',
583 'repo.ci.secretDeleted': 'Secret deleted',
584 'repo.ci.deleteFailed': 'Failed to delete',
585 'repo.ci.sub': 'Workflow definitions come from the .workflows/ directory of the default branch (or the current revision); runners pick up jobs through the scheduler.',
586 'repo.ci.workflows': 'Workflows',
587 'repo.ci.workflowsLoading': 'Reading .workflows/…',
588 'repo.ci.workflowsEmpty': 'This revision has no .workflows/*.yml definitions.',
589 'repo.ci.valid': 'Valid',
590 'repo.ci.invalid': 'Parse failed',
591 'repo.ci.dispatchable': 'Manually triggerable',
592 'repo.ci.secretsTitle': 'Repository secrets',
593 'repo.ci.secretsFailed': 'Failed to load secrets',
594 'repo.ci.updatedAt': 'Updated {time}',
595 'repo.ci.secretsEmpty': 'No secrets configured yet.',
596 'repo.ci.upsertSecret': 'Add / overwrite a secret',
597 'repo.ci.upsertSecretHint': 'The value is written to the database only (AES-GCM encrypted) and never read back.',
598 'repo.ci.secretNamePlaceholder': 'Name (letters, digits, _, -)',
599 'repo.ci.value': 'Value',
600 'repo.ci.secretValuePlaceholder': 'Secret value',
601 'repo.ci.saveSecret': 'Save secret',
602 'repo.import.state.processing': 'In progress',
603 'repo.import.state.succeeded': 'Completed',
604 'repo.import.stage.probe': 'Probing the source repository',
605 'repo.import.stage.clone': 'Mirroring refs',
606 'repo.import.stage.sync': 'Syncing branches and commits',
607 'repo.import.stage.finalize': 'Finalizing stats and authorship',
608 'repo.import.stage.done': 'Done',
609 'repo.import.sub': 'Import jobs that mirrored this repository from GitHub / GitLab / Gitea. Start a new import from the community menu’s “Import repository”.',
610 'repo.import.empty': 'This repository has no import jobs.',
611 'repo.import.attempts': '· {count} attempts',
612 'repo.import.stats': 'Branches {branches} · tags {tags} · commits {commits} · size {size}',
613 'repo.import.statsDefault': '· default branch {branch}',
614 'repo.import.canceled': 'Import job cancelled.',
615 'repo.import.confirmCancel': 'Confirm cancel',
616 'repo.import.requeued': 'Import job requeued.',
617 'repo.import.token': 'Retry token',
618 'repo.import.tokenHint': 'The server clears the token once a job finishes; private-repository retries must supply it again.',
619 'repo.import.tokenPlaceholder': 'Leave blank to retry anonymously',
620 'repo.remotes.sub': 'Git remotes configured for this repository. URLs must use https://, ssh:// or git:// and must not point at localhost or private addresses.',
621 'repo.remotes.loading': 'Loading remotes…',
622 'repo.remotes.empty': 'No remotes configured yet.',
623 'repo.remotes.fetchResult': 'Fetch: {added} new refs, {updated} updated.',
624 'repo.remotes.fetched': 'Fetched from “{name}”.',
625 'repo.remotes.fetch': 'Fetch',
626 'repo.remotes.pushResult': 'Push: {pushed} succeeded, {rejected} rejected.',
627 'repo.remotes.pushed': 'Pushed to “{name}”.',
628 'repo.remotes.push': 'Push',
629 'repo.remotes.deleted': 'Deleted remote “{name}”.',
630 'repo.remotes.upsertTitle': 'Add or update a remote',
631 'repo.remotes.name': 'Name',
632 'repo.remotes.nameHint': 'Cannot start with a dash, cannot contain spaces, at most 255 characters.',
633 'repo.remotes.url': 'URL',
634 'repo.remotes.tokenPlaceholder': 'Token (optional, this request only)',
635 'repo.remotes.refspecPlaceholder': 'refspec (for pushes; blank uses the default)',
636 'repo.remotes.saved': 'Saved remote “{name}”.',
637 'repo.remotes.save': 'Save remote',
638 'repo.remotes.mirrorResult': 'Mirror import: {commits} commits, default branch {branch}.',
639 'repo.remotes.mirrorDone': 'Mirror import complete.',
640 'repo.remotes.mirrorHint': 'Import every ref of an external repository into this one in mirror mode',
641 'repo.remotes.mirror': 'Mirror-import this URL',
642 'repo.remotes.note': 'The token is used for this request only and is never stored; a mirror import overwrites refs with the same name, so double-check the URL. To create and migrate a repository from GitHub / GitLab / Gitea, use the community menu’s “Import repository”; job progress shows under Settings → Import.',
643 'repo.insights.sub': 'Language stats, contributor rankings and commit activity. Stats are a persisted cache and need a rescan after changes.',
644 'repo.insights.rescanned': 'Language stats rescanned.',
645 'repo.insights.scanning': 'Scanning…',
646 'repo.insights.rescan': 'Rescan languages',
647 'repo.insights.rescanNote': 'The scan walks the directory tree of the current revision and takes longer for large repositories.',
648 'repo.insights.languages': 'Languages ({count})',
649 'repo.insights.languagesEmpty': 'No language stats yet — click “Rescan languages” above.',
650 'repo.insights.primary': 'Primary',
651 'repo.insights.total': 'Total {size}',
652 'repo.insights.contributors': 'Contributors ({count})',
653 'repo.insights.contributorsEmpty': 'No contributor stats yet.',
654 'repo.insights.commits': '{count} commits',
655 'repo.insights.commits_one': '{count} commit',
656 'repo.insights.commits_other': '{count} commits',
657 'repo.insights.activity': 'Commit activity',
658 'repo.insights.branchPlaceholder': 'Branch; blank uses the default',
659 'repo.insights.activityTotal': 'Total {count} commits',
660 'repo.insights.activityEmpty': 'No activity stats for this branch.',
661 'repo.release.titleField': 'Title',
662 'repo.branch.updatedAt': 'Updated {time}',
663} as const
Added web/src/shared/i18n/messages/en/settings.ts View web/src/shared/i18n/messages/en/settings.ts
@@ -0,0 +1,128 @@
1/** User settings modal: section list, form labels, notices and errors. */
2export const settings = {
3 'settings.title': 'User settings',
4 'settings.section.account': 'My account',
5 'settings.section.profile': 'Public profile',
6 'settings.section.security': 'Password & security',
7 'settings.section.sessions': 'Devices & sessions',
8 'settings.section.emails': 'Email addresses',
9 'settings.section.privacy': 'Privacy',
10 'settings.section.notifications': 'Notifications',
11 'settings.section.appearance': 'Appearance',
12 'settings.section.keys': 'Access keys',
13 'settings.section.sshKeys': 'SSH keys',
14 'settings.section.status': 'Account status',
15 'settings.account.title': 'My account',
16 'settings.account.sub': 'Account identity, language region and destructive actions.',
17 'settings.account.uploadAvatar': 'Upload avatar',
18 'settings.account.removeAvatar': 'Remove avatar',
19 'settings.account.avatarUpdated': 'Avatar updated.',
20 'settings.account.avatarRemoved': 'Avatar removed.',
21 'settings.account.displayName': 'Display name',
22 'settings.account.username': 'Username',
23 'settings.account.usernameHint': 'Lowercase letters, digits and underscores; takes effect immediately.',
24 'settings.account.language': 'Language',
25 'settings.account.locale': 'Region',
26 'settings.account.timezone': 'Time zone',
27 'settings.account.saved': 'Account details saved.',
28 'settings.account.userId': 'User ID',
29 'settings.account.createdAt': 'Joined',
30 'settings.account.status': 'Status',
31 'settings.account.active': 'Active',
32 'settings.account.inactive': 'Deactivated',
33 'settings.account.restricted': 'Restricted',
34 'settings.account.danger': 'Danger zone',
35 'settings.account.revokeNote': 'Sign out every session except this one.',
36 'settings.account.revokeAll': 'Sign out all sessions',
37 'settings.account.revoked': 'All other sessions signed out.',
38 'settings.error.generic': 'That action failed. Please try again.',
39 'settings.profile.title': 'Public profile',
40 'settings.profile.sub': 'What other people see on your profile page.',
41 'settings.profile.realName': 'Real name',
42 'settings.profile.bio': 'Bio',
43 'settings.profile.company': 'Company',
44 'settings.profile.location': 'Location',
45 'settings.profile.website': 'Website',
46 'settings.profile.pronouns': 'Pronouns',
47 'settings.profile.dateOfBirth': 'Date of birth',
48 'settings.profile.showEmail': 'Show email publicly',
49 'settings.profile.showLocation': 'Show location publicly',
50 'settings.profile.showRealName': 'Show real name publicly',
51 'settings.profile.showActivity': 'Show activity publicly', 'settings.profile.saved': 'Public profile saved.',
52 'settings.security.title': 'Password & security',
53 'settings.security.sub': 'Password, two-factor authentication and recovery codes.',
54 'settings.security.currentPassword': 'Current password',
55 'settings.security.newPassword': 'New password',
56 'settings.security.confirmPassword': 'Confirm new password',
57 'settings.security.changePassword': 'Change password',
58 'settings.security.passwordChanged': 'Password changed.',
59 'settings.security.totp': 'Two-factor authentication',
60 'settings.security.totpEnabled': 'Two-factor authentication enabled.',
61 'settings.security.totpDisabled': 'Two-factor authentication disabled.',
62 'settings.security.enableTotp': 'Enable two-factor',
63 'settings.security.disableTotp': 'Disable two-factor',
64 'settings.security.recoveryCodes': 'Recovery codes',
65 'settings.security.regenerateCodes': 'Regenerate recovery codes',
66 'settings.security.codesRegenerated': 'Recovery codes regenerated.',
67 'settings.security.scanQr': 'Scan the QR code with your authenticator app, then enter the 6-digit code.',
68 'settings.security.verifyCode': 'Verification code',
69 'settings.privacy.title': 'Privacy',
70 'settings.privacy.sub': 'Direct-message and mention permissions for your account, across all communities.',
71 'settings.privacy.allowDms': 'Allow direct messages',
72 'settings.privacy.allowMentions': 'Allow mentions',
73 'settings.privacy.showOnline': 'Show online status',
74 'settings.privacy.saved': 'Privacy settings saved.',
75 'settings.notifications.title': 'Notifications',
76 'settings.notifications.sub': 'Choose which events reach you in-app, by email and by push.',
77 'settings.notifications.inApp': 'In-app notifications',
78 'settings.notifications.email': 'Email notifications',
79 'settings.notifications.push': 'Push notifications',
80 'settings.notifications.digest': 'Email digest',
81 'settings.notifications.saved': 'Notification settings saved.',
82 'settings.appearance.title': 'Appearance',
83 'settings.appearance.sub': 'Palette, theme mode and how code differences are displayed.',
84 'settings.appearance.themeMode': 'Theme mode',
85 'settings.appearance.themeSystem': 'Follow system',
86 'settings.appearance.themeLight': 'Light',
87 'settings.appearance.themeDark': 'Dark',
88 'settings.appearance.diffStyle': 'Diff display',
89 'settings.appearance.diffDefault': 'Default (follow server setting)',
90 'settings.appearance.diffDefaultNote': 'Leaves diff_view_style unset; the server decides',
91 'settings.appearance.diffUnified': 'Unified · additions and deletions in one column',
92 'settings.appearance.diffUnifiedNote': 'Stores the value unified',
93 'settings.appearance.diffSplit': 'Split · deletions left, additions right',
94 'settings.appearance.diffSplitNote': 'Stores the value split',
95 'settings.appearance.diffHintSuffix': 'the server only recognises split; any other value renders unified',
96 'userSettings.appearance.themeSystem': 'Follow system',
97 'settings.appearance.saved': 'Appearance saved.',
98 'settings.keys.title': 'Access keys',
99 'settings.keys.sub': 'Personal access tokens for Git and the API.',
100 'settings.keys.create': 'New access key',
101 'settings.keys.name': 'Name',
102 'settings.keys.created': 'Access key created — copy it now.',
103 'settings.keys.revoked': 'Access key revoked.',
104 'settings.keys.empty': 'No access keys yet.',
105 'settings.ssh.title': 'SSH keys',
106 'settings.ssh.sub': 'Public keys used to push over SSH.',
107 'settings.ssh.add': 'Add SSH key',
108 'settings.ssh.key': 'Public key',
109 'settings.ssh.added': 'SSH key added.',
110 'settings.ssh.removed': 'SSH key removed.',
111 'settings.ssh.empty': 'No SSH keys yet.',
112 'settings.sessions.title': 'Devices & sessions',
113 'settings.sessions.sub': 'Every signed-in session for this account.',
114 'settings.sessions.revoke': 'Revoke',
115 'settings.sessions.revoked': 'Session revoked.',
116 'settings.sessions.empty': 'No other active sessions.',
117 'settings.emails.title': 'Email addresses',
118 'settings.emails.sub': 'Addresses used for sign-in and notifications.',
119 'settings.emails.add': 'Add email',
120 'settings.emails.primary': 'Primary',
121 'settings.emails.setPrimary': 'Set as primary',
122 'settings.emails.verified': 'Verified',
123 'settings.emails.unverified': 'Unverified',
124 'settings.emails.removed': 'Email removed.',
125 'settings.status.title': 'Account status',
126 'settings.status.sub': 'Restrictions, suspensions and deletion requests.',
127 'settings.status.empty': 'Your account is in good standing, with no active restrictions.',
128} as const
Added web/src/shared/i18n/messages/en/shell.ts View web/src/shared/i18n/messages/en/shell.ts
@@ -0,0 +1,94 @@
1/** Workspace frame: rail, sidebar, route titles and account menu. */
2export const shell = {
3 'shell.rail.personal': 'Personal',
4 'shell.rail.createSpace': 'New space',
5 'shell.nav.chat': 'Chat',
6 'shell.nav.board': 'Issues',
7 'shell.nav.repo': 'Repositories',
8 'shell.nav.notifications': 'Notifications',
9 'shell.nav.settings': 'Settings',
10 'shell.nav.backToWorkspace': 'Back to workspace',
11 'shell.sidebar.rooms': 'Channels',
12 'shell.sidebar.directMessages': 'Direct messages',
13 'shell.sidebar.repositories': 'Repositories',
14 'shell.sidebar.boards': 'Issue boards',
15 'shell.sidebar.searchRooms': 'Search channels',
16 'shell.sidebar.emptyRooms': 'No channels yet',
17 'shell.sidebar.emptyRoomsNote': 'Create a channel to start collaborating.',
18 'shell.sidebar.emptyRepos': 'No repositories yet',
19 'shell.sidebar.emptyBoards': 'No issue boards yet',
20 'shell.sidebar.newRoom': 'New channel',
21 'shell.account.menu': 'Account menu',
22 'shell.account.profile': 'Public profile',
23 'shell.account.settings': 'User settings',
24 'shell.account.logout': 'Sign out',
25 'shell.account.logoutFailed': 'Sign-out failed. Please try again.',
26 'shell.search.placeholder': 'Search repositories, channels, issues…',
27 'shell.search.empty': 'No matches',
28 'shell.search.hint': 'Type a keyword to search',
29 'shell.title': 'GitDataAI',
30 'shell.titleSpace': '{space} · GitDataAI',
31 'shell.titleRepo': '{repo} · {space} · GitDataAI',
32 'shell.titleBoard': '{board} · {space} · GitDataAI',
33 'shell.titleChat': '{room} · {space} · GitDataAI',
34 'shell.board.title': 'Issue boards',
35 'shell.board.pick': 'Pick an issue board from the left.',
36 'shell.board.notFound': 'That issue board does not exist or has been deleted',
37 'shell.board.forbidden': 'You do not have permission to view this issue board',
38 'shell.board.loadFailed': 'Could not load the issue board',
39 'shell.board.deletedHint': 'The board may have been deleted, or your account cannot read it.',
40 'shell.board.return': 'Back to communities',
41 'shell.board.newIssue': 'New issue',
42 'shell.board.colPending': 'Todo',
43 'shell.board.colInProgress': 'In progress',
44 'shell.board.colReview': 'In review',
45 'shell.board.colDone': 'Done', 'shell.repo.overview': 'Repository overview',
46 'shell.repo.tabs.code': 'Code',
47 'shell.repo.tabs.commits': 'Commits',
48 'shell.repo.tabs.pulls': 'Pull requests',
49 'shell.repo.tabs.branches': 'Branches',
50 'shell.repo.tabs.pipelines': 'Pipelines',
51 'shell.repo.tabs.releases': 'Releases',
52 'shell.repo.tabs.ai': 'AI summary',
53 'shell.repo.tabs.settings': 'Settings',
54 'shell.repo.loading': 'Loading repository…',
55 'shell.repo.notFound': 'The repository does not exist or you cannot access it',
56 'shell.repo.loadFailed': 'Could not load the repository',
57 'shell.repo.archived': 'This repository is archived (read-only). Restore it from Settings.',
58 'shell.repo.cloneUnconfigured': 'The backend has no [git].base_url / http_clone_base_url, so no clone URL can be generated',
59 'shell.repo.cloneUnconfiguredShort': 'Clone URL not configured',
60 'shell.repo.sshHint': 'To clone over SSH, register a key in User settings → SSH keys first',
61 'shell.repo.cloneCopied': 'Clone URL copied',
62 'shell.repo.copyClone': 'Copy clone URL',
63 'shell.repo.copyFailed': 'Copy failed — select the URL on the left and copy it manually',
64 'shell.repo.copyManually': 'Copy manually',
65 'shell.repo.clipboardOk': 'Clone URL copied to the clipboard',
66 'shell.repo.clipboardFail': 'Copy failed — select the clone URL and copy it manually',
67 'shell.repo.starFailed': 'Could not update the star right now. Please retry.',
68 'shell.repo.descriptionFallback': 'This repository\'s story starts with the code.',
69 'shell.repo.website': 'Website',
70 'shell.repo.openWebsite': 'Open {url} in a new window',
71 'shell.repo.size': 'Repository size',
72 'shell.repo.license': 'License',
73 'shell.repo.statBranches': 'Branches',
74 'shell.repo.statTags': 'Tags',
75 'shell.repo.statCommits': 'Commits',
76 'shell.repo.starUpdating': 'Updating…',
77 'shell.repo.starred': 'Starred · {count}',
78 'shell.repo.star': 'Star repository · {count}',
79 'shell.repo.languages': 'Languages',
80 'shell.repo.primaryLanguage': 'Primary language: {language}',
81 'shell.rail.home': 'Home',
82 'shell.rail.addSpace': 'Add a space',
83 'shell.rail.discover': 'Discover',
84 'shell.pending.title': 'Start collaborating here',
85 'shell.pending.body': '{label} — keep the conversation going, share an idea, or catch up on what the team shipped.',
86 'shell.pending.hint': 'Pick something in the workspace navigation',
87 'shell.pending.pickChannel': 'Pick a channel on the left',
88 'shell.user.settings': 'User settings',
89 'shell.chat.unknownUser': 'Unknown user',
90 'shell.chat.me': 'Me',
91 'shell.chat.userFallback': 'User {id}',
92 'shell.chat.dm': 'Direct message',
93 'shell.chat.channel': 'Channel', 'shell.titleNotifications': 'Notifications · GitDataAI',
94} as const
Added web/src/shared/i18n/messages/en/space.ts View web/src/shared/i18n/messages/en/space.ts

Diff too large to display.

Added web/src/shared/i18n/messages/en/ui.ts View web/src/shared/i18n/messages/en/ui.ts

Diff too large to display.

Added web/src/shared/i18n/messages/en/userSettings.ts View web/src/shared/i18n/messages/en/userSettings.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/auth.ts View web/src/shared/i18n/messages/zh-CN/auth.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/chat.ts View web/src/shared/i18n/messages/zh-CN/chat.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/chatComposer.ts View web/src/shared/i18n/messages/zh-CN/chatComposer.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/common.ts View web/src/shared/i18n/messages/zh-CN/common.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/errors.ts View web/src/shared/i18n/messages/zh-CN/errors.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/index.ts View web/src/shared/i18n/messages/zh-CN/index.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/issues.ts View web/src/shared/i18n/messages/zh-CN/issues.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/labels.ts View web/src/shared/i18n/messages/zh-CN/labels.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/misc.ts View web/src/shared/i18n/messages/zh-CN/misc.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/nav.ts View web/src/shared/i18n/messages/zh-CN/nav.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/repo.ts View web/src/shared/i18n/messages/zh-CN/repo.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/settings.ts View web/src/shared/i18n/messages/zh-CN/settings.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/shell.ts View web/src/shared/i18n/messages/zh-CN/shell.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/space.ts View web/src/shared/i18n/messages/zh-CN/space.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/ui.ts View web/src/shared/i18n/messages/zh-CN/ui.ts

Diff too large to display.

Added web/src/shared/i18n/messages/zh-CN/userSettings.ts View web/src/shared/i18n/messages/zh-CN/userSettings.ts

Diff too large to display.

Added web/src/shared/i18n/format.ts View web/src/shared/i18n/format.ts
@@ -0,0 +1,86 @@
1import { getLocale, type Locale } from './locale'
2
3/**
4 * Locale-aware display formatters. Every date/number the UI shows goes through
5 * here so that switching the interface language also switches the conventions
6 * (Y/M/D vs D/M/Y, 24h clock, relative-time wording, KiB grouping).
7 *
8 * The `null`/`undefined`/invalid paths keep returning the em dash used
9 * throughout the tables, so callers never need their own placeholder branch.
10 */
11
12export const DASH = '\u2014'
13
14/** Rust's `time` crate emits `… +00:00:00`; `Date` rejects the seconds offset. */
15export function parseDateValue(value: string): Date {
16 return new Date(value.replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2'))
17}
18
19export function formatBytes(value: number | null | undefined): string {
20 if (value === null || value === undefined || !Number.isFinite(value)) return DASH
21 const units = ['B', 'KB', 'MB', 'GB', 'TB']
22 let size = value
23 let unit = 0
24 while (size >= 1024 && unit < units.length - 1) {
25 size /= 1024
26 unit += 1
27 }
28 const formatted =
29 unit === 0
30 ? new Intl.NumberFormat(getLocale()).format(size)
31 : new Intl.NumberFormat(getLocale(), { maximumFractionDigits: 1 }).format(size)
32 return `${formatted} ${units[unit]}`
33}
34
35export function shortSha(sha: string | null | undefined, len = 7): string {
36 return sha ? sha.slice(0, len) : DASH
37}
38
39export function formatDateTime(value: string | number | null | undefined): string {
40 if (value === null || value === undefined) return DASH
41 const date = typeof value === 'number' ? new Date(value * 1000) : parseDateValue(value)
42 if (Number.isNaN(date.getTime())) return typeof value === 'number' ? DASH : value
43 return new Intl.DateTimeFormat(getLocale(), {
44 dateStyle: 'medium',
45 timeStyle: 'short',
46 }).format(date)
47}
48
49/** Compact relative stamp for commit and comment rows. */
50export function formatRelative(value: number | string | null | undefined): string {
51 const epoch =
52 typeof value === 'number'
53 ? value
54 : value
55 ? Math.floor(parseDateValue(value).getTime() / 1000)
56 : Number.NaN
57 if (!Number.isFinite(epoch)) return DASH
58 const delta = Math.floor(Date.now() / 1000) - epoch
59 // Future-dated commits (clock skew, rebased author time) read as "just now".
60 if (delta < 60) return relativeUnit(0, 'second')
61 const minutes = Math.floor(delta / 60)
62 if (minutes < 60) return relativeUnit(-minutes, 'minute')
63 const hours = Math.floor(minutes / 60)
64 if (hours < 24) return relativeUnit(-hours, 'hour')
65 const days = Math.floor(hours / 24)
66 if (days < 31) return relativeUnit(-days, 'day')
67 const months = Math.floor(days / 30)
68 if (months < 12) return relativeUnit(-months, 'month')
69 return relativeUnit(-Math.floor(days / 365), 'year')
70}
71
72function relativeUnit(amount: number, unit: Intl.RelativeTimeFormatUnit): string {
73 return new Intl.RelativeTimeFormat(getLocale(), { numeric: 'auto' }).format(amount, unit)
74}
75
76/** Medium date (no time) for settings rows and metadata blocks. */
77export function formatDate(value: string | null | undefined, locale?: Locale): string {
78 if (!value) return DASH
79 const date = parseDateValue(value)
80 if (Number.isNaN(date.getTime())) return value
81 return new Intl.DateTimeFormat(locale ?? getLocale(), {
82 year: 'numeric',
83 month: '2-digit',
84 day: '2-digit',
85 }).format(date)
86}
Added web/src/shared/i18n/index.ts View web/src/shared/i18n/index.ts
@@ -0,0 +1,25 @@
1// Public API of the shared/i18n segment.
2// Consumers import from '@/shared/i18n', never from deeper files.
3export {
4 LOCALES,
5 DEFAULT_LOCALE,
6 bootLocale,
7 applyAccountLocale,
8 getLocale,
9 setLocale,
10 localeLabel,
11 normalizeLocale,
12 useLocale,
13 type Locale,
14} from './locale'
15export { t, tCount, translate, translateCount, type MessageKey, type MessageParams } from './translate'
16export { useT, type I18n, type TranslateFn, type TranslateCountFn } from './use-t'
17export {
18 DASH,
19 parseDateValue,
20 formatBytes,
21 formatDate,
22 formatDateTime,
23 formatRelative,
24 shortSha,
25} from './format'
Added web/src/shared/i18n/locale.ts View web/src/shared/i18n/locale.ts
@@ -0,0 +1,142 @@
1import { useSyncExternalStore } from 'react'
2
3/**
4 * UI locale store: a module-level singleton exactly like `shared/lib/theme`, so
5 * the boot sequence, plain modules (formatters, `meta.ts` label tables) and
6 * React components all read the same value without a provider in scope.
7 *
8 * Resolution order — account preference, then the device choice, then the
9 * browser, then the reference locale:
10 *
11 * 1. `users.language` (written by the settings modal through `PATCH /me`)
12 * 2. localStorage, the explicit choice made on this device
13 * 3. `navigator.languages`
14 * 4. zh-CN, the reference dictionary every key is authored against
15 *
16 * Only the locales shipped in `messages/` are accepted. A tag such as `ja`
17 * or `zh-Hant` normalizes to `null` and simply loses its turn in the chain, so
18 * unsupported account preferences never blank the interface.
19 */
20
21/** UI locales shipped with this build. */
22export type Locale = 'zh-CN' | 'en'
23
24export const LOCALES: readonly Locale[] = ['zh-CN', 'en']
25export const DEFAULT_LOCALE: Locale = 'zh-CN'
26
27const STORAGE_KEY = 'gitdata-locale'
28
29let accountLocale: Locale | null = null
30let current: Locale = DEFAULT_LOCALE
31const listeners = new Set<() => void>()
32
33/**
34 * Fold a BCP-47 or POSIX tag onto a shipped locale.
35 *
36 * `zh_Hans`, `zh-Hans-CN`, `zh-CN`, `zh` and `en-US` all collapse; `zh-Hant`,
37 * `zh-TW`, `zh-HK` and `zh-MO` deliberately do **not** — rendering Simplified
38 * copy for a Traditional reader is worse than falling back to English.
39 */
40export function normalizeLocale(raw: string | null | undefined): Locale | null {
41 const tag = (raw ?? '').trim().toLowerCase().replace(/_/g, '-')
42 if (!tag) return null
43 if (tag === 'zh' || tag.startsWith('zh-')) {
44 const parts = tag.split('-').slice(1)
45 if (parts.some(part => part.startsWith('hant'))) return null
46 if (parts.some(part => part === 'tw' || part === 'hk' || part === 'mo')) return null
47 return 'zh-CN'
48 }
49 if (tag === 'en' || tag.startsWith('en-')) return 'en'
50 return null
51}
52
53function fromNavigator(): Locale | null {
54 if (typeof navigator === 'undefined') return null
55 const candidates = navigator.languages?.length ? navigator.languages : [navigator.language]
56 for (const candidate of candidates) {
57 const locale = normalizeLocale(candidate)
58 if (locale) return locale
59 }
60 return null
61}
62
63function storedLocale(): Locale | null {
64 try {
65 return normalizeLocale(localStorage.getItem(STORAGE_KEY))
66 } catch {
67 return null
68 }
69}
70
71function persist(locale: Locale) {
72 try {
73 localStorage.setItem(STORAGE_KEY, locale)
74 } catch {
75 /* private mode / storage disabled — the session keeps the in-memory value */
76 }
77}
78
79function resolve(): Locale {
80 return accountLocale ?? storedLocale() ?? fromNavigator() ?? DEFAULT_LOCALE
81}
82
83function paint(locale: Locale) {
84 if (typeof document !== 'undefined') document.documentElement.lang = locale
85 for (const listener of listeners) listener()
86}
87
88/** Boot-time entry point: resolve once, before the first paint of localized UI. */
89export function bootLocale() {
90 current = resolve()
91 if (typeof document !== 'undefined') document.documentElement.lang = current
92}
93
94/**
95 * Explicit switch from the settings UI. The choice is remembered on this
96 * device; the caller is responsible for persisting it to the account, and the
97 * server value re-applies on the next `fetchMe`.
98 */
99export function setLocale(locale: Locale) {
100 accountLocale = null
101 persist(locale)
102 if (current === locale) return
103 current = locale
104 paint(locale)
105}
106
107/**
108 * Apply the signed-in account's preference (`users.language` first, then
109 * `users.locale`). Passing values that normalize to `null` clears the override,
110 * which hands control back to the device/browser chain.
111 */
112export function applyAccountLocale(...candidates: (string | null | undefined)[]) {
113 let next: Locale | null = null
114 for (const candidate of candidates) {
115 next = normalizeLocale(candidate)
116 if (next) break
117 }
118 if (next === accountLocale) return
119 accountLocale = next
120 const resolved = resolve()
121 if (resolved === current) return
122 current = resolved
123 paint(resolved)
124}
125
126export function getLocale(): Locale {
127 return current
128}
129
130/** Human-readable locale name for pickers, in the locale's own language. */
131export function localeLabel(locale: Locale): string {
132 return locale === 'en' ? 'English' : '简体中文'
133}
134
135function subscribe(listener: () => void): () => void {
136 listeners.add(listener)
137 return () => listeners.delete(listener)
138}
139
140export function useLocale(): Locale {
141 return useSyncExternalStore(subscribe, getLocale)
142}
Added web/src/shared/i18n/translate.ts View web/src/shared/i18n/translate.ts

Diff too large to display.

Added web/src/shared/i18n/use-t.ts View web/src/shared/i18n/use-t.ts

Diff too large to display.

Modified web/src/shared/lib/format.ts View web/src/shared/lib/format.ts
@@ -1,50 +1,9 @@
1/** Generic display formatters shared across slices. */
2export function formatBytes(value: number | null | undefined): string {
3 if (value === null || value === undefined || !Number.isFinite(value)) return '—'
4 const units = ['B', 'KB', 'MB', 'GB', 'TB']
5 let size = value
6 let unit = 0
7 while (size >= 1024 && unit < units.length - 1) {
8 size /= 1024
9 unit += 1
10 }
11 return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`
12}
13
14export function shortSha(sha: string | null | undefined, len = 7): string {
15 return sha ? sha.slice(0, len) : '—'
16}
17
18export function fmtTime(value: string | number | null | undefined): string {
19 if (value === null || value === undefined) return '—'
20 if (typeof value === 'number') return new Date(value * 1000).toLocaleString('zh-CN', { hour12: false })
21 // Rust's time crate emits `… +00:00:00` (seconds in the offset), which
22 // Date cannot parse — fold it back to `+00:00` before constructing.
23 const iso = value.replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2')
24 const date = new Date(iso)
25 return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
26}
27
28/** Compact Chinese relative stamp ("19 天前") for commit rows. Accepts a unix
29 * second value (the API's `author_time`) or a date string. */
30export function fmtRelative(value: number | string | null | undefined): string {
31 const epoch =
32 typeof value === 'number'
33 ? value
34 : value
35 ? Math.floor(new Date(value.replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2')).getTime() / 1000)
36 : Number.NaN
37 if (!Number.isFinite(epoch)) return '—'
38 const delta = Math.floor(Date.now() / 1000) - epoch
39 // Future-dated commits (clock skew, rebased author time) read as "just now".
40 if (delta < 60) return '刚刚'
41 const minutes = Math.floor(delta / 60)
42 if (minutes < 60) return `${minutes} 分钟前`
43 const hours = Math.floor(minutes / 60)
44 if (hours < 24) return `${hours} 小时前`
45 const days = Math.floor(hours / 24)
46 if (days < 31) return `${days} 天前`
47 const months = Math.floor(days / 30)
48 if (months < 12) return `${months} 个月前`
49 return `${Math.floor(days / 365)} 年前`
50}
1/**
2 * Generic display formatters shared across slices.
3 *
4 * The implementations live in `shared/i18n/format` so that locale is resolved
5 * in one place; this module keeps the historical names (and the `shared/lib`
6 * public API) intact for the call sites that already import them.
7 */
8export { formatBytes, formatDate, formatDateTime, formatRelative, shortSha } from '@/shared/i18n/format'
9export { formatDateTime as fmtTime, formatRelative as fmtRelative } from '@/shared/i18n/format'
Modified web/src/shared/lib/palettes.ts View web/src/shared/lib/palettes.ts
@@ -13,6 +13,7 @@
1313 * pulls the mode with it, which `initPalette` keeps in sync.
1414 */
1515import { useSyncExternalStore } from 'react'
16import { t } from '@/shared/i18n'
1617import { getTheme, onThemeChange, setTheme, type Theme } from './theme'
1718
1819export type PaletteMode = Theme
@@ -52,82 +53,84 @@
5253export const DEFAULT_PALETTE_ID = 'graphite'
5354
5455export const PALETTES: Palette[] = [
56 // Names/origins resolve lazily so the picker follows the active language.
57
5558 {
5659 id: 'graphite',
57 name: '石墨',
58 origin: 'GitData 默认',
60 get name() { return t('palette.graphite.name') },
61 get origin() { return t('palette.graphite.origin') },
5962 light: { base: '#fafafa', panel: '#f5f5f4', surface: '#ffffff', text: '#23272b', muted: '#687078', line: '#e1e3e3', accent: '#2f3438' },
6063 dark: { base: '#1a1c1e', panel: '#282a2d', surface: '#202224', text: '#eceeef', muted: '#b1b5ba', line: '#3a3d40', accent: '#565e67' },
6164 },
6265 {
6366 id: 'paper',
64 name: '纸质',
65 origin: '暖色阅读',
67 get name() { return t('palette.paper.name') },
68 get origin() { return t('palette.paper.origin') },
6669 light: { base: '#faf7f2', panel: '#f2ede4', surface: '#fffdf9', text: '#2f2a26', muted: '#7d7367', line: '#e6ded2', accent: '#847459' },
6770 },
6871 {
6972 id: 'mint',
70 name: '薄荷',
71 origin: '冷色清爽',
73 get name() { return t('palette.mint.name') },
74 get origin() { return t('palette.mint.origin') },
7275 light: { base: '#f4f9f7', panel: '#e8f1ed', surface: '#ffffff', text: '#1f2d2a', muted: '#5f7a73', line: '#d9e7e2', accent: '#2f6f5f' },
7376 },
7477 {
7578 id: 'github',
76 name: '晴空',
79 get name() { return t('palette.github.name') },
7780 origin: 'GitHub',
7881 light: { base: '#f6f8fa', panel: '#eaeef2', surface: '#ffffff', text: '#1f2328', muted: '#59636e', line: '#d1d9e0', accent: '#215a8e' },
7982 dark: { base: '#0d1117', panel: '#161b22', surface: '#11161d', text: '#e6edf3', muted: '#9198a1', line: '#30363d', accent: '#3d6a94' },
8083 },
8184 {
8285 id: 'solarized',
83 name: '日曜',
86 get name() { return t('palette.solarized.name') },
8487 origin: 'Solarized',
8588 light: { base: '#fdf6e3', panel: '#eee8d5', surface: '#fffdf1', text: '#073642', muted: '#657b83', line: '#e3dcc5', accent: '#268bd2' },
8689 dark: { base: '#002b36', panel: '#0a3d4a', surface: '#06333f', text: '#e8e5d8', muted: '#93a1a1', line: '#1b4a56', accent: '#2aa198' },
8790 },
8891 {
8992 id: 'nord',
90 name: '北极',
93 get name() { return t('palette.nord.name') },
9194 origin: 'Nord',
9295 light: { base: '#eceff4', panel: '#e5e9f0', surface: '#f7f9fc', text: '#2e3440', muted: '#4c566a', line: '#d8dee9', accent: '#5e81ac' },
9396 dark: { base: '#2e3440', panel: '#3b4252', surface: '#353c4a', text: '#eceff4', muted: '#c8d1de', line: '#434c5e', accent: '#88c0d0' },
9497 },
9598 {
9699 id: 'catppuccin',
97 name: '猫咪',
100 get name() { return t('palette.catppuccin.name') },
98101 origin: 'Catppuccin',
99102 light: { base: '#eff1f5', panel: '#e6e9ef', surface: '#f7f8fc', text: '#4c4f69', muted: '#6c6f85', line: '#ccd0da', accent: '#7287fd' },
100103 dark: { base: '#1e1e2e', panel: '#313244', surface: '#24243a', text: '#cdd6f4', muted: '#a6adc8', line: '#45475a', accent: '#89b4fa' },
101104 },
102105 {
103106 id: 'everforest',
104 name: '常林',
107 get name() { return t('palette.everforest.name') },
105108 origin: 'Everforest',
106109 light: { base: '#f0f4ec', panel: '#e4ebdd', surface: '#f9fbf5', text: '#2d353b', muted: '#6c7a80', line: '#d5dfc9', accent: '#7f9e4c' },
107110 dark: { base: '#2b3339', panel: '#343f44', surface: '#303a40', text: '#d3c6aa', muted: '#9da9a0', line: '#404d53', accent: '#a7c080' },
108111 },
109112 {
110113 id: 'gruvbox',
111 name: '大地',
114 get name() { return t('palette.gruvbox.name') },
112115 origin: 'Gruvbox',
113116 light: { base: '#f2e5bc', panel: '#ebdbb2', surface: '#fbf1c7', text: '#3c3836', muted: '#7c6f64', line: '#d5c4a1', accent: '#076678' },
114117 dark: { base: '#282828', panel: '#3c3836', surface: '#32302f', text: '#ebdbb2', muted: '#a89984', line: '#504945', accent: '#83a598' },
115118 },
116119 {
117120 id: 'onedark',
118 name: '一色',
121 get name() { return t('palette.onedark.name') },
119122 origin: 'One Dark',
120123 dark: { base: '#282c34', panel: '#2c313a', surface: '#31363f', text: '#abb2bf', muted: '#8b95a7', line: '#3e4451', accent: '#61afef' },
121124 },
122125 {
123126 id: 'tokyonight',
124 name: '夜色',
127 get name() { return t('palette.tokyonight.name') },
125128 origin: 'Tokyo Night',
126129 dark: { base: '#1a1b26', panel: '#24283b', surface: '#1f2335', text: '#c0caf5', muted: '#9aa5ce', line: '#3b4261', accent: '#7aa2f7' },
127130 },
128131 {
129132 id: 'dracula',
130 name: '德古拉',
133 get name() { return t('palette.dracula.name') },
131134 origin: 'Dracula',
132135 dark: { base: '#282a36', panel: '#343746', surface: '#2f3341', text: '#f8f8f2', muted: '#b8c0d0', line: '#44475a', accent: '#bd93f9' },
133136 },
Modified web/src/shared/ui/app-shell.tsx View web/src/shared/ui/app-shell.tsx
@@ -1,5 +1,6 @@
11import { useEffect, useRef, useState, type ReactNode } from 'react'
22import { ChevronDown, Menu, PanelLeftClose, PanelLeftOpen, X } from 'lucide-react'
3import { useT } from '@/shared/i18n'
34
45/** Shared, responsive workspace frame. Content panes own their scroll position. */
56export interface AppShellProps {
@@ -13,6 +14,7 @@
1314}
1415
1516export function AppShell({ rail, sideHeader, navPinned, nav, sideFooter, children }: AppShellProps) {
17 const { t } = useT()
1618 const [navigationOpen, setNavigationOpen] = useState(false)
1719 const [collapsed, setCollapsed] = useState(false)
1820 const navigation = useRef<HTMLDivElement>(null)
@@ -53,34 +55,34 @@
5355 className="workspace-skip"
5456 onClick={() => document.getElementById('workspace-main')?.focus()}
5557 >
56 跳转到主要内容
58 {t('ui.skipToContent')}
5759 </button>
5860 <div className="workspace-mobile-bar">
59 <button ref={menu} type="button" className="workspace-icon-button" aria-label="打开工作区导航" aria-expanded={navigationOpen} aria-controls="workspace-navigation" onClick={() => setNavigationOpen(true)}><Menu size={20} /></button>
60 <span className="font-semibold">GitData<span className="ml-2 text-xs font-normal text-shell-ink-muted">工作空间</span></span>
61 <button ref={menu} type="button" className="workspace-icon-button" aria-label={t('ui.openNavigation')} aria-expanded={navigationOpen} aria-controls="workspace-navigation" onClick={() => setNavigationOpen(true)}><Menu size={20} /></button>
62 <span className="font-semibold">GitData<span className="ml-2 text-xs font-normal text-shell-ink-muted">{t('ui.workspaceLabel')}</span></span>
6163 </div>
62 {navigationOpen ? <button type="button" className="workspace-scrim" aria-label="收起工作区导航" tabIndex={-1} onClick={() => setNavigationOpen(false)} /> : null}
63 <div id="workspace-navigation" ref={navigation} className="workspace-navigation" role={navigationOpen ? 'dialog' : undefined} aria-modal={navigationOpen || undefined} aria-label="工作区导航" onClick={event => {
64 {navigationOpen ? <button type="button" className="workspace-scrim" aria-label={t('ui.closeNavigation')} tabIndex={-1} onClick={() => setNavigationOpen(false)} /> : null}
65 <div id="workspace-navigation" ref={navigation} className="workspace-navigation" role={navigationOpen ? 'dialog' : undefined} aria-modal={navigationOpen || undefined} aria-label={t('ui.navigation')} onClick={event => {
6466 if ((event.target as HTMLElement).closest('a[href]')) setNavigationOpen(false)
6567 }}>
66 <aside className="workspace-rail" aria-label="空间列表">{rail}</aside>
67 <aside className="workspace-sidebar" aria-label="当前空间">
68 <aside className="workspace-rail" aria-label={t('ui.spaceList')}>{rail}</aside>
69 <aside className="workspace-sidebar" aria-label={t('ui.currentSpace')}>
6870 <div className="workspace-sidebar-header">
69 {sideHeader ?? <><span className="min-w-0 flex-1 truncate font-semibold">个人空间</span><ChevronDown size={16} className="text-shell-ink-muted" /></>}
70 <button type="button" className="workspace-drawer-close workspace-icon-button" aria-label="收起导航" onClick={() => setNavigationOpen(false)}><X size={18} /></button>
71 {sideHeader ?? <><span className="min-w-0 flex-1 truncate font-semibold">{t('ui.personalSpace')}</span><ChevronDown size={16} className="text-shell-ink-muted" /></>}
72 <button type="button" className="workspace-drawer-close workspace-icon-button" aria-label={t('ui.collapseNavigation')} onClick={() => setNavigationOpen(false)}><X size={18} /></button>
7173 </div>
7274 {navPinned ? (
73 <div className="workspace-nav-pinned" aria-label="快捷入口">{navPinned}</div>
75 <div className="workspace-nav-pinned" aria-label={t('ui.shortcuts')}>{navPinned}</div>
7476 ) : null}
75 <div className="workspace-nav-caption"><span>工作区</span><button type="button" className="workspace-collapse workspace-icon-button" aria-label="折叠侧栏" title="折叠侧栏" onClick={() => setCollapsed(true)}><PanelLeftClose size={16} /></button></div>
76 <nav className="workspace-nav" aria-label="频道、看板与仓库">{nav}</nav>
77 <div className="workspace-nav-caption"><span>{t('ui.workspace')}</span><button type="button" className="workspace-collapse workspace-icon-button" aria-label={t('ui.collapseSidebar')} title={t('ui.collapseSidebar')} onClick={() => setCollapsed(true)}><PanelLeftClose size={16} /></button></div>
78 <nav className="workspace-nav" aria-label={t('ui.navScope')}>{nav}</nav>
7779 <div className="workspace-sidebar-footer">{sideFooter}</div>
7880 </aside>
7981 </div>
8082 <main id="workspace-main" tabIndex={-1} className="workspace-main" inert={navigationOpen || undefined}>
8183 {/* Always mounted: `visibility` hides it when the sidebar is open, which keeps
8284 it out of the tab order while still letting it fade in and out. */}
83 <button type="button" className="workspace-expand workspace-icon-button" aria-label="展开侧栏" title="展开侧栏" onClick={() => setCollapsed(false)}><PanelLeftOpen size={18} /></button>
85 <button type="button" className="workspace-expand workspace-icon-button" aria-label={t('ui.expandSidebar')} title={t('ui.expandSidebar')} onClick={() => setCollapsed(false)}><PanelLeftOpen size={18} /></button>
8486 {children}
8587 </main>
8688 </div>
Modified web/src/shared/ui/form-modal.tsx View web/src/shared/ui/form-modal.tsx
@@ -1,5 +1,6 @@
11import { useEffect, useState, type FormEvent, type ReactNode } from 'react'
22import { Loader2, X } from 'lucide-react'
3import { useT } from '@/shared/i18n'
34import { Alert, Button, Checkbox } from '@/shared/ui'
45import { parseList } from './form-values'
56import { ApiFailure } from '@/shared/lib'
@@ -74,12 +75,13 @@
7475 fields,
7576 open,
7677 danger = false,
77 confirmLabel = '确认',
78 confirmLabel,
7879 wide = false,
7980 load,
8081 onSubmit,
8182 onClose,
8283}: FormModalProps) {
84 const { t } = useT()
8385 const [values, setValues] = useState<FormValues>({})
8486 const [loading, setLoading] = useState(false)
8587 const [busy, setBusy] = useState(false)
@@ -108,7 +110,7 @@
108110 },
109111 err => {
110112 if (!active) return
111 setError(err instanceof ApiFailure ? err.message : '加载失败,请稍后重试。')
113 setError(err instanceof ApiFailure ? err.message : t('ui.loadFailed'))
112114 setLoading(false)
113115 },
114116 )
@@ -148,7 +150,7 @@
148150 return raw === '' && (f.kind === 'text' || f.kind === 'datetime' || f.kind === 'multiselect')
149151 })
150152 if (missing) {
151 setError(`请填写「${missing.label}」。`)
153 setError(t('ui.fieldRequired', { field: missing.label }))
152154 return
153155 }
154156 const badNumber = fields.find(f => {
@@ -157,7 +159,7 @@
157159 return raw !== '' && (!Number.isFinite(Number(raw)) || Number(raw) < 0)
158160 })
159161 if (badNumber) {
160 setError(`「${badNumber.label}」需要是非负整数。`)
162 setError(t('ui.fieldNonNegativeInteger', { field: badNumber.label }))
161163 return
162164 }
163165 setBusy(true)
@@ -171,7 +173,7 @@
171173 onClose()
172174 }
173175 } catch (err) {
174 setError(err instanceof ApiFailure ? err.message : '操作失败,请稍后重试。')
176 setError(err instanceof ApiFailure ? err.message : t('ui.actionFailed'))
175177 setBusy(false)
176178 }
177179 }
@@ -199,8 +201,8 @@
199201 <h2 className="text-[16px] font-semibold">{title}</h2>
200202 <button
201203 type="button"
202 aria-label="关闭"
203 className="text-shell-ink-muted transition-colors hover:text-shell-ink"
204 aria-label={t('ui.close')}
205 className="dialog-close text-shell-ink-muted transition-colors hover:text-shell-ink"
204206 onClick={onClose}
205207 >
206208 <X className="h-4 w-4" />
@@ -212,7 +214,7 @@
212214 {extra ? <div className="mb-4">{extra}</div> : null}
213215 {loading ? (
214216 <div className="flex h-32 items-center justify-center">
215 <Loader2 className="h-5 w-5 animate-spin text-shell-ink-muted" aria-label="加载中" />
217 <Loader2 className="h-5 w-5 animate-spin text-shell-ink-muted" aria-label={t('ui.loading')} />
216218 </div>
217219 ) : (
218220 <form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
@@ -263,7 +265,7 @@
263265 <div className="flex flex-wrap gap-2 pt-0.5" role="group" aria-label={field.label}>
264266 {field.options.length === 0 ? (
265267 <span className="text-[12px] text-shell-ink-muted">
266 {field.emptyLabel ?? '暂无可选项'}
268 {field.emptyLabel ?? t('ui.noOptions')}
267269 </span>
268270 ) : (
269271 field.options.map(option => {
@@ -336,14 +338,14 @@
336338 {error && <Alert tone="error">{error}</Alert>}
337339 <div className="flex justify-end gap-2 pt-1">
338340 <Button variant="secondary" onClick={onClose} disabled={busy}>
339 取消
341 {t('common.action.cancel')}
340342 </Button>
341343 <Button
342344 type="submit"
343345 disabled={busy || loading}
344346 className={danger ? 'bg-[#f23f43] text-white hover:brightness-95' : undefined}
345347 >
346 {busy ? '处理中…' : confirmLabel}
348 {busy ? t('common.action.working') : (confirmLabel ?? t('common.action.confirm'))}
347349 </Button>
348350 </div>
349351 </form>
Modified web/src/shared/ui/index.ts View web/src/shared/ui/index.ts
@@ -48,3 +48,4 @@
4848} from './modal'
4949export { ThemeContext, useThemeContext, type ThemeContextValue } from './theme-context'
5050export { ThemeProvider } from './theme-provider'
51export { LanguagePicker } from './language-picker'
Added web/src/shared/ui/language-picker.tsx View web/src/shared/ui/language-picker.tsx

Diff too large to display.

Modified web/src/shared/ui/modal.tsx View web/src/shared/ui/modal.tsx
@@ -1,5 +1,6 @@
11import { useEffect, useId, useRef, type ReactNode } from 'react'
22import { X } from 'lucide-react'
3import { useT } from '@/shared/i18n'
34
45/**
56 * Generic modal chrome (overlay + panel + Escape/outside-click close), shared
@@ -22,6 +23,7 @@
2223 children: ReactNode
2324 footer?: ReactNode
2425}) {
26 const { t } = useT()
2527 const panel = useRef<HTMLDivElement>(null)
2628 const closeRef = useRef(onClose)
2729 const titleId = useId()
@@ -74,7 +76,7 @@
7476 </div>
7577 <button
7678 type="button"
77 aria-label="关闭"
79 aria-label={t('ui.close')}
7880 onClick={onClose}
7981 className="shrink-0 text-shell-ink-muted transition-colors hover:text-shell-ink"
8082 >
Modified web/src/shared/ui/panels.tsx View web/src/shared/ui/panels.tsx
@@ -1,5 +1,6 @@
11import { useState } from 'react'
22import { Check, Copy } from 'lucide-react'
3import { useT } from '@/shared/i18n'
34
45/** Shared empty / error / copy affordances for the repo views. */
56
@@ -18,14 +19,15 @@
1819 onRetry: () => void
1920 note?: string
2021}) {
22 const { t } = useT()
2123 const text =
2224 status === 404
23 ? `${what}不存在于该修订。`
25 ? t('ui.notFoundOnRevision', { what })
2426 : status === 401 || status === 403
25 ? `没有权限访问该${what}。`
27 ? t('ui.forbidden', { what })
2628 : status === 0
27 ? `${what}加载失败:${note ?? '网络错误'}`
28 : `${what}加载失败(${status})。`
29 ? t('ui.loadFailedWithNote', { what, note: note ?? t('error.status.0') })
30 : t('ui.loadFailedWithStatus', { what, status })
2931 return (
3032 <div className="flex flex-col items-center gap-3 py-10 text-center">
3133 <p className="text-[13.5px]">{text}</p>
@@ -34,7 +36,7 @@
3436 onClick={onRetry}
3537 className="rounded-md border-2 border-shell-hairline bg-shell-field px-4 py-1.5 text-[13px] transition-colors hover:border-shell-ink"
3638 >
37 重试
39 {t('common.action.retry')}
3840 </button>
3941 </div>
4042 )
@@ -43,11 +45,12 @@
4345/** Copy-to-clipboard chip used for object ids; falls back silently when the
4446 * clipboard API is unavailable (insecure context / permission denied). */
4547export function CopyChip({ text, label }: { text: string; label?: string }) {
48 const { t } = useT()
4649 const [copied, setCopied] = useState(false)
4750 return (
4851 <button
4952 type="button"
50 aria-label={`复制${label ?? text}`}
53 aria-label={`${t('common.action.copy')} ${label ?? text}`}
5154 title={text}
5255 onClick={async () => {
5356 try {
@@ -61,7 +64,7 @@
6164 className="flex shrink-0 items-center gap-1 rounded bg-shell-active/70 px-1.5 py-0.5 font-mono text-[11px] text-shell-ink-muted transition-colors hover:text-shell-ink"
6265 >
6366 {copied ? <Check className="h-3 w-3" aria-hidden /> : <Copy className="h-3 w-3" aria-hidden />}
64 {copied ? '已复制' : (label ?? text)}
67 {copied ? t('common.action.copied') : (label ?? text)}
6568 </button>
6669 )
6770}
Modified web/src/shared/ui/settings-modal.tsx View web/src/shared/ui/settings-modal.tsx
@@ -1,5 +1,6 @@
11import { useEffect, type ReactNode } from 'react'
22import { X } from 'lucide-react'
3import { useT } from '@/shared/i18n'
34
45export interface SettingsSection {
56 id: string
@@ -31,6 +32,7 @@
3132 onClose,
3233 children,
3334}: SettingsModalShellProps) {
35 const { t } = useT()
3436 useEffect(() => {
3537 const onKey = (event: KeyboardEvent) => {
3638 if (event.key === 'Escape') onClose()
@@ -42,7 +44,7 @@
4244 return (
4345 <div className="workspace-overlay fixed inset-0 z-50 flex items-center justify-center p-6">
4446 <div role="dialog" aria-modal aria-label={title} className="settings-dialog text-shell-ink">
45 <nav className="settings-nav" aria-label={`${title}分类`}>
47 <nav className="settings-nav" aria-label={t('ui.settingsCategories', { title })}>
4648 <p>{title}</p>
4749 {sections.map(item => (
4850 <button
@@ -59,7 +61,7 @@
5961 <div className="settings-content">{children}</div>
6062 <button
6163 type="button"
62 aria-label="关闭设置"
64 aria-label={t('ui.closeSettings')}
6365 onClick={onClose}
6466 className="settings-close workspace-icon-button"
6567 >
Modified web/src/shared/ui/settings-primitives.tsx View web/src/shared/ui/settings-primitives.tsx
@@ -5,6 +5,7 @@
55 */
66import { useState, type ReactNode } from 'react'
77import { Copy, Loader2 } from 'lucide-react'
8import { useT } from '@/shared/i18n'
89import { Alert, Button, Checkbox } from '@/shared/ui'
910
1011export function SectionTitle({ title, sub }: { title: string; sub?: string }) {
@@ -74,7 +75,7 @@
7475 busy,
7576 notice,
7677 error,
77 label = '保存修改',
78 label,
7879 danger = false,
7980 extra,
8081}: {
@@ -86,6 +87,7 @@
8687 danger?: boolean
8788 extra?: ReactNode
8889}) {
90 const { t } = useT()
8991 return (
9092 <div className="flex flex-wrap items-center gap-3">
9193 <Button
@@ -93,7 +95,7 @@
9395 disabled={busy}
9496 className={danger ? 'settings-danger-btn' : undefined}
9597 >
96 {busy ? '保存中…' : label}
98 {busy ? t('ui.saving') : (label ?? t('ui.saveChanges'))}
9799 </Button>
98100 {extra}
99101 {notice ? <Alert tone="success">{notice}</Alert> : null}
@@ -103,11 +105,12 @@
103105}
104106
105107export function CopyButton({ text }: { text: string }) {
108 const { t } = useT()
106109 const [copied, setCopied] = useState(false)
107110 return (
108111 <button
109112 type="button"
110 aria-label="复制"
113 aria-label={t('common.action.copy')}
111114 onClick={async () => {
112115 try {
113116 await navigator.clipboard.writeText(text)
@@ -120,13 +123,14 @@
120123 className="text-shell-ink-muted transition-colors hover:text-shell-ink"
121124 >
122125 <Copy className="h-3.5 w-3.5" />
123 {copied ? <span className="ml-1 text-[11px]">已复制</span> : null}
126 {copied ? <span className="ml-1 text-[11px]">{t('common.action.copied')}</span> : null}
124127 </button>
125128 )
126129}
127130
128131export function Busy() {
129 return <Loader2 className="my-10 h-6 w-6 animate-spin text-shell-ink-muted" aria-label="加载中" />
132 const { t } = useT()
133 return <Loader2 className="my-10 h-6 w-6 animate-spin text-shell-ink-muted" aria-label={t('ui.loading')} />
130134}
131135
132136/** One entry in a settings list: leading mark, stacked text, trailing actions. */
Modified web/src/shared/ui/settings-utils.ts View web/src/shared/ui/settings-utils.ts
@@ -6,6 +6,7 @@
66 */
77import { useState } from 'react'
88import { ApiFailure } from '@/shared/lib'
9import { getLocale, translate } from '@/shared/i18n'
910
1011/**
1112 * Fields lean on the shared stylesheet for the border weight, corner radius and
@@ -22,7 +23,7 @@
2223export function errText(err: unknown) {
2324 if (err instanceof ApiFailure) return err.message
2425 if (err instanceof Error && err.message) return err.message
25 return '操作失败,请稍后重试。'
26 return translate(getLocale(), 'ui.actionFailed')
2627}
2728
2829export function useSave() {
@@ -59,7 +60,10 @@
5960export function fmtDate(value?: string | null) {
6061 if (!value) return '—'
6162 const date = parseDate(value)
62 return date ? date.toLocaleString('zh-CN', { hour12: false }) : value
63 if (!date) return value
64 // Locale-aware so the settings tables switch conventions along with the UI
65 // language (the old hardcoded `zh-CN` made the whole app read as Chinese).
66 return new Intl.DateTimeFormat(getLocale(), { dateStyle: 'medium', timeStyle: 'short' }).format(date)
6367}
6468
6569/** `2026-09-06T02:00:00+00:00` → the `YYYY-MM-DDTHH:mm` that `<input type=date>` wants. */
@@ -74,7 +78,7 @@
7478export function fileToBase64(file: File): Promise<string> {
7579 return new Promise((resolve, reject) => {
7680 const reader = new FileReader()
77 reader.onerror = () => reject(new Error('读取文件失败'))
81 reader.onerror = () => reject(new Error(translate(getLocale(), 'ui.readFileFailed')))
7882 reader.onload = () => {
7983 const result = String(reader.result ?? '')
8084 // `data:image/png;base64,AAAA…` → the part after the comma.
Modified web/src/shared/ui/theme-context.ts View web/src/shared/ui/theme-context.ts
@@ -1,4 +1,5 @@
11import { createContext, useContext } from 'react'
2import { getLocale, translate } from '@/shared/i18n'
23import type { Palette, PaletteMode } from '@/shared/lib'
34
45/**
@@ -25,6 +26,6 @@
2526
2627export function useThemeContext(): ThemeContextValue {
2728 const value = useContext(ThemeContext)
28 if (!value) throw new Error('useThemeContext 必须在 ThemeProvider 内使用')
29 if (!value) throw new Error(translate(getLocale(), 'ui.themeContextMissing'))
2930 return value
3031}
Modified web/src/shared/voice/session.ts View web/src/shared/voice/session.ts
@@ -23,6 +23,7 @@
2323 type Participant,
2424 type RemoteParticipant,
2525} from 'livekit-client'
26import { t } from '@/shared/i18n'
2627
2728/** Persisted so a refresh can rejoin the same channel. */
2829const STORAGE_KEY = 'gitdata.voice.session'
@@ -173,7 +174,7 @@
173174 const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
174175 for (const track of stream.getTracks()) track.stop()
175176 } catch {
176 this.patch({ notice: '未授予麦克风权限,将以只听模式加入' })
177 this.patch({ notice: t('chat.voice.notice.micDenied') })
177178 }
178179 }
179180
@@ -219,7 +220,7 @@
219220 await this.setMicrophone(true)
220221 }
221222 } catch (error) {
222 const message = error instanceof Error ? error.message : '语音连接失败'
223 const message = error instanceof Error ? error.message : t('chat.voice.notice.connectFailed')
223224 this.room = null
224225 this.leaving = true
225226 this.patch({
@@ -294,16 +295,16 @@
294295 screenShareEnabled: false,
295296 speaking: false,
296297 notice: kicked
297 ? '你已被管理员移出通话'
298 ? t('chat.voice.notice.kicked')
298299 : reason === DisconnectReason.DUPLICATE_IDENTITY
299 ? '该账号已在其他地方加入通话'
300 ? t('chat.voice.notice.duplicate')
300301 : retainedNotice
301302 ? retainedNotice
302303 : intentional
303304 ? null
304305 : this.hadMedia
305 ? '语音连接已中断,请点击重新加入'
306 : MEDIA_UNREACHABLE_NOTICE,
306 ? t('chat.voice.notice.dropped')
307 : mediaUnreachableNotice(),
307308 })
308309 })
309310 refreshSelf()
@@ -347,7 +348,7 @@
347348 this.patch({ micEnabled: enabled })
348349 } catch (error) {
349350 this.patch({
350 notice: error instanceof Error ? error.message : '无法访问麦克风',
351 notice: error instanceof Error ? error.message : t('chat.voice.notice.micUnavailable'),
351352 })
352353 }
353354 }
@@ -360,7 +361,7 @@
360361 this.patch({ cameraEnabled: enabled })
361362 } catch (error) {
362363 this.patch({
363 notice: error instanceof Error ? error.message : '无法访问摄像头',
364 notice: error instanceof Error ? error.message : t('chat.voice.notice.cameraUnavailable'),
364365 })
365366 }
366367 }
@@ -373,7 +374,7 @@
373374 this.patch({ screenShareEnabled: enabled })
374375 } catch (error) {
375376 this.patch({
376 notice: error instanceof Error ? error.message : '无法共享屏幕',
377 notice: error instanceof Error ? error.message : t('chat.voice.notice.screenUnavailable'),
377378 })
378379 }
379380 }
@@ -386,7 +387,7 @@
386387 await room.startAudio()
387388 this.patch({ audioPlaybackAllowed: room.canPlaybackAudio })
388389 } catch {
389 this.patch({ notice: '浏览器仍阻止了音频播放,请检查站点声音权限' })
390 this.patch({ notice: t('chat.voice.notice.audioBlocked') })
390391 }
391392 }
392393
@@ -404,8 +405,7 @@
404405 }
405406}
406407
407const MEDIA_UNREACHABLE_NOTICE =
408 '语音通道建立失败:浏览器连不上 LiveKit 媒体端口(UDP 50000 / TCP 7881),请确认 LiveKit 已启动后重试'
408const mediaUnreachableNotice = () => t('chat.voice.notice.mediaUnreachable')
409409
410410/** Turn the SDK's English transport errors into something actionable. */
411411function describeVoiceFailure(message: string): string {
@@ -416,13 +416,13 @@
416416 text.includes('ice') ||
417417 text.includes('timeout')
418418 ) {
419 return MEDIA_UNREACHABLE_NOTICE
419 return mediaUnreachableNotice()
420420 }
421421 if (text.includes('permission') || text.includes('notallowed') || text.includes('denied')) {
422 return '浏览器拒绝了麦克风/摄像头权限,请在地址栏恢复后重试'
422 return t('chat.voice.notice.permissionDenied')
423423 }
424424 if (text.includes('token')) {
425 return '语音令牌无效或已过期,请重新加入'
425 return t('chat.voice.notice.tokenInvalid')
426426 }
427427 return message
428428}
Modified web/openapi.json View web/openapi.json

Diff too large to display.

Modified web/package.json View web/package.json
@@ -6,7 +6,7 @@
66 "scripts": {
77 "dev": "vite",
88 "build": "tsc -b && vite build",
9 "lint": "oxlint && bun scripts/lint-nav.mjs",
9 "lint": "oxlint && bun scripts/lint-nav.mjs && bun scripts/lint-i18n.mjs",
1010 "lint:fsd": "steiger src",
1111 "preview": "vite preview",
1212 "gen:api": "alova gen -f"
Modified Cargo.lock View Cargo.lock
@@ -141,6 +141,7 @@
141141 "export",
142142 "futures",
143143 "git",
144 "i18n",
144145 "models",
145146 "redis",
146147 "reqwest 0.12.28",
@@ -674,6 +675,7 @@
674675 "export",
675676 "hkdf",
676677 "hmac 0.13.0",
678 "i18n",
677679 "maud",
678680 "models",
679681 "pow-solver",
@@ -1206,6 +1208,7 @@
12061208 "futures",
12071209 "hmac 0.13.0",
12081210 "http 0.2.12",
1211 "i18n",
12091212 "livekit-api",
12101213 "livekit-protocol",
12111214 "models",
@@ -2140,6 +2143,7 @@
21402143 "bytes",
21412144 "config",
21422145 "futures",
2146 "i18n",
21432147 "lettre",
21442148 "maud",
21452149 "redis",
@@ -4209,6 +4213,16 @@
42094213]
42104214
42114215[[package]]
4216name = "i18n"
4217version = "1.0.0"
4218dependencies = [
4219 "ci-protocol",
4220 "models",
4221 "sea-orm",
4222 "uuid",
4223]
4224
4225[[package]]
42124226name = "iana-time-zone"
42134227version = "0.1.65"
42144228source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4839,6 +4853,7 @@
48394853 "config",
48404854 "email",
48414855 "export",
4856 "i18n",
48424857 "models",
48434858 "sea-orm",
48444859 "search",
@@ -8453,6 +8468,7 @@
84538468 "config",
84548469 "email",
84558470 "export",
8471 "i18n",
84568472 "models",
84578473 "rand 0.8.7",
84588474 "sea-orm",
@@ -9707,8 +9723,10 @@
97079723 "axum",
97089724 "base64 0.23.1",
97099725 "config",
9726 "email",
97109727 "export",
97119728 "futures",
9729 "i18n",
97129730 "models",
97139731 "rand 0.8.7",
97149732 "redis",
Modified Cargo.toml View Cargo.toml
@@ -35,6 +35,7 @@
3535space = { path = "lib/space" }
3636issues = { path = "lib/issues" }
3737git = { path = "lib/git" }
38i18n = { path = "lib/i18n" }
3839import = { path = "lib/import" }
3940webapi = { path = "lib/webapi" }
4041pulls = { path = "lib/pulls" }
Modified openapi.json View openapi.json

Diff too large to display.