Skip to content
Clone

feat(issues): attach files to issues

Issues accept attachments through a presigned upload: the client asks for a
pending attachment, uploads straight to object storage, then finalizes it
against the issue and downloads it again through the API. Size, presign TTL
and per-user pending limits are configurable, and boards gained a bounded
prefix so links stay short.
ZhenYi committed
parent 11dfa2f commit 76d118d Browse files

36 files changed

+3255 −712

File tree 36
Added lib/issues/src/handlers/attachments/create_attachment.rs View lib/issues/src/handlers/attachments/create_attachment.rs
@@ -0,0 +1,32 @@
1use axum::Json;
2use axum::extract::State;
3use export::rest::{AppResponseData, NoData};
4use session_layer::Session;
5use session_layer::share::session::require_uid;
6
7use crate::errors::ApiError;
8use crate::services::attachments::{CreateIssueAttachmentCmd, IssueUploadIntentDto};
9use crate::services::service::IssueService;
10
11#[utoipa::path(
12 post,
13 path = "/issues/attachments",
14 tag = "issues",
15 description = "为一次工单附件上传预留记录并返回预签名 PUT 地址;上传完成后调用 finalize。",
16 request_body = CreateIssueAttachmentCmd,
17 responses(
18 (status = 200, description = "upload intent", body = AppResponseData<IssueUploadIntentDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 429, description = "too many pending uploads", body = AppResponseData<NoData>),
21 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn create_issue_attachment(
25 State(service): State<IssueService>,
26 session: Session,
27 Json(cmd): Json<CreateIssueAttachmentCmd>,
28) -> Result<AppResponseData<IssueUploadIntentDto>, ApiError> {
29 let uid = require_uid(&session)?;
30 let intent = service.create_issue_attachment(uid, cmd).await?;
31 Ok(AppResponseData::ok(intent))
32}
Added lib/issues/src/handlers/attachments/delete_attachment.rs View lib/issues/src/handlers/attachments/delete_attachment.rs
@@ -0,0 +1,30 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session_layer::Session;
4use session_layer::share::session::require_uid;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::service::IssueService;
9
10#[utoipa::path(
11 delete,
12 path = "/issues/attachments/{attachment_id}",
13 tag = "issues",
14 description = "删除一个工单附件(含对象存储里的文件);只有上传者本人可以删除。",
15 params(("attachment_id" = Uuid, Path, description = "attachment id")),
16 responses(
17 (status = 200, description = "attachment deleted", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>),
19 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn delete_issue_attachment(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(attachment_id): Path<Uuid>,
26) -> Result<AppResponseData<NoData>, ApiError> {
27 let uid = require_uid(&session)?;
28 service.delete_issue_attachment(uid, attachment_id).await?;
29 Ok(AppResponseData::ok(NoData))
30}
Added lib/issues/src/handlers/attachments/download_attachment.rs View lib/issues/src/handlers/attachments/download_attachment.rs
@@ -0,0 +1,45 @@
1use axum::extract::{Path, State};
2use axum::http::{HeaderMap, StatusCode, header};
3use axum::response::{IntoResponse, Response};
4use export::rest::{AppResponseData, NoData};
5use session_layer::Session;
6use session_layer::share::session::require_uid;
7use uuid::Uuid;
8
9use crate::errors::ApiError;
10use crate::services::service::IssueService;
11
12#[utoipa::path(
13 get,
14 path = "/issues/attachments/{attachment_id}/file",
15 tag = "issues",
16 description = "下载工单附件;上传者本人或可读该议题的成员可以访问。",
17 params(("attachment_id" = Uuid, Path, description = "attachment id")),
18 responses(
19 (status = 200, description = "file stream"),
20 (status = 404, description = "not found", body = AppResponseData<NoData>),
21 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn download_issue_attachment(
25 State(service): State<IssueService>,
26 session: Session,
27 Path(attachment_id): Path<Uuid>,
28) -> Result<Response, ApiError> {
29 let uid = require_uid(&session)?;
30 let (row, body, content_type) = service
31 .download_issue_attachment(uid, attachment_id)
32 .await?;
33 let mut headers = HeaderMap::new();
34 for (name, value) in IssueService::issue_attachment_content_headers(&row, content_type) {
35 if let Ok(value) = value.parse() {
36 headers.insert(name, value);
37 }
38 }
39 // Finalized bytes never change; a short private cache keeps thumbnails cheap.
40 headers.insert(
41 header::CACHE_CONTROL,
42 "private, max-age=300".parse().expect("static header value"),
43 );
44 Ok((StatusCode::OK, headers, body).into_response())
45}
Added lib/issues/src/handlers/attachments/finalize_attachment.rs View lib/issues/src/handlers/attachments/finalize_attachment.rs
@@ -0,0 +1,35 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session_layer::Session;
4use session_layer::share::session::require_uid;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::attachments::IssueAttachmentDto;
9use crate::services::service::IssueService;
10
11#[utoipa::path(
12 post,
13 path = "/issues/attachments/{attachment_id}/finalize",
14 tag = "issues",
15 description = "确认附件对象已上传并校验真实大小与类型;只有上传者本人可以 finalize。",
16 params(("attachment_id" = Uuid, Path, description = "attachment id")),
17 responses(
18 (status = 200, description = "attachment finalized", body = AppResponseData<IssueAttachmentDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>),
21 (status = 409, description = "already finalized", body = AppResponseData<NoData>),
22 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn finalize_issue_attachment(
26 State(service): State<IssueService>,
27 session: Session,
28 Path(attachment_id): Path<Uuid>,
29) -> Result<AppResponseData<IssueAttachmentDto>, ApiError> {
30 let uid = require_uid(&session)?;
31 let dto = service
32 .finalize_issue_attachment(uid, attachment_id)
33 .await?;
34 Ok(AppResponseData::ok(dto))
35}
Added lib/issues/src/handlers/attachments/list_attachments.rs View lib/issues/src/handlers/attachments/list_attachments.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session_layer::Session;
4use session_layer::share::session::require_uid;
5
6use crate::errors::ApiError;
7use crate::services::attachments::IssueAttachmentDto;
8use crate::services::issues::IssuePath;
9use crate::services::service::IssueService;
10
11#[utoipa::path(
12 get,
13 path = "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/attachments",
14 tag = "issues",
15 description = "列出议题的附件(只返回已上传完成的)。",
16 params(IssuePath),
17 responses(
18 (status = 200, description = "list attachments", body = AppResponseData<Vec<IssueAttachmentDto>>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>),
21 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn list_issue_attachments(
25 State(service): State<IssueService>,
26 session: Session,
27 Path(path): Path<IssuePath>,
28) -> Result<AppResponseData<Vec<IssueAttachmentDto>>, ApiError> {
29 let uid = require_uid(&session)?;
30 let rows = service
31 .list_issue_attachments(uid, &path.space_name, path.board_id, &path.issue_id)
32 .await?;
33 Ok(AppResponseData::ok(rows))
34}
Added lib/issues/src/handlers/attachments/mod.rs View lib/issues/src/handlers/attachments/mod.rs
@@ -0,0 +1,5 @@
1pub mod create_attachment;
2pub mod delete_attachment;
3pub mod download_attachment;
4pub mod finalize_attachment;
5pub mod list_attachments;
Modified lib/issues/src/handlers/boards/update_board.rs View lib/issues/src/handlers/boards/update_board.rs
@@ -11,7 +11,7 @@
1111 patch,
1212 path = "/issues/{space_name}/boards/{board_id}",
1313 tag = "issues",
14 description = "更新看板名称、图标或描述,并同步目录节点元数据。",
14 description = "更新看板名称、编号前缀、图标或描述,并同步目录节点元数据。",
1515 params(BoardPath),
1616 request_body = UpdateBoardCmd,
1717 responses(
Modified lib/issues/src/handlers/mod.rs View lib/issues/src/handlers/mod.rs
@@ -1,3 +1,4 @@
1pub mod attachments;
12pub mod boards;
23pub mod comments;
34pub mod content_history;
Added lib/issues/src/services/attachments.rs View lib/issues/src/services/attachments.rs
@@ -0,0 +1,406 @@
1//! Issue attachments: the same presigned-upload shape the chat module uses
2//! (`create intent → PUT bytes → finalize`), but the rows belong to an issue.
3//!
4//! A row starts unbound (`issue_id = NULL`) so the create-issue dialog can
5//! upload before the issue exists; submitting the issue links it. Only the
6//! uploader may finalize or delete, and reading a linked file follows the
7//! issue's own visibility rules.
8use axum::body::Body;
9use axum::http::header;
10use sea_orm::{
11 ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
12 QueryOrder, QuerySelect, Set,
13};
14use serde::{Deserialize, Serialize};
15use time::OffsetDateTime;
16use utoipa::ToSchema;
17use uuid::Uuid;
18
19use models::issue::issue_attachment::{self, IssueAttachmentStatus};
20
21use crate::errors::IssueError;
22use crate::services::service::IssueService;
23use crate::share::lookup::{issue_context_by_id, space_context};
24
25const ATTACHMENT_PREFIX: &str = "issue-attachments";
26
27/// Content types browsers may execute inline; never accepted, never served
28/// back as themselves (mirrors the chat attachment rule).
29const UNSAFE_INLINE_TYPES: &[&str] = &[
30 "text/html",
31 "application/xhtml+xml",
32 "image/svg+xml",
33 "application/xml",
34 "text/xml",
35 "application/javascript",
36 "text/javascript",
37];
38
39fn is_unsafe_inline(content_type: &str) -> bool {
40 let main = content_type.split(';').next().unwrap_or("").trim();
41 UNSAFE_INLINE_TYPES
42 .iter()
43 .any(|blocked| main.eq_ignore_ascii_case(blocked))
44}
45
46fn is_inline_renderable(content_type: &str) -> bool {
47 let main = content_type.split(';').next().unwrap_or("").trim();
48 main.starts_with("image/")
49 || main.starts_with("video/")
50 || main.starts_with("audio/")
51 || main.eq_ignore_ascii_case("text/plain")
52 || main.eq_ignore_ascii_case("text/markdown")
53 || main.eq_ignore_ascii_case("application/pdf")
54}
55
56fn sanitize_filename(name: &str) -> String {
57 let base = name.rsplit(['/', '\\']).next().unwrap_or("file");
58 let cleaned: String = base
59 .chars()
60 .map(|c| match c {
61 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
62 _ => '_',
63 })
64 .collect();
65 if cleaned.is_empty() || cleaned.chars().all(|c| c == '.') {
66 "file".to_string()
67 } else {
68 cleaned
69 }
70}
71
72fn storage_failure(err: impl std::fmt::Display) -> IssueError {
73 IssueError::Internal(format!("storage error: {err}"))
74}
75
76#[derive(Debug, Clone, Serialize, ToSchema)]
77pub struct IssueAttachmentDto {
78 pub id: Uuid,
79 /// `None` until the draft is submitted with this attachment attached.
80 pub issue_id: Option<Uuid>,
81 pub uploader_id: Uuid,
82 pub filename: String,
83 pub content_type: String,
84 pub size: i64,
85 pub status: IssueAttachmentStatus,
86 pub created_at: OffsetDateTime,
87}
88
89impl From<issue_attachment::Model> for IssueAttachmentDto {
90 fn from(row: issue_attachment::Model) -> Self {
91 Self {
92 id: row.id,
93 issue_id: row.issue_id,
94 uploader_id: row.uploader_id,
95 filename: row.filename,
96 content_type: row.content_type,
97 size: row.size,
98 status: row.status,
99 created_at: row.created_at,
100 }
101 }
102}
103
104#[derive(Debug, Clone, Deserialize, ToSchema)]
105pub struct CreateIssueAttachmentCmd {
106 /// Checked for membership so unbound uploads are still space-scoped.
107 pub space_name: String,
108 pub filename: String,
109 pub content_type: String,
110 pub size: i64,
111}
112
113#[derive(Debug, Clone, Serialize, ToSchema)]
114pub struct IssueUploadIntentDto {
115 pub id: Uuid,
116 pub upload_url: String,
117 pub expires_secs: u64,
118}
119
120impl IssueService {
121 pub async fn create_issue_attachment(
122 &self,
123 caller: Uuid,
124 cmd: CreateIssueAttachmentCmd,
125 ) -> Result<IssueUploadIntentDto, IssueError> {
126 let max_size = self.config().issues.attachment.max_size;
127 if cmd.size <= 0 || cmd.size > max_size {
128 return Err(IssueError::BadRequest(format!(
129 "file size must be within 1..{max_size} bytes"
130 )));
131 }
132 if cmd.filename.trim().is_empty() || cmd.filename.chars().count() > 255 {
133 return Err(IssueError::BadRequest("invalid filename".to_string()));
134 }
135 if cmd.content_type.trim().is_empty() || is_unsafe_inline(&cmd.content_type) {
136 return Err(IssueError::BadRequest(
137 "unsupported or unsafe content_type".to_string(),
138 ));
139 }
140
141 let space = space_context(self, caller, &cmd.space_name).await?;
142
143 let pending = issue_attachment::Entity::find()
144 .filter(issue_attachment::Column::UploaderId.eq(caller))
145 .filter(issue_attachment::Column::IssueId.is_null())
146 .count(self.db().read())
147 .await?;
148 if pending >= self.config().issues.attachment.max_pending_per_user {
149 return Err(IssueError::RateLimited(format!(
150 "too many unused attachments (max {})",
151 self.config().issues.attachment.max_pending_per_user
152 )));
153 }
154
155 let now = OffsetDateTime::now_utc();
156 let id = Uuid::now_v7();
157 let filename = sanitize_filename(&cmd.filename);
158 let key = format!("{ATTACHMENT_PREFIX}/{}/{caller}/{id}/{filename}", space.id);
159 let bucket = self.state.storage.client().bucket_name().to_string();
160
161 issue_attachment::ActiveModel {
162 id: Set(id),
163 issue_id: Set(None),
164 uploader_id: Set(caller),
165 filename: Set(filename),
166 content_type: Set(cmd.content_type.clone()),
167 size: Set(cmd.size),
168 bucket: Set(bucket),
169 s3_key: Set(key.clone()),
170 status: Set(IssueAttachmentStatus::Pending),
171 created_at: Set(now),
172 updated_at: Set(now),
173 }
174 .insert(self.db().write())
175 .await?;
176
177 let ttl = self
178 .config()
179 .issues
180 .attachment
181 .presign_ttl_secs
182 .min(604_800);
183 // The storage client signs with the `http` 0.2 header map (see chat's
184 // attachment pipeline); axum's own re-export is the 1.x line.
185 let mut put_headers = http::HeaderMap::new();
186 if let Ok(value) = cmd.content_type.parse() {
187 put_headers.insert(http::header::CONTENT_TYPE, value);
188 }
189 let upload_url = self
190 .state
191 .storage
192 .client()
193 .presign_put_with_headers(&key, ttl as u32, put_headers)
194 .map_err(storage_failure)?;
195
196 Ok(IssueUploadIntentDto {
197 id,
198 upload_url,
199 expires_secs: ttl,
200 })
201 }
202
203 pub async fn finalize_issue_attachment(
204 &self,
205 caller: Uuid,
206 attachment_id: Uuid,
207 ) -> Result<IssueAttachmentDto, IssueError> {
208 let row = issue_attachment::Entity::find_by_id(attachment_id)
209 .one(self.db().write())
210 .await?
211 .filter(|row| row.uploader_id == caller)
212 .ok_or(IssueError::NotFound)?;
213 if row.status != IssueAttachmentStatus::Pending {
214 return Err(IssueError::Conflict(
215 "attachment already finalized".to_string(),
216 ));
217 }
218
219 let head = self
220 .state
221 .storage
222 .client()
223 .head(&row.s3_key)
224 .await
225 .map_err(storage_failure)?
226 .ok_or_else(|| IssueError::BadRequest("object not uploaded yet".to_string()))?;
227 let (actual_size, actual_content_type) = head;
228 let max_size = self.config().issues.attachment.max_size;
229 if actual_size == 0 || actual_size > max_size as u64 {
230 return Err(IssueError::BadRequest(format!(
231 "uploaded object size {actual_size} outside 1..{max_size} bytes"
232 )));
233 }
234 let content_type = if actual_content_type.is_empty() {
235 row.content_type.clone()
236 } else {
237 actual_content_type
238 };
239 if is_unsafe_inline(&content_type) {
240 return Err(IssueError::BadRequest(
241 "uploaded object has an unsafe content_type".to_string(),
242 ));
243 }
244
245 let mut active = row.into_active_model();
246 active.size = Set(actual_size as i64);
247 active.content_type = Set(content_type);
248 active.status = Set(IssueAttachmentStatus::Uploaded);
249 active.updated_at = Set(OffsetDateTime::now_utc());
250 Ok(IssueAttachmentDto::from(
251 active.update(self.db().write()).await?,
252 ))
253 }
254
255 /// Removes the row and, best effort, the stored object.
256 pub async fn delete_issue_attachment(
257 &self,
258 caller: Uuid,
259 attachment_id: Uuid,
260 ) -> Result<(), IssueError> {
261 let row = issue_attachment::Entity::find_by_id(attachment_id)
262 .one(self.db().write())
263 .await?
264 .ok_or(IssueError::NotFound)?;
265 if row.uploader_id != caller {
266 return Err(IssueError::NotFound);
267 }
268
269 if row.status == IssueAttachmentStatus::Uploaded
270 && let Err(err) = self.state.storage.client().delete(&row.s3_key).await
271 {
272 tracing::warn!(error = %err, key = %row.s3_key, "failed to delete issue attachment object");
273 }
274 issue_attachment::Entity::delete_by_id(row.id)
275 .exec(self.db().write())
276 .await?;
277 Ok(())
278 }
279
280 pub async fn list_issue_attachments(
281 &self,
282 caller: Uuid,
283 space_name: &str,
284 board_id: Uuid,
285 issue_ref: &str,
286 ) -> Result<Vec<IssueAttachmentDto>, IssueError> {
287 let issue_id = self
288 .resolve_issue_ref(caller, space_name, board_id, issue_ref)
289 .await?;
290 let rows = issue_attachment::Entity::find()
291 .filter(issue_attachment::Column::IssueId.eq(issue_id))
292 .filter(issue_attachment::Column::Status.eq(IssueAttachmentStatus::Uploaded))
293 .order_by_asc(issue_attachment::Column::CreatedAt)
294 .all(self.db().read())
295 .await?;
296 Ok(rows.into_iter().map(IssueAttachmentDto::from).collect())
297 }
298
299 /// Streams one stored object; the caller is either the uploader or someone
300 /// who can read the issue the file is attached to.
301 pub async fn download_issue_attachment(
302 &self,
303 caller: Uuid,
304 attachment_id: Uuid,
305 ) -> Result<(issue_attachment::Model, Body, String), IssueError> {
306 let row = issue_attachment::Entity::find_by_id(attachment_id)
307 .one(self.db().read())
308 .await?
309 .ok_or(IssueError::NotFound)?;
310 if row.uploader_id != caller {
311 let issue_id = row.issue_id.ok_or(IssueError::NotFound)?;
312 issue_context_by_id(self, caller, issue_id).await?;
313 }
314 if row.status != IssueAttachmentStatus::Uploaded {
315 return Err(IssueError::NotFound);
316 }
317
318 let stream = self
319 .state
320 .storage
321 .client()
322 .get_stream(&row.s3_key)
323 .await
324 .map_err(storage_failure)?;
325 let body = Body::from_stream(stream);
326 let content_type = if is_unsafe_inline(&row.content_type) {
327 "application/octet-stream".to_string()
328 } else {
329 row.content_type.clone()
330 };
331 Ok((row, body, content_type))
332 }
333
334 /// Response headers for a streamed attachment: no sniffing, and unsafe
335 /// types always download instead of rendering inline.
336 pub fn issue_attachment_content_headers(
337 row: &issue_attachment::Model,
338 content_type: String,
339 ) -> Vec<(header::HeaderName, String)> {
340 let disposition = if is_inline_renderable(&row.content_type) {
341 format!("inline; filename=\"{}\"", row.filename.replace('"', "_"))
342 } else {
343 format!(
344 "attachment; filename=\"{}\"",
345 row.filename.replace('"', "_")
346 )
347 };
348 vec![
349 (header::CONTENT_TYPE, content_type),
350 (
351 header::HeaderName::from_static("x-content-type-options"),
352 "nosniff".to_string(),
353 ),
354 (header::CONTENT_DISPOSITION, disposition),
355 ]
356 }
357
358 /// Links freshly uploaded drafts to the issue being created. Runs inside
359 /// the create transaction so a rejected attachment rolls the issue back.
360 pub(crate) async fn link_issue_attachments(
361 txn: &impl sea_orm::ConnectionTrait,
362 caller: Uuid,
363 issue_id: Uuid,
364 attachment_ids: &[Uuid],
365 now: OffsetDateTime,
366 ) -> Result<(), IssueError> {
367 for id in attachment_ids {
368 let updated = issue_attachment::Entity::update_many()
369 .filter(issue_attachment::Column::Id.eq(*id))
370 .filter(issue_attachment::Column::UploaderId.eq(caller))
371 .filter(issue_attachment::Column::Status.eq(IssueAttachmentStatus::Uploaded))
372 .filter(issue_attachment::Column::IssueId.is_null())
373 .col_expr(
374 issue_attachment::Column::IssueId,
375 sea_orm::sea_query::Expr::value(Some(issue_id)),
376 )
377 .col_expr(
378 issue_attachment::Column::UpdatedAt,
379 sea_orm::sea_query::Expr::value(now),
380 )
381 .exec(txn)
382 .await?;
383 if updated.rows_affected != 1 {
384 return Err(IssueError::BadRequest(format!(
385 "attachment {id} is not an uploaded, unused attachment of yours"
386 )));
387 }
388 }
389 Ok(())
390 }
391
392 /// Attachment rows of one issue, for the delete cascade (`Select`-free).
393 pub(crate) async fn issue_attachment_keys(
394 txn: &impl sea_orm::ConnectionTrait,
395 issue_id: Uuid,
396 ) -> Result<Vec<String>, IssueError> {
397 let keys = issue_attachment::Entity::find()
398 .select_only()
399 .column(issue_attachment::Column::S3Key)
400 .filter(issue_attachment::Column::IssueId.eq(issue_id))
401 .into_tuple::<String>()
402 .all(txn)
403 .await?;
404 Ok(keys)
405 }
406}
Modified lib/issues/src/services/boards.rs View lib/issues/src/services/boards.rs
@@ -25,6 +25,8 @@
2525 pub id: Uuid,
2626 pub dir_id: Uuid,
2727 pub name: String,
28 /// Number prefix for the board's issues (`CAD` renders as `CAD-12`).
29 pub prefix: Option<String>,
2830 pub icon: Option<String>,
2931 pub description: Option<String>,
3032 pub open_issues_count: i64,
@@ -41,6 +43,8 @@
4143 #[serde(default, alias = "dir_id")]
4244 pub parent_dir_id: Option<Uuid>,
4345 #[serde(default)]
46 pub prefix: Option<String>,
47 #[serde(default)]
4448 pub icon: Option<String>,
4549 #[serde(default)]
4650 pub description: Option<String>,
@@ -51,6 +55,8 @@
5155 #[serde(default)]
5256 pub name: Option<String>,
5357 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
58 pub prefix: Option<Option<String>>,
59 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
5460 pub icon: Option<Option<String>>,
5561 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
5662 pub description: Option<Option<String>>,
@@ -71,6 +77,55 @@
7177}
7278
7379impl IssueService {
80 /// `None`/blank clears the prefix; anything else is upper-cased and must
81 /// look like a GitLab-style project key (letter first, alphanumeric only).
82 fn normalize_board_prefix(&self, raw: &str) -> Result<Option<String>, IssueError> {
83 let trimmed = raw.trim();
84 if trimmed.is_empty() {
85 return Ok(None);
86 }
87 let prefix = trimmed.to_ascii_uppercase();
88 let first_is_letter = prefix.starts_with(|c: char| c.is_ascii_alphabetic());
89 let valid = first_is_letter
90 && prefix.chars().count() <= self.config().issues.max_board_prefix_len
91 && prefix.chars().all(|c| c.is_ascii_alphanumeric());
92 if !valid {
93 return Err(IssueError::BadRequest(
94 "board prefix must start with a letter and contain only letters and digits"
95 .to_string(),
96 ));
97 }
98 Ok(Some(prefix))
99 }
100
101 /// Prefixes address issues (`CAD-12`), so two boards of one space must not
102 /// share one — the ambiguity would be permanent in every pasted link.
103 async fn board_prefix_taken(
104 &self,
105 space_id: Uuid,
106 prefix: &str,
107 exclude_board: Option<Uuid>,
108 ) -> Result<bool, IssueError> {
109 let dir_ids: Vec<Uuid> = dirs::Entity::find()
110 .filter(dirs::Column::SpaceId.eq(Some(space_id)))
111 .filter(dirs::Column::Kind.eq(DirKind::Object))
112 .all(self.db().read())
113 .await?
114 .into_iter()
115 .map(|d| d.id)
116 .collect();
117 if dir_ids.is_empty() {
118 return Ok(false);
119 }
120 let mut select = issues::Entity::find()
121 .filter(issues::Column::DirId.is_in(dir_ids))
122 .filter(issues::Column::Prefix.eq(prefix));
123 if let Some(board_id) = exclude_board {
124 select = select.filter(issues::Column::Id.ne(board_id));
125 }
126 Ok(select.one(self.db().read()).await?.is_some())
127 }
128
74129 fn board_dto(
75130 board: issues::Model,
76131 open_issues_count: i64,
@@ -80,6 +135,7 @@
80135 id: board.id,
81136 dir_id: board.dir_id,
82137 name: board.name,
138 prefix: board.prefix,
83139 icon: board.icon,
84140 description: board.description,
85141 open_issues_count,
@@ -152,6 +208,18 @@
152208 ));
153209 }
154210
211 let prefix = match cmd.prefix.as_deref() {
212 Some(raw) => self.normalize_board_prefix(raw)?,
213 None => None,
214 };
215 if let Some(ref prefix) = prefix {
216 if self.board_prefix_taken(space.id, prefix, None).await? {
217 return Err(IssueError::BadRequest(
218 "board prefix is already used by another board".to_string(),
219 ));
220 }
221 }
222
155223 if let Some(p) = cmd.parent_dir_id {
156224 let parent = dirs::Entity::find_by_id(p)
157225 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
@@ -203,6 +271,7 @@
203271 id: Set(Uuid::now_v7()),
204272 dir_id: Set(dir.id),
205273 name: Set(name.to_string()),
274 prefix: Set(prefix),
206275 icon: Set(cmd.icon),
207276 description: Set(cmd.description),
208277 created_at: Set(now),
@@ -217,6 +286,7 @@
217286 id: board.id,
218287 dir_id: board.dir_id,
219288 name: board.name,
289 prefix: board.prefix,
220290 icon: board.icon,
221291 description: board.description,
222292 open_issues_count: 0,
@@ -348,6 +418,23 @@
348418 dir_am.name = Set(name.to_string());
349419 dir_changed = true;
350420 }
421 if let Some(raw_prefix) = cmd.prefix {
422 let prefix = match raw_prefix.as_deref() {
423 Some(raw) => self.normalize_board_prefix(raw)?,
424 None => None,
425 };
426 if let Some(ref prefix) = prefix {
427 if self
428 .board_prefix_taken(space.id, prefix, Some(board_id))
429 .await?
430 {
431 return Err(IssueError::BadRequest(
432 "board prefix is already used by another board".to_string(),
433 ));
434 }
435 }
436 am.prefix = Set(prefix);
437 }
351438 if let Some(icon) = cmd.icon {
352439 am.icon = Set(icon.clone());
353440 dir_am.icon = Set(icon);
Modified lib/issues/src/services/issues.rs View lib/issues/src/services/issues.rs
@@ -1,4 +1,5 @@
11use models::issue::comment;
2use models::issue::issue_attachment;
23use models::issue::issue_content_history;
34use models::issue::issue_dependency;
45use models::issue::issue_event_line;
@@ -31,6 +32,7 @@
3132use uuid::Uuid;
3233
3334use crate::errors::IssueError;
35use crate::services::labels::LabelDto;
3436use crate::services::service::IssueEventDetail;
3537use crate::services::service::IssueService;
3638use crate::share::authz::is_board_manager;
@@ -70,6 +72,8 @@
7072 pub number: i32,
7173 pub author: Option<UserDto>,
7274 pub title: String,
75 /// Chips the list rows render; the detail view fetches these separately.
76 pub labels: Vec<LabelDto>,
7377 pub milestone_id: Option<Uuid>,
7478 pub priority: IssuePriority,
7579 pub due_at: Option<OffsetDateTime>,
@@ -102,6 +106,9 @@
102106 pub assignee_ids: Vec<Uuid>,
103107 #[serde(default)]
104108 pub label_ids: Vec<Uuid>,
109 /// Draft uploads (`POST /issues/attachments`) to link to the new issue.
110 #[serde(default)]
111 pub attachment_ids: Vec<Uuid>,
105112}
106113
107114#[derive(Debug, Clone, Deserialize, ToSchema)]
@@ -131,16 +138,97 @@
131138pub struct ListIssueQuery {
132139 pub state: Option<IssueState>,
133140 pub author_id: Option<Uuid>,
134 pub milestone_id: Option<Uuid>,
135 pub label_id: Option<Uuid>,
136 pub assignee_id: Option<Uuid>,
137 pub priority: Option<IssuePriority>,
141 /// One id or a comma-separated id list (`a,b`); `none` matches issues
142 /// without a milestone. Any of the listed values matches.
143 pub milestone_id: Option<String>,
144 /// One id or a comma-separated id list (`a,b`); matches any of them.
145 pub label_id: Option<String>,
146 /// One id or a comma-separated id list (`a,b`); matches any of them.
147 pub assignee_id: Option<String>,
148 /// One priority or a comma-separated list (`high,urgent`).
149 pub priority: Option<String>,
138150 pub sort: Option<String>,
139151 pub q: Option<String>,
140152 pub limit: Option<u64>,
141153 pub offset: Option<u64>,
142154}
143155
156/// `a,b` / `a` / absent → parsed ids, rejecting anything malformed so a bad
157/// filter link answers 400 instead of silently listing everything.
158fn parse_filter_ids(raw: Option<&str>) -> Result<Vec<Uuid>, IssueError> {
159 let Some(raw) = raw else {
160 return Ok(Vec::new());
161 };
162 raw.split(',')
163 .map(str::trim)
164 .filter(|part| !part.is_empty())
165 .map(|part| {
166 Uuid::parse_str(part)
167 .map_err(|_| IssueError::BadRequest("invalid id in filter".to_string()))
168 })
169 .collect()
170}
171
172fn parse_filter_priorities(raw: Option<&str>) -> Result<Vec<IssuePriority>, IssueError> {
173 let Some(raw) = raw else {
174 return Ok(Vec::new());
175 };
176 raw.split(',')
177 .map(str::trim)
178 .filter(|part| !part.is_empty())
179 .map(|part| {
180 PRIORITY_KEYS
181 .iter()
182 .copied()
183 .find(|(key, _)| *key == part)
184 .map(|(_, priority)| priority)
185 .ok_or_else(|| IssueError::BadRequest("invalid priority in filter".to_string()))
186 })
187 .collect()
188}
189
190/// Milestones accept the extra `none` token, so "未设里程碑" is a server-side
191/// filter instead of a per-page client-side hack.
192fn parse_milestone_filter(raw: Option<&str>) -> Result<(Vec<Uuid>, bool), IssueError> {
193 let Some(raw) = raw else {
194 return Ok((Vec::new(), false));
195 };
196 let parts: Vec<&str> = raw
197 .split(',')
198 .map(str::trim)
199 .filter(|part| !part.is_empty())
200 .collect();
201 let include_none = parts.contains(&"none");
202 let ids = parts
203 .into_iter()
204 .filter(|part| *part != "none")
205 .map(|part| {
206 Uuid::parse_str(part)
207 .map_err(|_| IssueError::BadRequest("invalid id in filter".to_string()))
208 })
209 .collect::<Result<Vec<Uuid>, IssueError>>()?;
210 Ok((ids, include_none))
211}
212
213/// snake_case query keys, in the enum's own order — the one place the wire
214/// names for `?priority=` live.
215const PRIORITY_KEYS: [(&str, IssuePriority); 5] = [
216 ("none", IssuePriority::None),
217 ("low", IssuePriority::Low),
218 ("medium", IssuePriority::Medium),
219 ("high", IssuePriority::High),
220 ("urgent", IssuePriority::Urgent),
221];
222
223/// Inverse of {@link PRIORITY_KEYS}: the query string a caller should send.
224pub fn priority_query_key(priority: IssuePriority) -> &'static str {
225 PRIORITY_KEYS
226 .iter()
227 .find(|(_, value)| *value == priority)
228 .map(|(key, _)| *key)
229 .unwrap_or("none")
230}
231
144232#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
145233pub struct IssuePath {
146234 pub space_name: String,
@@ -176,6 +264,11 @@
176264 .exec(txn)
177265 .await?;
178266
267 issue_attachment::Entity::delete_many()
268 .filter(issue_attachment::Column::IssueId.eq(issue_id))
269 .exec(txn)
270 .await?;
271
179272 comment::Entity::delete_many()
180273 .filter(comment::Column::IssueId.eq(issue_id))
181274 .exec(txn)
@@ -271,6 +364,45 @@
271364 Ok(map)
272365 }
273366
367 /// Labels per issue for one page of rows, in the board's own label order.
368 pub(crate) async fn load_label_map(
369 &self,
370 issue_ids: &[Uuid],
371 ) -> Result<std::collections::HashMap<Uuid, Vec<LabelDto>>, IssueError> {
372 let mut map: std::collections::HashMap<Uuid, Vec<LabelDto>> =
373 std::collections::HashMap::new();
374 if issue_ids.is_empty() {
375 return Ok(map);
376 }
377 let rels = issue_relation_label::Entity::find()
378 .filter(issue_relation_label::Column::IssueId.is_in(issue_ids.to_vec()))
379 .all(self.db().read())
380 .await?;
381 if rels.is_empty() {
382 return Ok(map);
383 }
384 let label_ids: Vec<Uuid> = rels.iter().map(|r| r.label_id).collect();
385 let labels = issue_label::Entity::find()
386 .filter(issue_label::Column::Id.is_in(label_ids))
387 .all(self.db().read())
388 .await?;
389 let by_id: std::collections::HashMap<Uuid, LabelDto> = labels
390 .into_iter()
391 .map(|label| (label.id, LabelDto::from(label)))
392 .collect();
393 for rel in rels {
394 if let Some(label) = by_id.get(&rel.label_id) {
395 map.entry(rel.issue_id)
396 .or_insert_with(Vec::new)
397 .push(label.clone());
398 }
399 }
400 for rows in map.values_mut() {
401 rows.sort_by(|a, b| a.name.cmp(&b.name));
402 }
403 Ok(map)
404 }
405
274406 pub(crate) async fn build_issue_dto(
275407 &self,
276408 caller: Uuid,
@@ -397,6 +529,7 @@
397529 let due_val = cmd.due_at;
398530 let assignee_ids = cmd.assignee_ids.clone();
399531 let label_ids = cmd.label_ids.clone();
532 let attachment_ids = cmd.attachment_ids.clone();
400533 let space_lower_for_txn = space.lower_name.clone();
401534 let mentioned_for_txn = mentioned_uids.clone();
402535 let this = self.clone();
@@ -464,6 +597,15 @@
464597 link.insert(txn).await?;
465598 }
466599
600 IssueService::link_issue_attachments(
601 txn,
602 caller,
603 issue_id,
604 &attachment_ids,
605 now,
606 )
607 .await?;
608
467609 this.write_event(
468610 txn,
469611 issue_id,
@@ -585,8 +727,18 @@
585727 select = select.filter(issue_item::Column::AuthorId.eq(author));
586728 }
587729
588 if let Some(milestone) = q.milestone_id {
589 select = select.filter(issue_item::Column::MilestoneId.eq(Some(milestone)));
730 let (milestone_ids, include_no_milestone) =
731 parse_milestone_filter(q.milestone_id.as_deref())?;
732 if include_no_milestone && !milestone_ids.is_empty() {
733 select = select.filter(
734 Condition::any()
735 .add(issue_item::Column::MilestoneId.is_null())
736 .add(issue_item::Column::MilestoneId.is_in(milestone_ids)),
737 );
738 } else if include_no_milestone {
739 select = select.filter(issue_item::Column::MilestoneId.is_null());
740 } else if !milestone_ids.is_empty() {
741 select = select.filter(issue_item::Column::MilestoneId.is_in(milestone_ids));
590742 }
591743
592744 if let Some(ref search) = q.q {
@@ -596,9 +748,12 @@
596748 }
597749 }
598750
599 if let Some(label_id) = q.label_id {
751 let label_ids = parse_filter_ids(q.label_id.as_deref())?;
752 if !label_ids.is_empty() {
753 // Any-of: picking two labels widens the list, matching the panel's
754 // checkbox semantics rather than intersecting them.
600755 let issue_ids: Vec<Uuid> = issue_relation_label::Entity::find()
601 .filter(issue_relation_label::Column::LabelId.eq(label_id))
756 .filter(issue_relation_label::Column::LabelId.is_in(label_ids))
602757 .all(self.db().read())
603758 .await?
604759 .into_iter()
@@ -606,9 +761,10 @@
606761 .collect();
607762 select = select.filter(issue_item::Column::Id.is_in(issue_ids));
608763 }
609 if let Some(assignee_id) = q.assignee_id {
764 let assignee_ids = parse_filter_ids(q.assignee_id.as_deref())?;
765 if !assignee_ids.is_empty() {
610766 let issue_ids: Vec<Uuid> = issue_member::Entity::find()
611 .filter(issue_member::Column::UserId.eq(assignee_id))
767 .filter(issue_member::Column::UserId.is_in(assignee_ids))
612768 .filter(issue_member::Column::Kind.eq(issue_member::IssueMemberKind::Assignee))
613769 .all(self.db().read())
614770 .await?
@@ -617,8 +773,9 @@
617773 .collect();
618774 select = select.filter(issue_item::Column::Id.is_in(issue_ids));
619775 }
620 if let Some(priority) = q.priority {
621 select = select.filter(issue_item::Column::Priority.eq(priority));
776 let priorities = parse_filter_priorities(q.priority.as_deref())?;
777 if !priorities.is_empty() {
778 select = select.filter(issue_item::Column::Priority.is_in(priorities));
622779 }
623780
624781 match q.sort.as_deref() {
@@ -640,12 +797,14 @@
640797 let user_map = self.load_user_briefs(&author_ids).await?;
641798 let issue_ids: Vec<Uuid> = rows.iter().map(|i| i.id).collect();
642799 let assignee_map = self.load_assignee_map(&issue_ids).await?;
800 let label_map = self.load_label_map(&issue_ids).await?;
643801
644802 let items = rows
645803 .into_iter()
646804 .map(|i| {
647805 let author = user_map.get(&i.author_id).cloned();
648806 let assignees = assignee_map.get(&i.id).cloned().unwrap_or_default();
807 let labels = label_map.get(&i.id).cloned().unwrap_or_default();
649808 let state = if i.closed_at.is_some() {
650809 IssueState::Closed
651810 } else {
@@ -657,6 +816,7 @@
657816 number: i.autoincrement_id,
658817 author,
659818 title: i.title,
819 labels,
660820 milestone_id: i.milestone_id,
661821 priority: i.priority,
662822 due_at: i.due_at,
@@ -1067,6 +1227,10 @@
10671227 return Err(IssueError::Forbidden);
10681228 }
10691229
1230 // Objects live outside the transaction: collect the keys first, drop
1231 // the rows in the cascade, then clean storage best effort.
1232 let attachment_keys =
1233 IssueService::issue_attachment_keys(self.db().read(), issue_id).await?;
10701234 self.db()
10711235 .primary()
10721236 .transaction::<_, (), IssueError>(|txn| {
@@ -1077,6 +1241,12 @@
10771241 })
10781242 .await?;
10791243
1244 for key in attachment_keys {
1245 if let Err(err) = self.state.storage.client().delete(&key).await {
1246 tracing::warn!(error = %err, key = %key, "failed to delete issue attachment object");
1247 }
1248 }
1249
10801250 self.queue_search_issue(issue_id).await?;
10811251 Ok(())
10821252 }
Modified lib/issues/src/services/mod.rs View lib/issues/src/services/mod.rs
@@ -1,3 +1,4 @@
1pub mod attachments;
12pub mod boards;
23pub mod comments;
34pub mod content_history;
Modified lib/issues/src/share/lookup.rs View lib/issues/src/share/lookup.rs
@@ -86,12 +86,29 @@
8686 base.filter(issue_item::Column::AutoincrementId.eq(number))
8787 .one(service.db().read())
8888 .await?
89 } else if let Some(number) = board_prefix_number(board.prefix.as_deref(), issue_ref) {
90 // `CAD-12` keeps pasted links readable; only this board's own prefix
91 // resolves, so a foreign key never silently points at another board.
92 base.filter(issue_item::Column::AutoincrementId.eq(number))
93 .one(service.db().read())
94 .await?
8995 } else {
9096 None
9197 };
9298 item.ok_or(IssueError::NotFound)
9399}
94100
101/// `PREFIX-12` (case-insensitive) → `Some(12)` when the prefix matches the
102/// board's own; anything else is left to the plain-number path.
103fn board_prefix_number(prefix: Option<&str>, issue_ref: &str) -> Option<i32> {
104 let prefix = prefix?;
105 let (head, tail) = issue_ref.rsplit_once('-')?;
106 if !head.eq_ignore_ascii_case(prefix) {
107 return None;
108 }
109 tail.parse::<i32>().ok()
110}
111
95112pub async fn require_member(
96113 service: &IssueService,
97114 space: &spaces::Model,
@@ -150,3 +167,32 @@
150167 issue_context(service, caller, space_name, board_id, issue_id).await?;
151168 Ok(())
152169}
170
171/// Same access rules as [`issue_context`], resolved from the issue id alone —
172/// used by routes that carry no space/board in their path (attachment reads).
173pub async fn issue_context_by_id(
174 service: &IssueService,
175 caller: Uuid,
176 issue_id: Uuid,
177) -> Result<(spaces::Model, issues::Model, issue_item::Model), IssueError> {
178 let item = issue_item::Entity::find_by_id(issue_id)
179 .one(service.db().read())
180 .await?
181 .ok_or(IssueError::NotFound)?;
182 let board = issues::Entity::find_by_id(item.collection_id)
183 .one(service.db().read())
184 .await?
185 .ok_or(IssueError::NotFound)?;
186 let dir = dirs::Entity::find_by_id(board.dir_id)
187 .one(service.db().read())
188 .await?
189 .ok_or(IssueError::NotFound)?;
190 let space = spaces::Entity::find_by_id(dir.space_id.ok_or(IssueError::NotFound)?)
191 .filter(spaces::Column::IsActive.eq(true))
192 .one(service.db().read())
193 .await?
194 .ok_or(IssueError::NotFound)?;
195 require_member(service, &space, caller).await?;
196 let board = lookup_board(service, &space, board.id, caller).await?;
197 Ok((space, board, item))
198}
Modified lib/issues/src/openapi.rs View lib/issues/src/openapi.rs
@@ -4,6 +4,11 @@
44#[derive(OpenApi)]
55#[openapi(
66 paths(
7 crate::handlers::attachments::create_attachment::create_issue_attachment,
8 crate::handlers::attachments::finalize_attachment::finalize_issue_attachment,
9 crate::handlers::attachments::delete_attachment::delete_issue_attachment,
10 crate::handlers::attachments::list_attachments::list_issue_attachments,
11 crate::handlers::attachments::download_attachment::download_issue_attachment,
712 crate::handlers::boards::create_board::create_board,
813 crate::handlers::boards::list_boards::list_boards,
914 crate::handlers::boards::get_board::get_board,
Modified lib/issues/src/routes.rs View lib/issues/src/routes.rs
@@ -1,6 +1,11 @@
11use axum::Router;
22use axum::routing::{delete, get, post, put};
33
4use crate::handlers::attachments::create_attachment::create_issue_attachment;
5use crate::handlers::attachments::delete_attachment::delete_issue_attachment;
6use crate::handlers::attachments::download_attachment::download_issue_attachment;
7use crate::handlers::attachments::finalize_attachment::finalize_issue_attachment;
8use crate::handlers::attachments::list_attachments::list_issue_attachments;
49use crate::handlers::boards::create_board::create_board;
510use crate::handlers::boards::delete_board::delete_board;
611use crate::handlers::boards::get_board::get_board;
@@ -63,6 +68,22 @@
6368
6469pub fn router(service: IssueService) -> Router<IssueService> {
6570 Router::new()
71 .route(
72 "/issues/attachments",
73 post(create_issue_attachment),
74 )
75 .route(
76 "/issues/attachments/{attachment_id}/finalize",
77 post(finalize_issue_attachment),
78 )
79 .route(
80 "/issues/attachments/{attachment_id}",
81 delete(delete_issue_attachment),
82 )
83 .route(
84 "/issues/attachments/{attachment_id}/file",
85 get(download_issue_attachment),
86 )
6687 .route("/issues/{space_name}/boards", post(create_board).get(list_boards))
6788 .route(
6889 "/issues/{space_name}/boards/{board_id}",
@@ -81,6 +102,10 @@
81102 get(get_issue).patch(update_issue).delete(delete_issue),
82103 )
83104 .route(
105 "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/attachments",
106 get(list_issue_attachments),
107 )
108 .route(
84109 "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/close",
85110 post(close_issue),
86111 )
Modified lib/issues/Cargo.toml View lib/issues/Cargo.toml
@@ -13,6 +13,7 @@
1313session-layer = { workspace = true }
1414space = { workspace = true }
1515axum = { workspace = true }
16http = { workspace = true }
1617sea-orm = { workspace = true }
1718serde = { workspace = true, features = ["derive"] }
1819serde_json = { workspace = true }
Added lib/models/src/issue/issue_attachment.rs View lib/models/src/issue/issue_attachment.rs
@@ -0,0 +1,57 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6/// Upload lifecycle: a row is created before the bytes exist (`Pending`) and
7/// only an `Uploaded` row may be referenced by an issue.
8#[derive(
9 Debug,
10 Clone,
11 Copy,
12 PartialEq,
13 Eq,
14 Default,
15 EnumIter,
16 DeriveActiveEnum,
17 Serialize,
18 Deserialize,
19 utoipa::ToSchema,
20)]
21#[serde(rename_all = "snake_case")]
22#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
23pub enum IssueAttachmentStatus {
24 #[default]
25 #[sea_orm(num_value = 0)]
26 Pending,
27 #[sea_orm(num_value = 1)]
28 Uploaded,
29}
30
31#[sea_orm::model]
32#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
33#[sea_orm(table_name = "issue_attachment")]
34pub struct Model {
35 #[sea_orm(primary_key, auto_increment = false)]
36 pub id: Uuid,
37 /// `None` while the upload belongs to a draft the caller has not submitted.
38 #[sea_orm(indexed)]
39 pub issue_id: Option<Uuid>,
40 #[sea_orm(indexed)]
41 pub uploader_id: Uuid,
42 pub filename: String,
43 pub content_type: String,
44 pub size: i64,
45 pub bucket: String,
46 pub s3_key: String,
47 #[sea_orm(default_value = 0)]
48 pub status: IssueAttachmentStatus,
49 pub created_at: OffsetDateTime,
50 pub updated_at: OffsetDateTime,
51 #[sea_orm(belongs_to, from = "issue_id", to = "id")]
52 pub issue: BelongsTo<Option<super::issue_item::Entity>>,
53 #[sea_orm(belongs_to, from = "uploader_id", to = "uid")]
54 pub uploader: BelongsTo<super::super::users::user::Entity>,
55}
56
57impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/src/issue/issue_event_line.rs View lib/models/src/issue/issue_event_line.rs
@@ -62,6 +62,10 @@
6262 RepoRelationAdded,
6363 #[sea_orm(string_value = "repo_relation_removed")]
6464 RepoRelationRemoved,
65 #[sea_orm(string_value = "pull_linked")]
66 PullLinked,
67 #[sea_orm(string_value = "pull_unlinked")]
68 PullUnlinked,
6569}
6670
6771#[sea_orm::model]
Modified lib/models/src/issue/issues.rs View lib/models/src/issue/issues.rs
@@ -10,6 +10,8 @@
1010 pub id: Uuid,
1111 pub dir_id: Uuid,
1212 pub name: String,
13 /// Key prefix for issue numbers inside this board (`CAD-12`).
14 pub prefix: Option<String>,
1315 pub icon: Option<String>,
1416 pub description: Option<String>,
1517 pub created_at: OffsetDateTime,
Modified lib/models/src/issue/mod.rs View lib/models/src/issue/mod.rs
@@ -1,6 +1,7 @@
11#![allow(clippy::module_inception)]
22
33pub mod comment;
4pub mod issue_attachment;
45pub mod issue_content_history;
56pub mod issue_dependency;
67pub mod issue_event_line;
Modified web/src/entities/issue/model/api.ts View web/src/entities/issue/model/api.ts
@@ -20,10 +20,12 @@
2020 IssueDetail,
2121 IssueEvent,
2222 IssueLabel,
23 IssueAttachment,
2324 IssuePage,
2425 IssuePatchCmd,
2526 IssueQuery,
2627 IssueState,
28 IssueUploadIntent,
2729 Milestone,
2830 NewIssueCmd,
2931 SpaceMember,
@@ -38,6 +40,10 @@
3840 */
3941const nz = <T,>(value: T | '' | null | undefined): T | undefined => (value ? value : undefined)
4042
43/** Empty selections are dropped; several become `a,b` (see the backend parser). */
44const csv = (values: readonly string[] | undefined): string | undefined =>
45 values && values.length > 0 ? values.join(',') : undefined
46
4147/** Board id path params repeated by almost every issue endpoint. */
4248export interface BoardRef {
4349 space: string
@@ -63,7 +69,10 @@
6369export const getBoard = ({ space, board }: BoardRef) =>
6470 unwrapApi<Board>(Apis.issues.get_board({ pathParams: { space_name: space, board_id: board } }))
6571
66export const updateBoard = ({ space, board }: BoardRef, cmd: { name?: string; description?: string | null; icon?: string | null }) =>
72export const updateBoard = (
73 { space, board }: BoardRef,
74 cmd: { name?: string; prefix?: string | null; description?: string | null; icon?: string | null },
75) =>
6776 oneOf<Board>(
6877 Apis.issues.update_board({ pathParams: { space_name: space, board_id: board }, data: cmd }),
6978 )
@@ -85,10 +94,12 @@
8594 // omitting the key at all is what "no state filter" means.
8695 state: query.state && query.state !== 'all' ? (query.state as IssueState) : undefined,
8796 q: nz(query.q),
88 priority: nz(query.priority),
89 label_id: nz(query.labelId),
90 milestone_id: nz(query.milestoneId),
91 assignee_id: nz(query.assigneeId),
97 // Multi-select travels as one comma-separated value: the query string
98 // stays readable and the OpenAPI param stays a plain string.
99 priority: csv(query.priorities),
100 label_id: csv(query.labelIds),
101 milestone_id: csv(query.milestoneIds),
102 assignee_id: csv(query.assigneeIds),
92103 author_id: nz(query.authorId),
93104 sort: query.sort ? SORT_PARAMS[query.sort] : undefined,
94105 limit: query.limit,
@@ -115,6 +126,7 @@
115126 milestone_id: cmd.milestone_id ?? null,
116127 assignee_ids: cmd.assignee_ids ?? [],
117128 label_ids: cmd.label_ids ?? [],
129 attachment_ids: cmd.attachment_ids ?? [],
118130 },
119131 }),
120132 )
@@ -304,3 +316,56 @@
304316 pageOf<SpaceMember>(
305317 Apis.space.list_space_members({ pathParams: { space_name: space }, params: { limit: 100 } }),
306318 )
319
320/* ------------------------------------------------------------ attachments */
321
322/**
323 * Attachments follow the chat upload shape: reserve a row + presigned PUT,
324 * upload the bytes straight to the object store, then finalize (which verifies
325 * the real size/type). A draft is linked to the issue by `createIssue`'s
326 * `attachment_ids`; only finalized drafts can be linked.
327 */
328export const createIssueAttachment = (cmd: {
329 space_name: string
330 filename: string
331 content_type: string
332 size: number
333}) => oneOf<IssueUploadIntent>(Apis.issues.create_issue_attachment({ data: cmd }))
334
335export const finalizeIssueAttachment = (attachmentId: string) =>
336 oneOf<IssueAttachment>(
337 Apis.issues.finalize_issue_attachment({
338 pathParams: { attachment_id: attachmentId },
339 }),
340 )
341
342export const deleteIssueAttachment = (attachmentId: string) =>
343 unwrapApi<null>(
344 Apis.issues.delete_issue_attachment({ pathParams: { attachment_id: attachmentId } }),
345 )
346
347export const listIssueAttachments = ({ space, board }: BoardRef, issueId: string) =>
348 listOf<IssueAttachment>(
349 Apis.issues.list_issue_attachments({
350 pathParams: { space_name: space, board_id: board, issue_id: issueId },
351 }),
352 )
353
354/** Same-origin download/file URL for one attachment (works for `<img>` too). */
355export function issueAttachmentUrl(attachmentId: string): string {
356 return `/api/v1/issues/attachments/${encodeURIComponent(attachmentId)}/file`
357}
358
359/**
360 * PUT the raw bytes to the presigned URL. The object store allows `*` origins,
361 * so the browser talks to it directly and a dead bucket never looks like a
362 * dead API to the outage gate.
363 */
364export async function putIssueUpload(uploadUrl: string, file: File): Promise<void> {
365 const response = await fetch(uploadUrl, {
366 method: 'PUT',
367 headers: { 'content-type': file.type || 'application/octet-stream' },
368 body: file,
369 })
370 if (!response.ok) throw new Error(`upload failed: ${response.status}`)
371}
Modified web/src/entities/issue/model/issue.ts View web/src/entities/issue/model/issue.ts
@@ -10,7 +10,9 @@
1010import type {
1111 AppResponseData_BoardDto,
1212 AppResponseData_CommentDto,
13 AppResponseData_IssueAttachmentDto,
1314 AppResponseData_IssueDto,
15 AppResponseData_IssueUploadIntentDto,
1416 AppResponseData_LabelDto,
1517 AppResponseData_MilestoneDto,
1618 AppResponseData_Vec_CommentTreeNode,
@@ -34,6 +36,12 @@
3436/** Milestone (`GET …/milestones`). */
3537export type Milestone = NonNullable<AppResponseData_MilestoneDto['data']>
3638
39/** One issue attachment (`GET …/issues/{id}/attachments`). */
40export type IssueAttachment = NonNullable<AppResponseData_IssueAttachmentDto['data']>
41
42/** Presigned upload intent (`POST /issues/attachments`). */
43export type IssueUploadIntent = NonNullable<AppResponseData_IssueUploadIntentDto['data']>
44
3745/** Issue comment, flat or inside a thread tree. */
3846export type IssueComment = NonNullable<AppResponseData_CommentDto['data']>
3947export type CommentNode = NonNullable<
@@ -47,6 +55,7 @@
4755 number: number
4856 author?: UserDto | null
4957 title: string
58 labels: IssueLabel[]
5059 milestone_id?: string | null
5160 priority: IssuePriority
5261 due_at?: string | null
@@ -92,10 +101,12 @@
92101export interface IssueQuery {
93102 state?: IssueState | 'all'
94103 q?: string
95 priority?: IssuePriority
96 labelId?: string
97 milestoneId?: string
98 assigneeId?: string
104 /** Multi-select: any of these priorities matches. */
105 priorities?: IssuePriority[]
106 labelIds?: string[]
107 /** `none` is a UI-only token meaning "no milestone" (filtered client-side). */
108 milestoneIds?: string[]
109 assigneeIds?: string[]
99110 authorId?: string
100111 sort?: IssueSort
101112 limit?: number
@@ -116,10 +127,11 @@
116127 state: IssueState | 'all'
117128 /** Title keyword (backend `q`). */
118129 q: string
119 priority: IssuePriority | ''
120 labelId: string
121 milestoneId: string
122 assigneeId: string
130 /** Filter panel dimensions; empty arrays mean "no constraint". */
131 priorities: IssuePriority[]
132 labelIds: string[]
133 milestoneIds: string[]
134 assigneeIds: string[]
123135 sort: IssueSort
124136 /** 1-based page within the filtered result set. */
125137 page: number
@@ -137,6 +149,8 @@
137149 milestone_id?: string | null
138150 assignee_ids?: string[]
139151 label_ids?: string[]
152 /** Draft upload ids from `POST /issues/attachments` to link to the new issue. */
153 attachment_ids?: string[]
140154}
141155
142156/**
Modified web/src/entities/issue/model/meta.ts View web/src/entities/issue/model/meta.ts
@@ -5,8 +5,46 @@
55 * the detail sidebar all have to agree on them.
66 */
77import { getLocale, isMessageKey, t } from '@/shared/i18n'
8import { fmtRelative } from '@/shared/lib'
89import type { IssueBoardView, IssuePriority, IssueState, IssueSort, WatchMode } from './issue'
910
11/** `issues.attachment.max_size` — the backend rejects anything larger. */
12export const MAX_ATTACHMENT_BYTES = 26_214_400
13
14/** `issues.max_board_prefix_len` — mirrored so the dialog validates before sending. */
15export const MAX_BOARD_PREFIX_LEN = 10
16
17/**
18 * Board-scoped issue key as the backend resolves it (`CAD-12`), falling back to
19 * the bare number so boards without a prefix keep today's links.
20 */
21export function issueRef(board: { prefix?: string | null } | null | undefined, number: number): string {
22 const prefix = board?.prefix?.trim()
23 return prefix ? `${prefix}-${number}` : String(number)
24}
25
26/** Display form of {@link issueRef}: `CAD-12`, or `#12` without a prefix. */
27export function issueLabel(board: { prefix?: string | null } | null | undefined, number: number): string {
28 const prefix = board?.prefix?.trim()
29 return prefix ? `${prefix}-${number}` : `#${number}`
30}
31
32/**
33 * Row stamp, Linear-style: relative while the change is fresh, then the month
34 * (`2026年10月` / `Oct 2026`) once "3天前" stops being useful.
35 */
36export function issueStamp(value: string | null | undefined): string {
37 const date = parseIssueDate(value)
38 if (!date) return ''
39 if (Date.now() - date.getTime() < 7 * 86_400_000) return fmtRelative(value as string)
40 return new Intl.DateTimeFormat(getLocale(), { month: 'short', year: 'numeric' }).format(date)
41}
42
43/** `^[A-Z][A-Z0-9]{0,9}$` — the shape `PATCH …/boards` accepts. */
44export function isValidBoardPrefix(value: string): boolean {
45 return /^[A-Z][A-Z0-9]{0,9}$/.test(value)
46}
47
1048export interface PriorityMeta {
1149 id: IssuePriority
1250 label: string
Modified web/src/features/issue-board/model/prefetch.ts View web/src/features/issue-board/model/prefetch.ts
@@ -18,10 +18,10 @@
1818const DEFAULT_QUERY: IssueQuery = {
1919 state: 'open',
2020 q: undefined,
21 priority: undefined,
22 labelId: undefined,
23 milestoneId: undefined,
24 assigneeId: undefined,
21 priorities: undefined,
22 labelIds: undefined,
23 milestoneIds: undefined,
24 assigneeIds: undefined,
2525 sort: 'created',
2626 limit: ISSUE_PAGE_SIZE,
2727 offset: 0,
Modified web/src/features/issue-board/model/use-board-data.ts View web/src/features/issue-board/model/use-board-data.ts
@@ -5,6 +5,7 @@
55 * back to "加载失败" inside their own menus.
66 */
77import { boardKey, spaceMembersPageKey, useAsync } from '@/shared/lib'
8import { useRef } from 'react'
89import {
910 getBoard,
1011 listIssues,
@@ -24,6 +25,18 @@
2425 const issues = useAsync(() => listIssues(ref, query), [ref.space, ref.board, queryKey, reloadKey], {
2526 cacheKey: boardKey(ref.space, ref.board, `issues:${queryKey}`),
2627 })
28 /**
29 * The cache key carries the filters, so every filter change is a *cold* key
30 * and `useAsync` would drop the rows it was showing — one tick of the filter
31 * panel painted a skeleton. Keep this board's previous page on screen while
32 * the next one loads; a board switch still starts from a clean slate.
33 */
34 const lastPage = useRef<{ board: string; page: NonNullable<typeof issues.data> } | null>(null)
35 if (issues.data) lastPage.current = { board: ref.board, page: issues.data }
36 const keepLast = !issues.data && !issues.error && lastPage.current?.board === ref.board
37 const issuesState = keepLast
38 ? { ...issues, data: lastPage.current!.page, refreshing: true }
39 : issues
2740 // eslint-disable-next-line react-hooks/exhaustive-deps
2841 const board = useAsync(() => getBoard(ref), [ref.space, ref.board, reloadKey], {
2942 cacheKey: boardKey(ref.space, ref.board, 'board'),
@@ -41,7 +54,7 @@
4154 cacheKey: spaceMembersPageKey(ref.space),
4255 })
4356
44 return { board, issues, labels, milestones, members }
57 return { board, issues: issuesState, labels, milestones, members }
4558}
4659
4760export type BoardData = ReturnType<typeof useBoardData>
Modified web/src/features/issue-board/ui/board-filters.tsx View web/src/features/issue-board/ui/board-filters.tsx
@@ -1,14 +1,33 @@
11/**
2 * Board toolbar: the search box, the server-side filters and the sort switch.
3 * Every control writes route state through `onFilter`, so a filtered board is
4 * copy-pasteable and survives a reload.
2 * Board chrome, in the two layers the page actually has:
3 *
4 * header — 看板/列表 tabs beside the title, primary “新建工单” on the right
5 * toolbar — 搜索 · 状态 · 排序 · 筛选 (n) · ⋯
6 * chips — “共 N 个结果” + one removable chip per applied value
7 *
8 * The 筛选 panel owns the four low-frequency dimensions (优先级 / 标签 / 里程碑 /
9 * 负责人) as multi-selects. Every toggle applies immediately — the popover stays
10 * open while the result count behind it moves, which is the feedback an “应用”
11 * button would only defer.
512 */
6import { useEffect, useState } from 'react'
7import { LayoutGrid, List, Plus, Search, Settings2, X } from 'lucide-react'
13import { useEffect, useRef, useState } from 'react'
14import {
15 Check,
16 ChevronDown,
17 LayoutGrid,
18 List,
19 MoreHorizontal,
20 Plus,
21 Search,
22 Settings2,
23 SlidersHorizontal,
24 X,
25} from 'lucide-react'
826import {
927 PRIORITIES,
1028 SORTS,
1129 STATE_FILTERS,
30 normalizeColor,
1231 priorityOf,
1332 type IssueBoardView,
1433 type IssueLabel,
@@ -21,8 +40,55 @@
2140import { useT } from '@/shared/i18n'
2241import { FilterMenu } from './parts'
2342
43/** Add/remove one value from a multi-select. */
44function toggleValue(values: readonly string[], value: string): string[] {
45 return values.includes(value) ? values.filter(item => item !== value) : [...values, value]
46}
47
48/* ------------------------------------------------------------ view tabs --- */
49
50/**
51 * 看板 / 列表 is a page-level choice, not a filter: it sits with the title in
52 * the header, so the toolbar below only ever talks about narrowing a list.
53 */
54export function BoardViewTabs({
55 value,
56 onChange,
57}: {
58 value: IssueBoardView['view']
59 onChange: (view: IssueBoardView['view']) => void
60}) {
61 const { t } = useT()
62 return (
63 <div className="board-view-tabs" role="group" aria-label={t('issue.filter.viewGroup')}>
64 {(
65 [
66 ['board', t('issue.board.viewBoard'), LayoutGrid],
67 ['list', t('issue.board.viewList'), List],
68 ] as const
69 ).map(([id, label, Icon]) => (
70 <button
71 key={id}
72 type="button"
73 aria-pressed={value === id}
74 title={label}
75 onClick={() => onChange(id)}
76 className="board-view-tab"
77 >
78 <Icon className="h-3.5 w-3.5" aria-hidden />
79 {label}
80 </button>
81 ))}
82 </div>
83 )
84}
85
86/* --------------------------------------------------------------- toolbar --- */
87
2488export interface BoardFiltersProps {
2589 view: IssueBoardView
90 /** Rows matching the filters — shown once anything narrows the list. */
91 total: number
2692 filtered: boolean
2793 labels: IssueLabel[]
2894 labelsFailed: boolean
@@ -30,10 +96,13 @@
3096 members: UserDto[]
3197 onFilter: (updates: Partial<IssueBoardView>) => void
3298 onClear: () => void
99 onCreate: () => void
100 onManage: () => void
33101}
34102
35103export function BoardFilters({
36104 view,
105 total,
37106 filtered,
38107 labels,
39108 labelsFailed,
@@ -41,11 +110,18 @@
41110 members,
42111 onFilter,
43112 onClear,
113 onCreate,
114 onManage,
44115}: BoardFiltersProps) {
45116 const { t } = useT()
46117 // The input keeps its own text while the debounce is pending, and re-syncs
47118 // whenever the URL wins (back button, cleared chip, another tab's change).
48119 const [text, setText] = useState(view.q)
120 const [panelOpen, setPanelOpen] = useState(false)
121 const [menuOpen, setMenuOpen] = useState(false)
122 const search = useRef<HTMLInputElement | null>(null)
123 const bar = useRef<HTMLDivElement | null>(null)
124
49125 useEffect(() => setText(view.q), [view.q])
50126 useEffect(() => {
51127 if (text === view.q) return
@@ -54,138 +130,313 @@
54130 // eslint-disable-next-line react-hooks/exhaustive-deps
55131 }, [text])
56132
57 const milestoneName = (id: string) => milestones.find(m => m.id === id)?.name
58 const memberName = (id: string) => members.find(u => u.user_id === id)?.display_name
133 // Outside click closes whatever the toolbar has open.
134 useEffect(() => {
135 if (!panelOpen && !menuOpen) return
136 const onDown = (event: MouseEvent) => {
137 if (bar.current?.contains(event.target as Node)) return
138 setPanelOpen(false)
139 setMenuOpen(false)
140 }
141 window.addEventListener('mousedown', onDown)
142 return () => window.removeEventListener('mousedown', onDown)
143 }, [panelOpen, menuOpen])
144
145 /**
146 * Page shortcuts. `/` runs in the capture phase and stops propagation so the
147 * global command palette (which also claims `/`) does not swallow it; `c`
148 * creates an issue; `Esc` closes this toolbar first, then clears filters.
149 * Typing fields, open menus and open dialogs keep their own keys.
150 */
151 useEffect(() => {
152 const onKey = (event: KeyboardEvent) => {
153 if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return
154 const target = event.target as HTMLElement | null
155 const typing = Boolean(target?.closest('input, textarea, select, [contenteditable="true"]'))
156 const blocked = typing || Boolean(document.querySelector('[role="dialog"]'))
157 if (event.key === '/' && !blocked) {
158 event.preventDefault()
159 event.stopPropagation()
160 search.current?.focus()
161 return
162 }
163 if (blocked) return
164 if (event.key === 'c' || event.key === 'C') {
165 event.preventDefault()
166 onCreate()
167 return
168 }
169 if (event.key === 'Escape') {
170 // An open sort menu owns Esc until it closes.
171 if (document.querySelector('[role="listbox"]')) return
172 if (panelOpen || menuOpen) {
173 setPanelOpen(false)
174 setMenuOpen(false)
175 return
176 }
177 if (filtered) onClear()
178 }
179 }
180 window.addEventListener('keydown', onKey, true)
181 return () => window.removeEventListener('keydown', onKey, true)
182 }, [filtered, onClear, onCreate, panelOpen, menuOpen])
183
184 const activeCount =
185 view.priorities.length + view.labelIds.length + view.milestoneIds.length + view.assigneeIds.length
59186
60187 return (
61 <div className="board-filters flex min-w-0 flex-wrap items-center gap-2">
62 <label className="board-search flex h-7 min-w-[180px] max-w-[280px] flex-1 items-center gap-1.5 rounded-md bg-shell-field px-2 focus-within:ring-2 focus-within:ring-shell-tile">
63 <Search className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
64 <input
65 value={text}
66 onChange={event => setText(event.target.value)}
67 placeholder={t('issue.filter.searchPlaceholder')}
68 aria-label={t('issue.filter.searchAria')}
69 className="min-w-0 flex-1 bg-transparent text-[12.5px] outline-none placeholder:text-shell-ink-muted"
188 <div className="board-filters" ref={bar}>
189 <div className="board-filters-row">
190 <label className="board-search" title={t('issue.filter.searchHint')}>
191 <Search className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
192 <input
193 ref={search}
194 value={text}
195 onChange={event => setText(event.target.value)}
196 placeholder={t('issue.filter.searchPlaceholder')}
197 aria-label={t('issue.filter.searchAria')}
198 className="min-w-0 flex-1 bg-transparent text-[12.5px] outline-none placeholder:text-shell-ink-muted"
199 />
200 {text ? (
201 <button type="button" aria-label={t('issue.filter.clearSearch')} onClick={() => setText('')} className="board-search-clear">
202 <X className="h-3.5 w-3.5" />
203 </button>
204 ) : null}
205 </label>
206
207 <div className="board-segmented" role="group" aria-label={t('issue.filter.statusGroup')}>
208 {STATE_FILTERS.map(item => (
209 <button
210 key={item.id}
211 type="button"
212 aria-pressed={view.state === item.id}
213 onClick={() => onFilter({ state: item.id as IssueState | 'all' })}
214 >
215 {item.label}
216 </button>
217 ))}
218 </div>
219
220 <FilterMenu
221 label={t('issue.board.filter.sort')}
222 value={view.sort}
223 active={t('issue.filter.sortActive', { label: (SORTS.find(s => s.id === view.sort) ?? SORTS[0]).label })}
224 options={SORTS.map(sort => ({ value: sort.id, label: sort.label }))}
225 onChange={value => onFilter({ sort: value as IssueSort })}
70226 />
71 {text ? (
72 <button type="button" aria-label={t('issue.filter.clearSearch')} onClick={() => setText('')} className="shrink-0 text-shell-ink-muted hover:text-shell-ink">
73 <X className="h-3.5 w-3.5" />
74 </button>
75 ) : null}
76 </label>
77227
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')}>
79 {STATE_FILTERS.map(item => (
228 {/* The panel hangs off this trigger, not off the toolbar row: a popover
229 that opens under the search box reads as "search options". */}
230 <div className="board-filter-anchor">
80231 <button
81 key={item.id}
82232 type="button"
83 aria-pressed={view.state === item.id}
84 onClick={() => onFilter({ state: item.id as IssueState | 'all' })}
85 className={`rounded px-2 py-1 text-[12.5px] transition-colors ${
86 view.state === item.id ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:text-shell-ink'
87 }`}
233 className="board-filter-trigger board-filter-open"
234 aria-expanded={panelOpen}
235 aria-haspopup="dialog"
236 onClick={() => {
237 setPanelOpen(open => !open)
238 setMenuOpen(false)
239 }}
88240 >
89 {item.label}
241 <SlidersHorizontal className="h-3.5 w-3.5 shrink-0" aria-hidden />
242 <span className="board-filter-trigger-value">{t('issue.filter.panelTitle')}</span>
243 {activeCount > 0 ? <span className="board-filter-count">{activeCount}</span> : null}
244 <ChevronDown className="board-filter-chevron h-3 w-3 shrink-0 opacity-70" aria-hidden />
90245 </button>
91 ))}
92 </div>
93246
94 <FilterMenu
95 label={t('issue.board.filter.priority')}
96 placeholder={t('issue.filter.allPriorities')}
97 value={view.priority}
98 options={PRIORITIES.map(meta => ({ value: meta.id, label: meta.label, color: meta.color }))}
99 onChange={value => onFilter({ priority: value as IssuePriority | '' })}
100 />
101 <FilterMenu
102 label={t('issue.board.filter.label')}
103 placeholder={labelsFailed ? t('issue.filter.labelsFailed') : t('issue.filter.allLabels')}
104 value={view.labelId}
105 options={labels.map(label => ({ value: label.id, label: label.name, color: label.color }))}
106 onChange={value => onFilter({ labelId: value })}
107 />
108 <FilterMenu
109 label={t('issue.board.filter.milestone')}
110 placeholder={t('issue.filter.allMilestones')}
111 value={view.milestoneId}
112 options={[
113 ...milestones.map(m => ({
114 value: m.id,
115 label: m.name,
116 hint: m.is_closed ? t('label.issueState.closed') : undefined,
117 })),
118 // `none` is not a milestone id: the backend cannot filter it, so it is
119 // handled client-side on the loaded page (see `IssueBoardBody`).
120 { value: 'none', label: t('issue.filter.noMilestone') },
121 ]}
122 onChange={value => onFilter({ milestoneId: value })}
123 />
124 <FilterMenu
125 label={t('issue.board.filter.assignee')}
126 placeholder={t('issue.filter.allAssignees')}
127 value={view.assigneeId}
128 options={members.map(user => ({
129 value: user.user_id,
130 label: user.display_name || user.username,
131 hint: user.username ? `@${user.username}` : undefined,
132 }))}
133 onChange={value => onFilter({ assigneeId: value })}
134 />
135 <FilterMenu
136 label={t('issue.board.filter.sort')}
137 value={view.sort}
138 active={t('issue.filter.sortActive', { label: (SORTS.find(s => s.id === view.sort) ?? SORTS[0]).label })}
139 options={SORTS.map(sort => ({ value: sort.id, label: sort.label }))}
140 onChange={value => onFilter({ sort: value as IssueSort })}
141 />
247 {panelOpen ? (
248 <div className="board-filter-panel" role="dialog" aria-label={t('issue.filter.panelTitle')}>
249 <FilterSection
250 title={t('issue.board.filter.priority')}
251 options={PRIORITIES.map(meta => ({ value: meta.id, label: meta.label, color: meta.color }))}
252 selected={view.priorities}
253 onToggle={value => onFilter({ priorities: toggleValue(view.priorities, value) as IssuePriority[] })}
254 />
255 <FilterSection
256 title={t('issue.board.filter.label')}
257 options={labels.map(label => ({ value: label.id, label: label.name, color: label.color }))}
258 selected={view.labelIds}
259 onToggle={value => onFilter({ labelIds: toggleValue(view.labelIds, value) })}
260 searchable
261 empty={labelsFailed ? t('issue.filter.labelsFailed') : t('issue.board.manage.noLabels')}
262 />
263 <FilterSection
264 title={t('issue.board.filter.milestone')}
265 options={[
266 ...milestones.map(milestone => ({
267 value: milestone.id,
268 label: milestone.name,
269 hint: milestone.is_closed ? t('label.issueState.closed') : undefined,
270 })),
271 // `none` is a real filter: the backend matches a NULL milestone.
272 { value: 'none', label: t('issue.filter.noMilestone') },
273 ]}
274 selected={view.milestoneIds}
275 onToggle={value => onFilter({ milestoneIds: toggleValue(view.milestoneIds, value) })}
276 searchable
277 />
278 <FilterSection
279 title={t('issue.board.filter.assignee')}
280 options={members.map(user => ({
281 value: user.user_id,
282 label: user.display_name || user.username,
283 hint: user.username ? `@${user.username}` : undefined,
284 }))}
285 selected={view.assigneeIds}
286 onToggle={value => onFilter({ assigneeIds: toggleValue(view.assigneeIds, value) })}
287 searchable
288 />
289 <footer className="board-filter-panel-foot">
290 <button type="button" className="board-filter-clear" disabled={!filtered} onClick={onClear}>
291 {t('issue.filter.clearAll')}
292 </button>
293 <button type="button" className="board-filter-done" onClick={() => setPanelOpen(false)}>
294 {t('issue.filter.done')}
295 </button>
296 </footer>
297 </div>
298 ) : null}
299 </div>
142300
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')}>
144 {(
145 [
146 ['board', t('issue.board.viewBoard'), LayoutGrid],
147 ['list', t('issue.board.viewList'), List],
148 ] as const
149 ).map(([id, label, Icon]) => (
301 <div className="board-more">
150302 <button
151 key={id}
152303 type="button"
153 aria-pressed={view.view === id}
154 title={label}
155 onClick={() => onFilter({ view: id })}
156 className={`flex items-center gap-1 rounded px-2 py-1 text-[12.5px] transition-colors ${
157 view.view === id ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:text-shell-ink'
158 }`}
304 className="board-icon-button"
305 aria-label={t('issue.board.more')}
306 title={t('issue.board.more')}
307 aria-expanded={menuOpen}
308 aria-haspopup="menu"
309 onClick={() => {
310 setMenuOpen(open => !open)
311 setPanelOpen(false)
312 }}
159313 >
160 <Icon className="h-3.5 w-3.5" aria-hidden />
161 {label}
314 <MoreHorizontal className="h-4 w-4" aria-hidden />
162315 </button>
163 ))}
164 </div>
316 {menuOpen ? (
317 <div className="board-more-menu" role="menu">
318 <button
319 type="button"
320 role="menuitem"
321 className="board-more-item"
322 onClick={() => {
323 setMenuOpen(false)
324 onManage()
325 }}
326 >
327 <Settings2 className="h-3.5 w-3.5" aria-hidden />
328 {t('issue.board.settingsAction')}
329 </button>
330 </div>
331 ) : null}
332 </div>
165333
166 {filtered ? (
167 <button
168 type="button"
169 onClick={onClear}
170 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"
171 >
172 <X className="h-3.5 w-3.5" aria-hidden />
173 {t('issue.filter.clear')}
174 </button>
175 ) : null}
334 </div>
176335
177 <ActiveChips
178 view={view}
179 labelName={id => labels.find(l => l.id === id)?.name}
180 milestoneName={milestoneName}
181 memberName={memberName}
182 onFilter={onFilter}
183 />
336 {/*
337 * Always mounted so the row can grow and collapse over 200ms — inserting
338 * it outright made the list size jump the moment a filter was ticked.
339 */}
340 <div className={`board-filter-summary${filtered ? ' is-on' : ''}`} aria-hidden={!filtered}>
341 <div className="board-filter-summary-inner">
342 <span className="board-filter-total">{t('issue.board.resultCount', { count: total })}</span>
343 <ActiveChips
344 view={view}
345 labelName={id => labels.find(label => label.id === id)?.name}
346 milestoneName={id => milestones.find(item => item.id === id)?.name}
347 memberName={id => members.find(user => user.user_id === id)?.display_name}
348 onFilter={onFilter}
349 />
350 <button type="button" className="board-filter-clear" tabIndex={filtered ? undefined : -1} onClick={onClear}>
351 <X className="h-3 w-3" aria-hidden />
352 {t('issue.filter.clearAll')}
353 </button>
354 </div>
355 </div>
184356 </div>
185357 )
186358}
187359
188/** Removable summary of what is currently filtered — the state is never hidden. */
360/* ----------------------------------------------------------- filter panel --- */
361
362interface FilterOption {
363 value: string
364 label: string
365 color?: string
366 hint?: string
367}
368
369/** One dimension of the panel: a checkbox list, searchable when it is long. */
370function FilterSection({
371 title,
372 options,
373 selected,
374 onToggle,
375 searchable = false,
376 empty,
377}: {
378 title: string
379 options: FilterOption[]
380 selected: readonly string[]
381 onToggle: (value: string) => void
382 searchable?: boolean
383 empty?: string
384}) {
385 const { t } = useT()
386 const [query, setQuery] = useState('')
387 const keyword = query.trim().toLowerCase()
388 const rows = keyword ? options.filter(option => option.label.toLowerCase().includes(keyword)) : options
389 return (
390 <section className="board-filter-section">
391 <header className="board-filter-section-head">
392 <span>{title}</span>
393 {selected.length > 0 ? <span className="board-filter-section-count">{selected.length}</span> : null}
394 </header>
395 {searchable && options.length > 6 ? (
396 <label className="board-filter-section-search">
397 <Search className="h-3 w-3 shrink-0" aria-hidden />
398 <input
399 value={query}
400 onChange={event => setQuery(event.target.value)}
401 placeholder={t('issue.filter.searchIn', { title })}
402 aria-label={t('issue.filter.searchIn', { title })}
403 />
404 </label>
405 ) : null}
406 <div className="board-filter-options">
407 {rows.map(option => {
408 const on = selected.includes(option.value)
409 return (
410 <button
411 key={option.value}
412 type="button"
413 role="checkbox"
414 aria-checked={on}
415 onClick={() => onToggle(option.value)}
416 className="board-filter-check"
417 >
418 <span className="board-filter-check-box" aria-hidden>{on ? <Check className="h-3 w-3" /> : null}</span>
419 {option.color ? (
420 <span
421 className="board-filter-check-dot"
422 style={{ backgroundColor: normalizeColor(option.color) }}
423 aria-hidden
424 />
425 ) : null}
426 <span className="board-filter-check-label">{option.label}</span>
427 {option.hint ? <span className="board-filter-check-hint">{option.hint}</span> : null}
428 </button>
429 )
430 })}
431 {rows.length === 0 ? <p className="board-filter-section-empty">{empty ?? t('issue.board.noOptions')}</p> : null}
432 </div>
433 </section>
434 )
435}
436
437/* ---------------------------------------------------------------- chips --- */
438
439/** Every applied value is removable on its own — the filter state is never hidden. */
189440function ActiveChips({
190441 view,
191442 labelName,
@@ -203,57 +454,71 @@
203454 const chips: Array<{ key: string; text: string; clear: Partial<IssueBoardView> }> = []
204455 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' } })
205456 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: '' } })
208 if (view.milestoneId)
457 for (const id of view.priorities) {
458 chips.push({
459 key: `priority-${id}`,
460 text: t('issue.filter.chip.priority', { value: priorityOf(id).label }),
461 clear: { priorities: view.priorities.filter(value => value !== id) },
462 })
463 }
464 for (const id of view.labelIds) {
209465 chips.push({
210 key: 'milestone',
211 text: t('issue.filter.chip.milestone', { value: view.milestoneId === 'none' ? t('issue.filter.noMilestoneShort') : (milestoneName(view.milestoneId) ?? t('issue.filter.unknownMilestone')) }),
212 clear: { milestoneId: '' },
466 key: `label-${id}`,
467 text: t('issue.filter.chip.label', { value: labelName(id) ?? t('issue.filter.unknownLabel') }),
468 clear: { labelIds: view.labelIds.filter(value => value !== id) },
213469 })
214 if (view.assigneeId) chips.push({ key: 'assignee', text: t('issue.filter.chip.assignee', { value: memberName(view.assigneeId) ?? t('common.state.unknownUser') }), clear: { assigneeId: '' } })
470 }
471 for (const id of view.milestoneIds) {
472 chips.push({
473 key: `milestone-${id}`,
474 text: t('issue.filter.chip.milestone', {
475 value: id === 'none' ? t('issue.filter.noMilestoneShort') : (milestoneName(id) ?? t('issue.filter.unknownMilestone')),
476 }),
477 clear: { milestoneIds: view.milestoneIds.filter(value => value !== id) },
478 })
479 }
480 for (const id of view.assigneeIds) {
481 chips.push({
482 key: `assignee-${id}`,
483 text: t('issue.filter.chip.assignee', { value: memberName(id) ?? t('common.state.unknownUser') }),
484 clear: { assigneeIds: view.assigneeIds.filter(value => value !== id) },
485 })
486 }
487 if (view.sort !== 'created') {
488 chips.push({
489 key: 'sort',
490 text: t('issue.filter.chip.sort', { value: (SORTS.find(s => s.id === view.sort) ?? SORTS[0]).label }),
491 clear: { sort: 'created' },
492 })
493 }
215494 if (chips.length === 0) return null
216495 return (
217 <div className="board-active-filters flex w-full flex-wrap items-center gap-1.5">
496 <>
218497 {chips.map(chip => (
219 <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">
498 <span key={chip.key} className="board-filter-chip">
220499 {chip.text}
221 <button type="button" aria-label={t('issue.filter.remove', { text: chip.text })} onClick={() => onFilter(chip.clear)} className="opacity-60 hover:opacity-100">
500 <button type="button" aria-label={t('issue.filter.remove', { text: chip.text })} onClick={() => onFilter(chip.clear)}>
222501 <X className="h-3 w-3" aria-hidden />
223502 </button>
224503 </span>
225504 ))}
226 </div>
505 </>
227506 )
228507}
229508
509/* -------------------------------------------------------------- actions --- */
510
230511export interface BoardActionsProps {
231512 onCreate: () => void
232 onManage: () => void
233 managing?: boolean
234513}
235514
236/** Toolbar right segment: watchers are the board's own avatars, then the actions. */
237export function BoardActions({ onCreate, onManage }: BoardActionsProps) {
515/** Header right slot: the only primary action on the page. */
516export function BoardActions({ onCreate }: BoardActionsProps) {
238517 const { t } = useT()
239518 return (
240 <>
241 <button
242 type="button"
243 onClick={onManage}
244 title={t('issue.board.manageTitle')}
245 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"
246 >
247 <Settings2 className="h-3.5 w-3.5" aria-hidden />
248 {t('issue.board.manageButton')}
249 </button>
250 <button
251 type="button"
252 onClick={onCreate}
253 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"
254 >
255 <Plus className="h-4 w-4" aria-hidden /> {t('issue.board.new')}
256 </button>
257 </>
519 <button type="button" onClick={onCreate} title={t('issue.board.newHint')} className="board-create-button">
520 <Plus className="h-4 w-4" aria-hidden />
521 <span className="board-create-label">{t('issue.board.new')}</span>
522 </button>
258523 )
259524}
Modified web/src/features/issue-board/ui/board-manage-modal.tsx View web/src/features/issue-board/ui/board-manage-modal.tsx
@@ -7,13 +7,14 @@
77 * the rest of the edits, and every success bumps the page's reload key so the
88 * toolbar filters and the issue pickers see the new option immediately.
99 */
10import { useState } from 'react'
11import { Loader2 } from 'lucide-react'
10import { useEffect, useState } from 'react'
11import { ChevronRight, Loader2 } from 'lucide-react'
1212import { Alert, Button } from '@/shared/ui'
1313import { ApiFailure } from '@/shared/lib'
1414import { useT } from '@/shared/i18n'
1515import {
1616 LABEL_COLORS,
17 MAX_BOARD_PREFIX_LEN,
1718 createLabel,
1819 createMilestone,
1920 dateApiValue,
@@ -21,6 +22,7 @@
2122 deleteBoard,
2223 deleteLabel,
2324 deleteMilestone,
25 isValidBoardPrefix,
2426 normalizeColor,
2527 updateBoard,
2628 updateLabel,
@@ -32,7 +34,7 @@
3234} from '@/entities/issue'
3335import type { BoardData } from '../model/use-board-data'
3436import type { MessageKey } from '@/shared/i18n'
35import { fieldInputClass, fieldLabelClass, smallInputClass, Dialog } from './dialog'
37import { smallInputClass, Dialog } from './dialog'
3638import { DangerButton } from './parts'
3739
3840type Pane = 'general' | 'labels' | 'milestones'
@@ -65,6 +67,29 @@
6567 // Prefer the freshly fetched board: the page loads it, so a rename elsewhere
6668 // is reflected here instead of the (possibly stale) sidebar row.
6769 const current = data.board.data ?? board
70 const boardId = current?.id
71
72 // Drafts live on the modal (not the pane) because the footer owns the save
73 // button. They reset per board so a half-typed rename never leaks across.
74 const [name, setName] = useState('')
75 const [prefix, setPrefix] = useState('')
76 const [description, setDescription] = useState('')
77 useEffect(() => {
78 if (!open) return
79 setName(current?.name ?? '')
80 setPrefix(current?.prefix ?? '')
81 setDescription(current?.description ?? '')
82 // eslint-disable-next-line react-hooks/exhaustive-deps
83 }, [open, boardId])
84
85 const trimmedName = name.trim()
86 const trimmedPrefix = prefix.trim().toUpperCase()
87 const prefixInvalid = trimmedPrefix.length > 0 && !isValidBoardPrefix(trimmedPrefix)
88 const dirty =
89 Boolean(current) &&
90 (trimmedName !== (current?.name ?? '') ||
91 (trimmedPrefix || null) !== (current?.prefix ?? null) ||
92 (description.trim() || null) !== (current?.description ?? null))
6893
6994 async function run(key: string, action: () => Promise<unknown>, keepOpen = true) {
7095 setBusy(key)
@@ -82,28 +107,65 @@
82107 }
83108 }
84109
110 const save = () =>
111 void run('save-board', () =>
112 updateBoard(boardRef, {
113 name: trimmedName,
114 prefix: trimmedPrefix || null,
115 description: description.trim() || null,
116 }),
117 )
118
85119 return (
86120 <Dialog
87121 open={open}
88122 onClose={onClose}
89123 title={t('issue.board.manage.title')}
90 description={t('issue.board.manage.description')}
91 width="max-w-[620px]"
92 footer={<Button variant="secondary" onClick={onClose}>{t('common.action.close')}</Button>}
124 width="max-w-[640px]"
125 panelClassName="issue-manage-dialog"
126 header={
127 <div className="issue-form-head">
128 {current?.name ? <span className="issue-form-team">{current.name}</span> : null}
129 <ChevronRight className="h-3.5 w-3.5 shrink-0" aria-hidden />
130 <span className="issue-form-kicker">{t('issue.board.manage.title')}</span>
131 </div>
132 }
133 footer={
134 <div className="issue-manage-footer">
135 {pane === 'general' && current ? (
136 <DangerButton
137 label={t('issue.board.manage.deleteBoard')}
138 busy={busy === 'delete-board'}
139 onConfirm={() => void run('delete-board', () => deleteBoard(boardRef), false)}
140 />
141 ) : null}
142 <div className="issue-manage-footer-actions">
143 <Button variant="secondary" onClick={onClose}>{t('common.action.close')}</Button>
144 {pane === 'general' ? (
145 <Button
146 disabled={busy !== null || !dirty || !trimmedName || prefixInvalid || !current}
147 onClick={save}
148 >
149 {busy === 'save-board' ? t('common.state.busy') : t('common.action.save')}
150 </Button>
151 ) : null}
152 </div>
153 </div>
154 }
93155 >
94 <div className="flex gap-1 border-b-2 border-shell-hairline" role="tablist">
156 <div className="issue-manage-tabs" role="tablist">
95157 {PANES.map(id => (
96158 <button
97159 key={id}
98160 type="button"
99161 role="tab"
100162 aria-selected={pane === id}
101 onClick={() => setPane(id)}
102 className={`-mb-0.5 border-b-2 px-2.5 pb-1.5 text-[13px] font-medium transition-colors ${
103 pane === id
104 ? 'border-shell-ink text-shell-ink'
105 : 'border-transparent text-shell-ink-muted hover:text-shell-ink'
106 }`}
163 data-active={pane === id || undefined}
164 onClick={() => {
165 setPane(id)
166 setError(null)
167 }}
168 className="issue-manage-tab"
107169 >
108170 {t(PANE_KEYS[id])}
109171 </button>
@@ -118,16 +180,49 @@
118180 ) : null}
119181
120182 {pane === 'general' ? (
121 // Keyed by the board id: opening the dialog for another board remounts
122 // the pane, so its draft state can never leak between boards.
123 <BoardGeneralPane
124 key={current?.id ?? 'none'}
125 boardRef={boardRef}
126 board={current}
127 busy={busy}
128 onRun={run}
129 onDelete={() => void run('delete-board', () => deleteBoard(boardRef), false)}
130 />
183 <div className="issue-manage-body">
184 <label className="issue-manage-field">
185 <span className="issue-manage-label">{t('issue.board.manage.name')}</span>
186 <input
187 className="issue-manage-input"
188 value={name}
189 onChange={event => setName(event.target.value)}
190 maxLength={64}
191 disabled={!current}
192 />
193 </label>
194 <label className="issue-manage-field">
195 <span className="issue-manage-label">{t('issue.board.manage.prefix')}</span>
196 <input
197 className={`issue-manage-input issue-manage-prefix${prefixInvalid ? ' is-invalid' : ''}`}
198 value={prefix}
199 onChange={event => setPrefix(event.target.value.toUpperCase())}
200 placeholder={t('issue.board.manage.prefixPlaceholder')}
201 maxLength={MAX_BOARD_PREFIX_LEN}
202 spellCheck={false}
203 autoComplete="off"
204 disabled={!current}
205 />
206 {prefixInvalid ? (
207 <span className="issue-manage-error">{t('issue.board.manage.prefixInvalid')}</span>
208 ) : (
209 <span className="issue-manage-hint">{t('issue.board.manage.prefixHint')}</span>
210 )}
211 </label>
212 <label className="issue-manage-field">
213 <span className="issue-manage-label">{t('issue.board.manage.descriptionField')}</span>
214 <textarea
215 value={description}
216 onChange={event => setDescription(event.target.value)}
217 rows={3}
218 disabled={!current}
219 className="issue-manage-input issue-manage-textarea"
220 />
221 </label>
222 <p className="issue-manage-stats">
223 {t('issue.board.manage.stats', { open: current?.open_issues_count ?? 0, closed: current?.closed_issues_count ?? 0 })}
224 </p>
225 </div>
131226 ) : null}
132227
133228 {pane === 'labels' ? (
@@ -148,64 +243,6 @@
148243}
149244
150245type Runner = (key: string, action: () => Promise<unknown>, keepOpen?: boolean) => Promise<boolean>
151
152function BoardGeneralPane({
153 boardRef,
154 board,
155 busy,
156 onRun,
157 onDelete,
158}: {
159 boardRef: BoardRef
160 board?: Board
161 busy: string | null
162 onRun: Runner
163 onDelete: () => void
164}) {
165 const [name, setName] = useState(board?.name ?? '')
166 const { t } = useT()
167 const [description, setDescription] = useState(board?.description ?? '')
168 return (
169 <div className="space-y-4">
170 <label className="block space-y-1.5">
171 <span className={fieldLabelClass}>{t('issue.board.manage.name')}</span>
172 <input
173 className={fieldInputClass}
174 value={name}
175 onChange={event => setName(event.target.value)}
176 maxLength={64}
177 disabled={!board}
178 />
179 </label>
180 <label className="block space-y-1.5">
181 <span className={fieldLabelClass}>{t('issue.board.manage.descriptionField')}</span>
182 <textarea
183 value={description}
184 onChange={event => setDescription(event.target.value)}
185 rows={3}
186 disabled={!board}
187 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"
188 />
189 </label>
190 <p className="text-[12px] text-shell-ink-muted">
191 {t('issue.board.manage.stats', { open: board?.open_issues_count ?? 0, closed: board?.closed_issues_count ?? 0 })}
192 </p>
193 <div className="flex items-center justify-between gap-3">
194 <Button
195 disabled={busy !== null || !name.trim() || !board}
196 onClick={() =>
197 void onRun('save-board', () =>
198 updateBoard(boardRef, { name: name.trim(), description: description.trim() || null }),
199 )
200 }
201 >
202 {t('common.action.save')}
203 </Button>
204 <DangerButton label={t('issue.board.manage.deleteBoard')} busy={busy === 'delete-board'} onConfirm={onDelete} />
205 </div>
206 </div>
207 )
208}
209246
210247function LabelsPane({boardRef,
211248 labels,
Modified web/src/features/issue-board/ui/board-polish.css View web/src/features/issue-board/ui/board-polish.css
@@ -1,42 +1,127 @@
11/* Issue workspace: comfortable controls, quiet surfaces and legible state. */
22.board-toolbar:has(.board-filters) {
33 align-items: flex-start;
4 gap: 16px;
5 padding-block: 18px;
4 gap: 8px;
5 padding-block: 10px;
66 border-bottom: 1px solid var(--shell-hairline);
77 background: var(--shell-surface);
88}
9.board-filters { flex: 1 1 620px; gap: 9px; }
9.board-filters { display: flex; min-width: 0; flex: 1 1 100%; flex-direction: column; gap: 8px; }
10.board-filters-row { position: relative; display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 8px; }
1011.board-toolbar-actions { padding-top: 1px; gap: 8px; }
1112.board-search {
12 height: 36px;
13 min-width: 205px;
14 max-width: 270px;
15 gap: 9px;
16 padding-inline: 11px;
13 display: flex;
14 height: 32px;
15 width: min(360px, 40vw);
16 min-width: 180px;
17 flex: 0 0 auto;
18 align-items: center;
19 gap: 7px;
20 padding-inline: 9px;
1721 border: 1px solid var(--shell-hairline);
18 border-radius: 9px;
22 border-radius: 8px;
1923 background: var(--shell-field);
2024 transition: border-color 150ms, box-shadow 150ms;
2125}
2226.board-search:focus-within { border-color: var(--shell-tile); background: var(--shell-surface); box-shadow: 0 0 0 3px color-mix(in srgb, var(--shell-focus) 20%, transparent); }
2327.board-search input { font-size: 13px; }
24.board-search > button { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 5px; }
25.board-search > button:hover { background: var(--shell-active); }
26.board-segmented { min-height: 36px; padding: 3px; border-radius: 9px; gap: 2px; }
27.board-segmented > button { min-height: 28px; padding-inline: 10px; border-radius: 6px; font-size: 12px; }
28.board-search-clear { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 5px; }
29.board-search-clear:hover { background: var(--shell-active); }
30.board-segmented { display: inline-flex; min-height: 32px; align-items: center; padding: 3px; border-radius: 8px; background: var(--shell-field); gap: 2px; }
31.board-segmented > button { min-height: 26px; flex-shrink: 0; padding-inline: 9px; border-radius: 6px; color: var(--shell-ink-muted); font-size: 12px; white-space: nowrap; transition: background 140ms, color 140ms, box-shadow 140ms; }
32.board-segmented > button:hover { color: var(--shell-ink); }
2833.board-segmented > button[aria-pressed='true'] { color: var(--shell-ink-muted); background: var(--shell-surface); box-shadow: 0 1px 3px rgb(20 30 60 / 7%); }
29.board-filter-trigger { height: 36px; padding-inline: 10px; gap: 6px; border: 1px solid var(--shell-hairline); border-radius: 9px; background: var(--shell-surface); font-size: 12px; }
34.board-filter-trigger { display: inline-flex; height: 32px; flex-shrink: 0; align-items: center; padding-inline: 10px; gap: 6px; border: 1px solid var(--shell-hairline); border-radius: 8px; background: var(--shell-surface); font-size: 12.5px; white-space: nowrap; }
3035.board-filter-trigger[aria-expanded='true'] { border-color: var(--shell-tile); color: var(--shell-ink-muted); background: var(--shell-field); }
3136.board-filter-trigger:hover { border-color: var(--shell-tile); }
3237.board-filter-popover { margin-top: 7px; padding: 6px; max-width: calc(100vw - 40px); border-width: 1px; border-radius: 12px; box-shadow: 0 12px 36px rgb(20 30 60 / 14%); }
3338.board-filter-option { min-height: 36px; border-radius: 7px; gap: 8px; }
3439.board-filter-option[aria-selected='true'] { background: var(--shell-field); color: var(--shell-ink-muted); }
3540.board-filter-option:focus-visible { outline-offset: -2px; }
36.board-active-filters { padding-top: 2px; }
37.board-active-filters > span { padding: 5px 9px; gap: 6px; font-size: 11px; background: var(--shell-field); color: var(--shell-ink-muted); }
38.board-active-filters > span > button { display: grid; width: 18px; height: 18px; place-items: center; border-radius: 50%; }
39.board-create-button, .board-manage-button { min-height: 36px; border-radius: 9px; gap: 7px; padding-inline: 12px; }
41.board-view-tabs { display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: var(--shell-field); }
42.board-view-tab { display: inline-flex; height: 26px; flex-shrink: 0; align-items: center; gap: 5px; padding-inline: 10px; border-radius: 6px; color: var(--shell-ink-muted); font-size: 12.5px; white-space: nowrap; transition: background 140ms, color 140ms, box-shadow 140ms; }
43.board-view-tab:hover { color: var(--shell-ink); }
44.board-view-tab[aria-pressed='true'] { background: var(--shell-surface); color: var(--shell-ink); font-weight: 600; box-shadow: 0 1px 2px rgb(20 30 60 / 12%); }
45.board-filter-count { display: inline-grid; min-width: 16px; height: 16px; place-items: center; padding-inline: 4px; border-radius: 999px; background: var(--shell-action); color: #fff; font-size: 10.5px; font-weight: 600; font-variant-numeric: tabular-nums; }
46.board-icon-button { display: grid; width: 32px; height: 32px; place-items: center; border: 1px solid var(--shell-hairline); border-radius: 8px; background: var(--shell-surface); color: var(--shell-ink-muted); transition: background 140ms, color 140ms, border-color 140ms; }
47.board-icon-button:hover, .board-icon-button[aria-expanded='true'] { border-color: var(--shell-tile); background: var(--shell-field); color: var(--shell-ink); }
48.board-more { position: relative; }
49.board-more-menu { position: absolute; right: 0; z-index: 30; margin-top: 6px; min-width: 168px; padding: 4px; border: 1px solid var(--shell-hairline); border-radius: 10px; background: var(--shell-surface); box-shadow: 0 12px 36px rgb(20 30 60 / 14%); }
50.board-more-item { display: flex; width: 100%; min-height: 32px; align-items: center; gap: 8px; padding-inline: 8px; border-radius: 7px; color: var(--shell-ink); font-size: 12.5px; transition: background 140ms; }
51.board-more-item:hover { background: var(--shell-field); }
52/*
53 * 筛选 panel: one popover for the four low-frequency dimensions. Multi-select
54 * rows apply immediately, and each section carries its own search once the
55 * option list is long enough to need one.
56 */
57.board-filter-anchor { position: relative; flex-shrink: 0; }
58/* Anchored to the 筛选 trigger and opened leftwards, so it reads as that
59 button's panel rather than as an extension of the search field. */
60.board-filter-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; display: flex; width: 300px; max-height: min(560px, calc(100dvh - 220px)); flex-direction: column; overflow-y: auto; padding: 8px; border: 1px solid var(--shell-hairline); border-radius: 12px; background: var(--shell-surface); box-shadow: 0 18px 48px rgb(20 30 60 / 16%); }
61.board-filter-section { display: flex; flex-direction: column; gap: 4px; padding-block: 5px; }
62.board-filter-section + .board-filter-section { border-top: 1px solid var(--shell-hairline); }
63.board-filter-section-head { display: flex; align-items: center; gap: 6px; padding: 2px 8px 3px; color: var(--shell-ink-muted); font-size: 11.5px; font-weight: 600; letter-spacing: .02em; }
64.board-filter-section-count { display: inline-grid; min-width: 15px; height: 15px; place-items: center; border-radius: 999px; background: var(--shell-active); color: var(--shell-ink); font-size: 10px; }
65.board-filter-section-search { display: flex; height: 28px; align-items: center; gap: 6px; margin: 0 6px 4px; padding-inline: 8px; border: 1px solid var(--shell-hairline); border-radius: 7px; background: var(--shell-field); color: var(--shell-ink-muted); }
66.board-filter-section-search input { min-width: 0; flex: 1; background: transparent; font-size: 12px; outline: none; }
67.board-filter-options { display: flex; max-height: 176px; flex-direction: column; gap: 1px; overflow-y: auto; }
68.board-filter-check { display: flex; min-height: 30px; align-items: center; gap: 8px; padding-inline: 8px; border-radius: 7px; color: var(--shell-ink); font-size: 12.5px; text-align: left; transition: background 120ms; }
69.board-filter-check:hover { background: var(--shell-field); }
70.board-filter-check-box { display: grid; width: 15px; height: 15px; flex-shrink: 0; place-items: center; border: 1px solid var(--shell-hairline); border-radius: 4px; background: var(--shell-surface); color: #fff; transition: background 120ms, border-color 120ms; }
71.board-filter-check[aria-checked='true'] .board-filter-check-box { border-color: var(--shell-action); background: var(--shell-action); }
72.board-filter-check-dot { width: 8px; height: 8px; flex-shrink: 0; border-radius: 999px; }
73.board-filter-check-label { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
74.board-filter-check-hint { flex-shrink: 0; color: var(--shell-ink-muted); font-size: 11px; }
75.board-filter-section-empty { padding: 6px 8px; color: var(--shell-ink-muted); font-size: 11.5px; }
76.board-filter-panel-foot { display: flex; align-items: center; gap: 8px; padding: 7px 4px 2px; border-top: 1px solid var(--shell-hairline); }
77.board-filter-done { height: 28px; margin-left: auto; padding-inline: 12px; border-radius: 7px; background: var(--shell-action); color: #fff; font-size: 12px; font-weight: 600; }
78.board-filter-done:hover { filter: brightness(1.08); }
79/*
80 * The summary grows out of the toolbar instead of being inserted into it: a
81 * `0fr → 1fr` grid row is the one height transition that works without
82 * measuring, so the list below slides down rather than jumping.
83 */
84.board-filter-summary { display: grid; grid-template-rows: 0fr; opacity: 0; transition: grid-template-rows 200ms ease, opacity 200ms ease; }
85.board-filter-summary.is-on { grid-template-rows: 1fr; opacity: 1; }
86.board-filter-summary-inner { display: flex; min-height: 0; min-width: 0; flex-wrap: wrap; align-items: center; gap: 6px; overflow: hidden; }
87.board-filter-total { color: var(--shell-ink-muted); font-size: 11.5px; }
88.board-filter-chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 6px 3px 9px; border-radius: 999px; background: var(--shell-field); color: var(--shell-ink); font-size: 11.5px; }
89.board-filter-chip > button { display: grid; width: 16px; height: 16px; place-items: center; border-radius: 50%; color: var(--shell-ink-muted); }
90.board-filter-chip > button:hover { background: var(--shell-active); color: var(--shell-ink); }
91.board-filter-clear { display: inline-flex; align-items: center; gap: 4px; padding: 3px 4px; border-radius: 6px; color: var(--shell-ink-muted); font-size: 11.5px; transition: color 140ms; }
92.board-filter-clear:hover:not(:disabled) { color: var(--shell-ink); }
93.board-filter-clear:disabled { opacity: .5; }
94
95/*
96 * Responsive ladder for the board chrome:
97 * <1280px the toolbar keeps search + status + 排序 + 筛选, everything else
98 * already lives in the panel, so only the gaps tighten
99 * <900px the search takes the full row instead of shrinking forever
100 * <768px 筛选 becomes a bottom sheet and 新建工单 drops to its icon
101 */
102@media (max-width: 1280px) {
103 .board-filters-row { gap: 6px; }
104 .board-search { width: min(300px, 34vw); }
105}
106@media (max-width: 900px) {
107 .board-search { width: 100%; order: -1; }
108}
109@media (max-width: 768px) {
110 .board-toolbar:has(.board-filters) { padding-block: 8px; }
111 .board-filter-panel {
112 position: fixed;
113 inset: auto 0 0 0;
114 width: auto;
115 max-height: 72dvh;
116 border-radius: 16px 16px 0 0;
117 border-bottom: 0;
118 box-shadow: 0 -18px 48px rgb(20 30 60 / 22%);
119 }
120 .board-filter-options { max-height: none; }
121 .board-create-label { display: none; }
122 .board-create-button { padding-inline: 10px; }
123}
124.board-create-button { display: inline-flex; height: 32px; min-height: 32px; align-items: center; gap: 6px; padding-inline: 12px; border-radius: 8px; background: var(--shell-action); color: #fff; font-size: 13px; font-weight: 600; transition: filter 140ms, transform 140ms; }
40125.board-create-button { box-shadow: 0 3px 8px rgb(35 39 43 / 5%); }
41126.board-create-button:hover { transform: translateY(-1px); }
42127.board-canvas:has(.issue-board-body), .board-canvas:has(.issue-board-empty) { padding-top: 20px; }
@@ -70,6 +155,8 @@
70155.issue-board-card[data-moving] { opacity: .45; }
71156.issue-board-card > div:first-child { flex-wrap: wrap; row-gap: 8px; }
72157.issue-board-card-title { flex-basis: 100%; font-size: 13px; font-weight: 550; line-height: 1.7; }
158.issue-board-card-title .gitdata-markdown,
159.issue-list-title .gitdata-markdown { color: inherit; font-size: inherit; font-weight: inherit; line-height: inherit; letter-spacing: inherit; }
73160.issue-board-card-meta { flex-wrap: wrap; gap: 7px; margin-top: 14px; padding-top: 10px; border-top: 1px solid color-mix(in srgb, var(--shell-hairline) 65%, transparent); }
74161.issue-board-card-meta > span[title] { max-width: 95px; }
75162.issue-priority-badge { padding: 3px 8px; gap: 5px; color: color-mix(in srgb, var(--priority-tone) 85%, var(--shell-ink)); background: color-mix(in srgb, var(--priority-tone) 12%, var(--shell-surface)); border-radius: 5px; font-size: 10px; line-height: 1.5; font-weight: 550; }
@@ -84,14 +171,43 @@
84171.issue-board-empty > p:first-of-type { font-size: 18px; font-weight: 600; }
85172.issue-board-empty > p:nth-of-type(2) { margin-top: 2px; font-size: 13px; line-height: 1.8; }
86173.issue-board-empty > button { min-height: 38px; margin-top: 17px; border-radius: 9px; padding-inline: 15px; }
87.issue-board-table { border-width: 1px; border-radius: 13px; }
88.issue-board-table table { min-width: 790px; }
89.issue-board-table th { padding: 13px 14px; white-space: nowrap; font-size: 11px; }
90.issue-board-table td { height: 64px; padding: 12px 14px; }
91.issue-board-table td:nth-child(3) { min-width: 240px; }
92.issue-board-table-title { padding-block: 5px; line-height: 1.6; }
93.issue-board-table-title:hover { color: var(--shell-ink-muted); }
94.issue-board-table select { height: 30px; border-width: 1px; border-radius: 6px; padding-inline: 5px; }
174/*
175 * List view: Linear-style rows. No header, no cell borders, no card — the row
176 * itself is the only container, and hover paints it. Priority is a mark you
177 * can click to change; the trailing facts (milestone / assignees / updated)
178 * stay muted so the title keeps the ink.
179 */
180.issue-list { display: flex; min-width: 0; flex-direction: column; gap: 1px; padding: 6px 8px; border-radius: 12px; background: var(--shell-surface); }
181/* Rows settle in rather than snapping when a filter changes the result set.
182 No `fill-mode`, so the hover transforms below keep working after the fade. */
183.issue-list-row, .issue-board-card { animation: issue-row-in 200ms ease; }
184@keyframes issue-row-in {
185 from { opacity: 0; transform: translateY(-3px); }
186 to { opacity: 1; transform: none; }
187}
188.issue-list-row { display: flex; min-height: 40px; min-width: 0; cursor: pointer; align-items: center; gap: 8px; padding: 0 8px; border-radius: 8px; transition: background 120ms ease; }
189/* The list sits on a white surface, so the hover steps down to the canvas
190 tone instead of vanishing into the page. */
191.issue-list-row:hover, .issue-list-row:focus-within { background: var(--shell-field); }
192.issue-list-title { min-width: 0; flex: 1; overflow: hidden; color: var(--shell-ink); font-size: 13px; font-weight: 500; line-height: 1.6; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
193.issue-list-title:hover { color: color-mix(in srgb, var(--shell-ink) 74%, var(--shell-ink-muted)); }
194.issue-list-key { flex-shrink: 0; white-space: nowrap; color: var(--shell-ink); font-size: 12.5px; font-weight: 550; letter-spacing: .01em; }
195.issue-list-state { display: grid; width: 16px; flex-shrink: 0; place-items: center; color: var(--shell-ink-muted); }
196.issue-list-state[data-state='open'] { color: #3ba55d; }
197.issue-list-meta { display: flex; flex-shrink: 0; align-items: center; gap: 10px; }
198.issue-list-chip { display: inline-flex; max-width: 190px; align-items: center; gap: 6px; padding: 2px 9px; border: 1px solid var(--shell-hairline); border-radius: 999px; background: var(--shell-surface); color: var(--shell-ink-muted); font-size: 11.5px; line-height: 1.5; }
199.issue-list-dot { width: 7px; height: 7px; flex-shrink: 0; border-radius: 999px; }
200.issue-list-chip-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
201.issue-list-date { min-width: 76px; flex-shrink: 0; text-align: right; color: var(--shell-ink-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
202.issue-priority-glyph { display: grid; width: 18px; height: 18px; place-items: center; color: var(--shell-ink-muted); }
203.issue-priority-glyph svg { display: block; }
204.issue-priority-glyph rect { fill: none; stroke: currentColor; stroke-width: 1.1; opacity: .45; }
205.issue-priority-glyph rect.is-on { fill: currentColor; stroke: none; opacity: .95; }
206.issue-priority-glyph[data-priority='urgent'] { color: #e5484d; }
207.issue-priority-glyph[data-priority='high'] { color: color-mix(in srgb, var(--shell-ink) 82%, var(--shell-ink-muted)); }
208/* Square hit area: an icon-only trigger must not be a tall letterbox. */
209.board-filter-trigger-icon { display: grid; width: 26px; height: 26px; place-items: center; padding: 0; border-radius: 7px; background: transparent; color: var(--shell-ink-muted); }
210.board-filter-trigger-icon:hover, .board-filter-trigger-icon[aria-expanded='true'] { background: color-mix(in srgb, var(--shell-ink) 8%, transparent); color: var(--shell-ink); }
95211.issue-board-skeleton { animation: issue-board-pulse 1.5s ease-in-out infinite alternate; }
96212@keyframes issue-board-pulse { to { opacity: .5; } }
97213
@@ -119,7 +235,9 @@
119235.issue-detail-key { display: flex; align-items: center; gap: 8px; }
120236.issue-detail-number { color: var(--shell-ink-muted); font-family: var(--font-mono); font-size: 12px; }
121237.issue-detail-title { margin-top: 10px; font-size: 24px; font-weight: 650; line-height: 1.35; letter-spacing: -.02em; }
122.issue-detail-desc { margin-top: 10px; font-size: 13.5px; line-height: 1.75; white-space: pre-wrap; word-break: break-word; }
238.issue-detail-title .gitdata-markdown { color: inherit; font-size: inherit; font-weight: inherit; line-height: inherit; letter-spacing: inherit; }
239.issue-detail-title .gitdata-markdown code { font-size: .9em; }
240.issue-detail-desc { margin-top: 10px; font-size: 13.5px; line-height: 1.75; word-break: break-word; }
123241.issue-detail-desc.is-empty { color: var(--shell-ink-muted); }
124242.issue-detail-byline { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 16px; color: var(--shell-ink-muted); font-size: 12px; }
125243
@@ -135,9 +253,14 @@
135253.issue-event { display: grid; grid-template-columns: 20px minmax(0, 1fr); align-items: start; gap: 10px; }
136254.issue-event-icon { display: grid; width: 20px; height: 20px; place-items: center; }
137255.issue-event-dot { display: grid; width: 20px; height: 20px; place-items: center; border-radius: 50%; background: var(--shell-field); color: var(--shell-ink-muted); }
256.issue-event-stack { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
138257.issue-event-body { min-width: 0; padding-top: 1px; color: var(--shell-ink-muted); font-size: 12.5px; line-height: 1.6; }
139258.issue-event-actor { color: var(--shell-ink); font-weight: 550; }
140259.issue-event-extra { color: var(--shell-ink-muted); }
260/* The changed value is the point of the line: keep it in ink, not in grey. */
261.issue-event-value { padding: 1px 5px; border-radius: 5px; background: var(--shell-field); color: var(--shell-ink); font-weight: 550; }
262.issue-event-toggle { display: inline-flex; align-self: flex-start; align-items: center; gap: 4px; margin-top: 2px; padding: 2px 6px 2px 4px; border-radius: 6px; color: var(--shell-ink-muted); font-size: 11.5px; transition: background 140ms, color 140ms; }
263.issue-event-toggle:hover { background: var(--shell-field); color: var(--shell-ink); }
141264.issue-event-time { color: var(--shell-ink-muted); font-size: 11.5px; }
142265
143266.comment-card { padding: 14px 16px; border: 1px solid var(--shell-hairline); border-radius: 10px; background: var(--shell-surface); box-shadow: 0 1px 2px rgb(20 30 60 / 4%); }
@@ -187,10 +310,11 @@
187310.issue-prop-row:hover .issue-prop-chevron, .issue-prop-row[aria-expanded='true'] .issue-prop-chevron { opacity: .7; }
188311.issue-prop-clear { display: grid; width: 18px; height: 18px; flex-shrink: 0; place-items: center; border-radius: 5px; color: var(--shell-ink-muted); }
189312.issue-prop-clear:hover { background: var(--shell-active); color: var(--shell-ink); }
190.issue-prop-date { max-width: 134px; height: 28px; padding: 0; color: inherit; font-size: 12.5px; text-align: right; outline: none; }
191.issue-prop-date.is-empty { color: var(--shell-ink-muted); }
192.issue-prop-date::-webkit-calendar-picker-indicator { opacity: .45; cursor: pointer; }
193.issue-prop-date:hover::-webkit-calendar-picker-indicator { opacity: .75; }
313/* The date field is a rail row like the others; the native input only holds
314 the value and feeds the picker. */
315.issue-prop-row-action { position: relative; padding-inline: 8px; }
316.issue-prop-target { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; text-align: left; }
317.issue-prop-date-sr { position: absolute; width: 1px; height: 1px; padding: 0; border: 0; clip-path: inset(50%); opacity: 0; pointer-events: none; }
194318.issue-prop-popover { position: absolute; top: calc(100% + 4px); right: 0; z-index: 40; width: 248px; max-height: 280px; overflow-y: auto; }
195319.issue-prop-add { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding-inline: 6px; border-radius: 6px; color: var(--shell-ink-muted); font-size: 12px; transition: background 140ms, color 140ms; }
196320.issue-prop-add:hover { background: var(--shell-field); color: var(--shell-ink); }
@@ -198,13 +322,109 @@
198322.issue-check.is-on { border-color: var(--shell-ink); background: var(--shell-ink); }
199323
200324.issue-pull-links { display: flex; flex-direction: column; }
201.issue-pull-row { display: flex; min-height: 30px; align-items: center; gap: 8px; padding-inline: 8px; border-radius: 7px; color: var(--shell-ink); font-size: 12.5px; transition: background 140ms; }
202.issue-pull-row:hover { background: var(--shell-field); }
325.issue-pull-card { position: relative; display: flex; flex-direction: column; gap: 3px; margin-bottom: 6px; padding: 8px 26px 8px 10px; border-radius: 9px; background: var(--shell-field); }
326.issue-pull-card-head { display: flex; min-width: 0; align-items: center; gap: 6px; color: var(--shell-ink); font-size: 12.5px; }
327.issue-pull-card-head > svg { flex: none; }
328.issue-pull-card-title { min-width: 0; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
329/*
330 * 状态徽标不许被长标题挤扁:flex:none + nowrap 保持一行,底色跟随 data-state
331 * 的颜色(currentColor),与看板状态徽标观感一致。
332 */
333.issue-pull-state { flex: none; padding: 0 6px; border-radius: 999px; background: color-mix(in srgb, currentColor 12%, transparent); font-size: 11px; font-weight: 600; line-height: 18px; white-space: nowrap; }
334.issue-pull-card-foot { display: flex; min-width: 0; align-items: center; gap: 6px; }
335.issue-pull-branch { min-width: 0; flex: 1 1 auto; overflow: hidden; color: var(--shell-ink-muted); font-family: var(--font-mono); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
336.issue-pull-branch-base { flex: 0 1 auto; }
337.issue-pull-arrow { flex: none; color: var(--shell-ink-muted); font-size: 11px; }
338.issue-pull-card-remove { position: absolute; top: 8px; right: 8px; }
339.issue-pull-state[data-state='open'] { color: #3ba55d; }
340.issue-pull-state[data-state='merged'] { color: #8e4ec6; }
341.issue-pull-state[data-state='closed'] { color: #b42318; }
342.issue-pull-picker { display: flex; flex-direction: column; gap: 2px; margin-top: 4px; padding: 4px; border: 1px solid var(--shell-hairline); border-radius: 9px; }
343.issue-pull-picker-foot { display: flex; align-items: center; gap: 6px; padding: 2px 4px 4px; }
344.issue-pull-cancel { padding: 4px 8px; border-radius: 6px; color: var(--shell-ink-muted); font-size: 11.5px; transition: background 140ms, color 140ms; }
345.issue-pull-cancel:hover { background: var(--shell-field); color: var(--shell-ink); }
346.issue-more { position: relative; }
347.issue-more-menu { position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; min-width: 168px; padding: 4px; border: 1px solid var(--shell-hairline); border-radius: 10px; background: var(--shell-surface); box-shadow: 0 14px 36px rgb(20 30 60 / 16%); }
348.issue-more-danger { display: flex; width: 100%; min-height: 30px; align-items: center; gap: 8px; padding-inline: 8px; border-radius: 7px; font-size: 12.5px; }
203349.issue-pull-state { color: var(--shell-ink-muted); }
204350.issue-link-button { align-self: flex-start; height: 28px; margin: 4px 0 0 8px; padding-inline: 10px; border-radius: 7px; background: var(--shell-active); font-size: 12px; font-weight: 550; transition: background 140ms; }
205351.issue-link-button:not(:disabled):hover { background: var(--shell-tile); }
206352.issue-link-button:disabled { opacity: .5; }
207353
354/*
355 * Create/edit issue dialog, Linear's composer: breadcrumb header, bare
356 * title/description fields, and one row of chips above the primary action.
357 */
358.issue-form-dialog { padding: 18px 22px 16px; border-radius: 18px; }
359.issue-form-dialog .issue-dialog-header { margin-bottom: 4px; align-items: center; }
360.issue-form-dialog .issue-dialog-content { overflow: visible; padding-right: 0; }
361.issue-form-dialog .issue-dialog-footer { margin-top: 16px; padding-top: 0; border-top: 0; }
362.issue-form-head { display: flex; min-width: 0; align-items: center; gap: 6px; }
363.issue-form-head > svg { flex-shrink: 0; color: var(--shell-ink-muted); }
364.issue-form-team { display: inline-flex; height: 22px; align-items: center; padding-inline: 8px; border-radius: 6px; background: var(--shell-field); color: var(--shell-ink); font-size: 11.5px; font-weight: 600; }
365.issue-form-kicker { overflow: hidden; color: var(--shell-ink-muted); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; }
366
367.issue-form-body { display: flex; flex-direction: column; gap: 2px; }
368.issue-form-title-row { display: flex; align-items: center; gap: 10px; }
369.issue-form-dialog .issue-form-title { width: 100%; height: 44px; padding: 0; border: 0; background: transparent; color: var(--shell-ink); font-size: 19px; font-weight: 600; letter-spacing: -.015em; outline: none; }
370.issue-form-dialog .issue-form-summary { width: 100%; height: 32px; padding: 0; border: 0; background: transparent; color: var(--shell-ink); font-size: 13.5px; outline: none; }
371.issue-form-dialog .issue-form-desc { width: 100%; min-height: 76px; max-height: 300px; margin-top: 6px; padding: 0; border: 0; border-radius: 0; background: transparent; color: var(--shell-ink); font-size: 13px; line-height: 1.75; outline: none; resize: none; overflow-y: auto; }
372.issue-form-dialog .issue-form-desc:focus { border: 0; background: transparent; box-shadow: none; }
373.issue-form-dialog :is(.issue-form-title, .issue-form-summary, .issue-form-desc):focus-visible { outline: none; }
374.issue-form-dialog :is(.issue-form-title, .issue-form-summary, .issue-form-desc)::placeholder { color: var(--shell-ink-muted); }
375.issue-form-count { flex-shrink: 0; color: var(--shell-ink-muted); font-size: 11px; font-variant-numeric: tabular-nums; }
376.issue-form-preview { width: 100%; min-height: 76px; margin-top: 6px; padding: 0; }
377.issue-form-desc-tools { display: flex; justify-content: flex-end; }
378.issue-form-preview-toggle { height: 24px; padding-inline: 6px; border-radius: 6px; color: var(--shell-ink-muted); font-size: 12px; transition: background 140ms, color 140ms; }
379.issue-form-preview-toggle:hover { background: var(--shell-field); color: var(--shell-ink); }
380
381.issue-form-bar { display: flex; width: 100%; flex-wrap: wrap; align-items: center; gap: 8px; }
382.issue-form-chips { display: flex; min-width: 0; flex: 1 1 auto; flex-wrap: wrap; align-items: center; gap: 6px; }
383.issue-form-submit { display: flex; margin-left: auto; align-items: center; gap: 8px; }
384
385.issue-form-chip { position: relative; display: inline-flex; height: 28px; align-items: center; gap: 6px; padding-inline: 10px; border: 1px solid var(--shell-hairline); border-radius: 999px; background: transparent; color: var(--shell-ink-muted); font-size: 12px; transition: background 140ms, color 140ms; }
386.issue-form-chip > svg { flex-shrink: 0; }
387.issue-form-chip.is-button:hover, .issue-form-chip.is-button:focus-within { background: var(--shell-field); color: var(--shell-ink); }
388.issue-form-chip.is-disabled { opacity: .55; }
389.issue-form-chip-main { display: inline-flex; height: 100%; align-items: center; gap: 6px; padding: 0; color: inherit; font-size: inherit; }
390.issue-chip-text { max-width: 150px; overflow: hidden; color: var(--shell-ink); text-overflow: ellipsis; white-space: nowrap; }
391.issue-chip-text.is-empty { color: var(--shell-ink-muted); }
392.issue-form-attach { display: grid; width: 28px; height: 28px; flex-shrink: 0; place-items: center; border: 1px solid var(--shell-hairline); border-radius: 999px; color: var(--shell-ink-muted); transition: background 140ms, color 140ms; }
393.issue-form-attach:hover { background: var(--shell-field); color: var(--shell-ink); }
394.issue-form-chip.is-draft { max-width: 220px; }
395.issue-form-chip.is-draft.is-failed { border-color: color-mix(in srgb, #e5484d 45%, var(--shell-hairline)); color: #b42318; }
396.issue-chip-size { flex-shrink: 0; color: var(--shell-ink-muted); font-size: 11px; font-variant-numeric: tabular-nums; }
397.issue-chip-dot { width: 8px; height: 8px; flex-shrink: 0; border-radius: 50%; }
398.issue-chip-clear { display: grid; width: 16px; height: 16px; flex-shrink: 0; place-items: center; border-radius: 50%; color: var(--shell-ink-muted); }
399.issue-chip-clear:hover { background: var(--shell-active); color: var(--shell-ink); }
400.issue-chip-date { position: absolute; right: 10px; bottom: 0; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
401
402/* Attachments under the issue description. */
403.issue-attachments { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 14px; }
404.issue-attachment { position: relative; display: inline-flex; max-width: 100%; align-items: center; }
405.issue-attachment.is-file { height: 30px; padding-inline: 10px; border: 1px solid var(--shell-hairline); border-radius: 8px; background: var(--shell-surface); transition: border-color 140ms, background 140ms; }
406.issue-attachment.is-file:hover { border-color: var(--shell-tile); background: var(--shell-field); }
407.issue-attachment-link { display: inline-flex; min-width: 0; align-items: center; gap: 7px; padding: 0; border: 0; background: transparent; color: var(--shell-ink); font-size: 12.5px; }
408.issue-attachment-link > svg { flex-shrink: 0; color: var(--shell-ink-muted); }
409.issue-attachment-name { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
410.issue-attachment-size { flex-shrink: 0; color: var(--shell-ink-muted); font-size: 11.5px; font-variant-numeric: tabular-nums; }
411.issue-attachment-image { display: block; overflow: hidden; padding: 0; border: 1px solid var(--shell-hairline); border-radius: 8px; background: var(--shell-field); line-height: 0; transition: border-color 140ms; }
412.issue-attachment-image:hover { border-color: var(--shell-tile); }
413.issue-attachment-image img { display: block; width: 132px; height: 88px; object-fit: cover; }
414.issue-attachment-remove { position: absolute; top: -6px; right: -6px; display: grid; width: 18px; height: 18px; place-items: center; border: 1px solid var(--shell-hairline); border-radius: 50%; background: var(--shell-surface); color: var(--shell-ink-muted); opacity: 0; transition: opacity 140ms, color 140ms; }
415.issue-attachment:hover .issue-attachment-remove, .issue-attachment:focus-within .issue-attachment-remove { opacity: 1; }
416.issue-attachment-remove:hover { color: #b42318; }
417
418/* The dialog's FilterMenus wear the same chip and open upwards. */
419.issue-form-bar .board-filter-trigger { height: 28px; padding-inline: 10px; gap: 6px; border: 1px solid var(--shell-hairline); border-radius: 999px; background: transparent; color: var(--shell-ink-muted); font-size: 12px; }
420.issue-form-bar .board-filter-trigger:hover, .issue-form-bar .board-filter-trigger[aria-expanded='true'] { border-color: var(--shell-hairline); background: var(--shell-field); color: var(--shell-ink); }
421.issue-form-bar .board-filter-trigger-value { max-width: 150px; }
422.issue-form-bar .board-filter-chevron { opacity: 0; transition: opacity 140ms; }
423.issue-form-bar .board-filter-trigger:hover .board-filter-chevron, .issue-form-bar .board-filter-trigger[aria-expanded='true'] .board-filter-chevron { opacity: .7; }
424.issue-form-bar .board-filter-popover { top: auto; bottom: calc(100% + 6px); margin: 0; }
425.issue-form-popover { position: absolute; bottom: calc(100% + 6px); left: 0; z-index: 40; width: 248px; max-height: 280px; overflow-y: auto; }
426.issue-form-popover.is-right { left: auto; right: 0; }
427
208428/* Bare filter trigger = a rail row that opens the picker. */
209429.board-filter-trigger-bare { width: 100%; height: 30px; justify-content: flex-start; gap: 8px; padding-inline: 8px; border: 0; border-radius: 7px; background: transparent; color: var(--shell-ink); }
210430.board-filter-trigger-bare:hover, .board-filter-trigger-bare[aria-expanded='true'] { background: var(--shell-field); }
@@ -271,5 +491,152 @@
271491}
272492@media (prefers-reduced-motion: reduce) {
273493 .issue-board-skeleton { animation: none; }
494 .issue-list-row, .issue-board-card { animation: none; }
495 .board-filter-summary { transition: none; }
274496 .issue-board-card:hover, .board-create-button:hover { transform: none; }
275497}
498
499/* ------------------------------------------------- 问题集管理(Linear 风格) --- */
500.issue-dialog.issue-manage-dialog {
501 padding: 0;
502 border: 0;
503 border-radius: 14px;
504 overflow: hidden;
505 box-shadow: 0 24px 60px rgb(15 20 35 / 24%), 0 2px 8px rgb(15 20 35 / 12%);
506}
507.issue-manage-dialog .issue-dialog-header {
508 margin: 0;
509 align-items: center;
510 padding: 13px 18px;
511 border-bottom: 1px solid var(--shell-hairline);
512}
513.issue-manage-dialog .issue-dialog-content {
514 padding: 14px 18px 16px;
515 overflow: visible;
516}
517.issue-manage-dialog .issue-dialog-footer {
518 margin: 0;
519 padding: 11px 18px;
520 border-top: 1px solid var(--shell-hairline);
521 background: var(--shell-board);
522}
523.issue-manage-tabs {
524 display: inline-flex;
525 gap: 2px;
526 padding: 2px;
527 border-radius: 8px;
528 background: var(--shell-field);
529}
530.issue-manage-dialog .issue-manage-tab {
531 height: 26px;
532 padding: 0 12px;
533 border: 0;
534 border-radius: 6px;
535 background: transparent;
536 color: var(--shell-ink-muted);
537 font-size: 12.5px;
538 font-weight: 500;
539 transition: background 140ms, color 140ms, box-shadow 140ms;
540}
541.issue-manage-dialog .issue-manage-tab:hover {
542 color: var(--shell-ink);
543}
544.issue-manage-dialog .issue-manage-tab[data-active] {
545 background: var(--shell-surface);
546 color: var(--shell-ink);
547 box-shadow: 0 1px 2px rgb(20 30 60 / 14%);
548}
549.issue-manage-body {
550 display: flex;
551 flex-direction: column;
552 gap: 14px;
553}
554.issue-manage-field {
555 display: flex;
556 flex-direction: column;
557 gap: 6px;
558}
559.issue-manage-label {
560 color: var(--shell-ink);
561 font-size: 12px;
562 font-weight: 550;
563}
564.issue-manage-input {
565 width: 100%;
566 height: 34px;
567 padding: 0 10px;
568 border: 1px solid var(--shell-hairline);
569 border-radius: 8px;
570 background: var(--shell-surface);
571 color: var(--shell-ink);
572 font-size: 13px;
573 outline: none;
574 transition: border-color 140ms, box-shadow 140ms;
575}
576.issue-manage-input:disabled {
577 opacity: .6;
578}
579/*
580 * Linear-quiet fields: one hairline, a slightly deeper hairline on hover, and
581 * a soft grey ring on focus. The shared workspace rules (and the older
582 * `.issue-field-input:focus`) draw a near-black frame there, which reads as an
583 * error state instead of "selected" — so every field inside this dialog is
584 * restated here, at a specificity that wins over those.
585 */
586.issue-manage-dialog :is(input, textarea, select):not([type="checkbox"]):not([type="radio"]) {
587 border: 1px solid var(--shell-hairline);
588 border-radius: 8px;
589 background: var(--shell-surface);
590 box-shadow: none;
591 transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
592}
593.issue-manage-dialog :is(input, textarea, select):not([type="checkbox"]):not([type="radio"]):hover:not(:focus):not(:disabled) {
594 border-color: color-mix(in srgb, var(--shell-ink) 18%, transparent);
595}
596.issue-manage-dialog :is(input, textarea, select):not([type="checkbox"]):not([type="radio"]):focus {
597 border-color: color-mix(in srgb, var(--shell-focus) 55%, transparent);
598 outline: none;
599 box-shadow: 0 0 0 3px color-mix(in srgb, var(--shell-focus) 16%, transparent);
600}
601.issue-manage-dialog .issue-manage-textarea {
602 height: auto;
603 min-height: 74px;
604 padding: 8px 10px;
605 line-height: 1.65;
606 resize: vertical;
607}
608.issue-manage-prefix {
609 width: 168px;
610 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
611 font-size: 12.5px;
612 letter-spacing: .06em;
613}
614.issue-manage-prefix.is-invalid {
615 border-color: #e5484d;
616}
617.issue-manage-hint {
618 color: var(--shell-ink-muted);
619 font-size: 11.5px;
620 line-height: 1.55;
621}
622.issue-manage-error {
623 color: #b42318;
624 font-size: 11.5px;
625 line-height: 1.55;
626}
627.issue-manage-stats {
628 color: var(--shell-ink-muted);
629 font-size: 11.5px;
630}
631.issue-manage-footer {
632 display: flex;
633 width: 100%;
634 align-items: center;
635 gap: 8px;
636}
637.issue-manage-footer-actions {
638 display: flex;
639 margin-left: auto;
640 align-items: center;
641 gap: 8px;
642}
Modified web/src/features/issue-board/ui/dialog.tsx View web/src/features/issue-board/ui/dialog.tsx
@@ -13,6 +13,8 @@
1313 open,
1414 onClose,
1515 width = 'max-w-[560px]',
16 panelClassName = '',
17 header,
1618 children,
1719 footer,
1820}: {
@@ -21,6 +23,10 @@
2123 open: boolean
2224 onClose: () => void
2325 width?: string
26 /** Extra classes for the panel (feature-specific chrome). */
27 panelClassName?: string
28 /** Replaces the default title block; the close button and the a11y name stay. */
29 header?: ReactNode
2430 children: ReactNode
2531 footer?: ReactNode
2632}) {
@@ -82,13 +88,20 @@
8288 aria-labelledby={titleId}
8389 aria-describedby={description ? descriptionId : undefined}
8490 onClick={event => event.stopPropagation()}
85 className={`issue-dialog flex max-h-[86vh] w-full flex-col rounded-xl border-2 border-shell-hairline bg-shell-surface p-5 shadow-xl ${width}`}
91 className={`issue-dialog flex max-h-[86vh] w-full flex-col rounded-xl border-2 border-shell-hairline bg-shell-surface p-5 shadow-xl ${width} ${panelClassName}`}
8692 >
8793 <div className="issue-dialog-header mb-3 flex items-start justify-between gap-3">
88 <div className="min-w-0">
89 <h2 id={titleId} className="text-[16px] font-semibold">{title}</h2>
90 {description ? <p id={descriptionId} className="mt-1 text-[12.5px] leading-[1.5] text-shell-ink-muted">{description}</p> : null}
91 </div>
94 {header ? (
95 <>
96 <h2 id={titleId} className="sr-only">{title}</h2>
97 {header}
98 </>
99 ) : (
100 <div className="min-w-0">
101 <h2 id={titleId} className="text-[16px] font-semibold">{title}</h2>
102 {description ? <p id={descriptionId} className="mt-1 text-[12.5px] leading-[1.5] text-shell-ink-muted">{description}</p> : null}
103 </div>
104 )}
92105 <button
93106 type="button"
94107 aria-label={t('issue.board.closeDialog')}
Modified web/src/features/issue-board/ui/issue-board.tsx View web/src/features/issue-board/ui/issue-board.tsx
@@ -8,23 +8,28 @@
88 * the request state, never from placeholder data.
99 */
1010import { useState } from 'react'
11import { ArrowRight, CircleCheck, Inbox, Loader2, Plus } from 'lucide-react'
11import { ArrowRight, Circle, CircleCheck, Inbox, Loader2, Milestone as MilestoneIcon, Plus } from 'lucide-react'
1212import './board-polish.css'
13import { ErrorPanel } from '@/shared/ui'
13import { ErrorPanel, Markdown } from '@/shared/ui'
1414import { useT } from '@/shared/i18n'
1515import {
1616 PRIORITIES,
17 STATES,
18 issueLabel,
19 issueRef,
20 issueStamp,
21 normalizeColor,
1722 priorityOf,
1823 updateIssue,
24 type Board,
1925 type BoardRef,
2026 type IssueBoardView,
2127 type IssueListItem,
2228 type IssuePriority,
2329 type Milestone,
2430} from '@/entities/issue'
25import { fmtRelative } from '@/shared/lib'
2631import type { BoardData } from '../model/use-board-data'
27import { AvatarStack, DueChip, PriorityBadge, StateBadge } from './parts'
32import { AvatarStack, DueChip, FilterMenu, PriorityBadge, PriorityGlyph } from './parts'
2833
2934export interface IssueBoardBodyProps {
3035 boardRef: BoardRef
@@ -87,7 +92,7 @@
8792 )
8893 }
8994
90 const rows = applyMilestoneFilter(loaded, view.milestoneId)
95 const rows = loaded
9196
9297 if (rows.length === 0) {
9398 return props.filtered ? (
@@ -107,15 +112,10 @@
107112
108113 return (
109114 <div className="issue-board-body flex min-h-0 w-full flex-col gap-2">
110 {rows.length !== loaded.length ? (
111 <p className="text-[11.5px] text-shell-ink-muted">
112 {t('issue.board.milestoneClipped', { total, hidden: loaded.length - rows.length })}
113 </p>
114 ) : null}
115115 {view.view === 'board' ? (
116116 <BoardColumns {...props} rows={rows} />
117117 ) : (
118 <IssueTable {...props} rows={rows} />
118 <IssueList {...props} rows={rows} />
119119 )}
120120 <Pager view={view} total={total} limit={limit} onFilter={props.onFilter} />
121121 {state.loading ? (
@@ -127,11 +127,6 @@
127127 )
128128}
129129
130function applyMilestoneFilter(rows: IssueListItem[], milestoneId: string): IssueListItem[] {
131 if (milestoneId !== 'none') return rows
132 return rows.filter(issue => !issue.milestone_id)
133}
134
135130/* ------------------------------------------------------------- kanban --- */
136131
137132function BoardColumns({boardRef,
@@ -222,12 +217,13 @@
222217 <IssueCard
223218 key={issue.id}
224219 issue={{ ...issue, priority: moves[issue.id] ?? issue.priority }}
220 board={data.board.data}
225221 moving={dragging === issue.id}
226222 milestone={data.milestones.data?.items.find(m => m.id === issue.milestone_id)}
227223 draggable
228224 onDragStart={() => setDragging(issue.id)}
229225 onDragEnd={() => setDragging(null)}
230 onOpen={() => onOpenIssue(String(issue.number))}
226 onOpen={() => onOpenIssue(issueRef(data.board.data, issue.number))}
231227 />
232228 ))}
233229 {cards.length === 0 ? (
@@ -252,6 +248,7 @@
252248
253249function IssueCard({
254250 issue,
251 board,
255252 milestone,
256253 onOpen,
257254 draggable,
@@ -260,6 +257,7 @@
260257 onDragEnd,
261258}: {
262259 issue: IssueListItem
260 board?: Board
263261 milestone?: Milestone
264262 onOpen: () => void
265263 draggable?: boolean
@@ -287,15 +285,17 @@
287285 }}
288286 role="button"
289287 tabIndex={0}
290 aria-label={t('issue.board.cardAria', { number: issue.number, title: issue.title })}
288 aria-label={t('issue.board.cardAria', { number: issueLabel(board, issue.number), title: issue.title })}
291289 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"
292290 >
293291 <div className="flex items-start gap-2">
294 <p className="issue-board-card-title min-w-0 flex-1 text-[13px] font-medium leading-5 break-words">{issue.title}</p>
292 <p className="issue-board-card-title min-w-0 flex-1 text-[13px] font-medium leading-5 break-words">
293 <Markdown inline>{issue.title}</Markdown>
294 </p>
295295 <PriorityBadge priority={issue.priority} compact />
296296 </div>
297297 <div className="issue-board-card-meta flex items-center gap-2">
298 <span className="font-mono text-[11px] text-shell-ink-muted">#{issue.number}</span>
298 <span className="font-mono text-[11px] text-shell-ink-muted">{issueLabel(board, issue.number)}</span>
299299 {milestone ? (
300300 <span className="truncate text-[11px] text-shell-ink-muted" title={t('issue.board.milestoneTitle', { name: milestone.name })}>
301301 {milestone.name}
@@ -311,99 +311,96 @@
311311 )
312312}
313313
314/* -------------------------------------------------------------- table --- */
314/* --------------------------------------------------------------- list --- */
315315
316function IssueTable({boardRef,
316/**
317 * Linear-style rows: priority mark, state mark, key, title, then the trailing
318 * facts (milestone, assignees, updated). No header, no cell borders, no card —
319 * hover is the only container, so the page reads as one list of issues.
320 */
321function IssueList({boardRef,
317322 rows,
318323 data,
319324 onOpenIssue,
320325 onChanged,
321326}: IssueBoardBodyProps & { rows: IssueListItem[] }) {
322327 const { t } = useT()
328 // One shared width for the key column: a long prefix (APACHE-17) must not
329 // wrap, and every row still starts its title at the same x.
330 const keyChars = Math.max(4, ...rows.map(issue => issueLabel(data.board.data, issue.number).length))
323331 return (
324 <div className="issue-board-table min-w-0 overflow-x-auto rounded-lg border-2 border-shell-hairline bg-shell-surface">
325 <table className="w-full border-collapse text-[13px]">
326 <caption className="sr-only">{t('issue.board.tableCaption')}</caption>
327 <thead className="bg-shell-field text-[12px] text-shell-ink-muted">
328 <tr>
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>
336 </tr>
337 </thead>
338 <tbody>
339 {rows.map(issue => (
340 <tr
341 key={issue.id}
342 onClick={() => onOpenIssue(String(issue.number))}
343 className="cursor-pointer border-t border-shell-hairline transition-colors hover:bg-shell-field"
332 <div className="issue-list" role="list" aria-label={t('issue.board.tableCaption')}>
333 {rows.map(issue => {
334 const milestone = data.milestones.data?.items.find(item => item.id === issue.milestone_id)
335 const open = () => onOpenIssue(issueRef(data.board.data, issue.number))
336 return (
337 <div key={issue.id} role="listitem" className="issue-list-row" onClick={open}>
338 {/* The priority mark is a control inside a clickable row: its own
339 clicks must not fall through and open the issue. */}
340 <span className="issue-list-priority" onClick={event => event.stopPropagation()}>
341 <FilterMenu
342 label={t('issue.board.filter.priority')}
343 iconOnly
344 leading={<PriorityGlyph priority={issue.priority} />}
345 value={issue.priority}
346 options={PRIORITIES.map(meta => ({ value: meta.id, label: meta.label, color: meta.color }))}
347 onChange={async value => {
348 await updateIssue(boardRef, issue.id, { priority: value as IssuePriority }).catch(() => undefined)
349 onChanged()
350 }}
351 />
352 </span>
353 <span className="issue-list-state" data-state={issue.state} title={STATES[issue.state]?.label}>
354 {issue.state === 'closed' ? (
355 <CircleCheck className="h-3.5 w-3.5" aria-hidden />
356 ) : (
357 <Circle className="h-3.5 w-3.5" aria-hidden />
358 )}
359 <span className="sr-only">{STATES[issue.state]?.label}</span>
360 </span>
361 <span className="issue-list-key" style={{ minWidth: keyChars + 'ch' }}>
362 {issueLabel(data.board.data, issue.number)}
363 </span>
364 <button
365 type="button"
366 className="issue-list-title"
367 title={issue.title}
368 onClick={event => {
369 event.stopPropagation()
370 open()
371 }}
344372 >
345 <td className="px-2 py-2">
346 <StateBadge state={issue.state} />
347 </td>
348 <td className="px-2 py-2 font-mono text-[12px] text-shell-ink-muted">#{issue.number}</td>
349 <td className="max-w-0 px-2 py-2">
350 <button type="button" onClick={event => { event.stopPropagation(); onOpenIssue(String(issue.number)) }} className="issue-board-table-title block w-full truncate text-left font-medium" title={issue.title}>
351 {issue.title}
352 </button>
353 </td>
354 <td className="px-2 py-2">
355 <InlinePriority
356 value={issue.priority}
357 busy={false}
358 onChange={async priority => {
359 await updateIssue(boardRef, issue.id, { priority }).catch(() => undefined)
360 onChanged()
361 }}
362 />
363 </td>
364 <td className="truncate px-2 py-2 text-[12px] text-shell-ink-muted">
365 {data.milestones.data?.items.find(m => m.id === issue.milestone_id)?.name ?? '—'}
366 </td>
367 <td className="px-2 py-2">
368 <AvatarStack users={issue.assignees} max={2} />
369 </td>
370 <td className="px-2 py-2 text-[12px] text-shell-ink-muted" title={issue.updated_at ?? issue.created_at}>
371 {fmtRelative(issue.updated_at ?? issue.created_at)}
372 </td>
373 </tr>
374 ))}
375 </tbody>
376 </table>
373 <Markdown inline>{issue.title}</Markdown>
374 </button>
375 <span className="issue-list-meta">
376 {(issue.labels ?? []).slice(0, 3).map(label => (
377 <span key={label.id} className="issue-list-chip" title={label.name}>
378 <span
379 className="issue-list-dot"
380 style={{ backgroundColor: normalizeColor(label.color) }}
381 aria-hidden
382 />
383 <span className="issue-list-chip-text">{label.name}</span>
384 </span>
385 ))}
386 {(issue.labels ?? []).length > 3 ? (
387 <span className="issue-list-chip">+{(issue.labels ?? []).length - 3}</span>
388 ) : null}
389 {milestone ? (
390 <span className="issue-list-chip" title={t('issue.board.milestoneTitle', { name: milestone.name })}>
391 <MilestoneIcon className="h-3 w-3 shrink-0" aria-hidden />
392 <span className="issue-list-chip-text">{milestone.name}</span>
393 </span>
394 ) : null}
395 <AvatarStack users={issue.assignees} max={2} size={20} />
396 <span className="issue-list-date" title={issue.updated_at ?? issue.created_at}>
397 {issueStamp(issue.updated_at ?? issue.created_at)}
398 </span>
399 </span>
400 </div>
401 )
402 })}
377403 </div>
378 )
379}
380
381function InlinePriority({
382 value,
383 busy,
384 onChange,
385}: {
386 value: IssuePriority
387 busy: boolean
388 onChange: (priority: IssuePriority) => void
389}) {
390 const { t } = useT()
391 return (
392 <label className="flex items-center gap-1" onClick={event => event.stopPropagation()}>
393 <select
394 value={value}
395 disabled={busy}
396 onChange={event => onChange(event.target.value as IssuePriority)}
397 aria-label={t('issue.board.changePriority')}
398 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"
399 >
400 {PRIORITIES.map(meta => (
401 <option key={meta.id} value={meta.id}>
402 {meta.label}
403 </option>
404 ))}
405 </select>
406 </label>
407404 )
408405}
409406
Modified web/src/features/issue-board/ui/issue-detail.tsx View web/src/features/issue-board/ui/issue-detail.tsx
@@ -4,7 +4,7 @@
44 * and refreshes only this view's data — no document reload, and the board
55 * behind it keeps its filters.
66 */
7import { useEffect, useRef, useState } from 'react'
7import { useEffect, useRef, useState, type ReactNode } from 'react'
88import {
99 ArrowLeft,
1010 ArrowUp,
@@ -12,19 +12,21 @@
1212 Calendar,
1313 Check,
1414 ChevronDown,
15 FileText,
1516 Flag,
1617 FolderGit2,
1718 GitPullRequest,
1819 Loader2,
1920 Milestone as MilestoneIcon,
21 MoreHorizontal,
2022 Pencil,
2123 Plus,
2224 Users,
2325 X,
2426} from 'lucide-react'
25import { fmtTime, issueKey, spaceReposPickKey, useAsync } from '@/shared/lib'
27import { fmtTime, formatBytes, issueKey, spaceReposPickKey, useAsync } from '@/shared/lib'
2628import { parseDateValue, t as tr, useT } from '@/shared/i18n'
27import { ErrorPanel } from '@/shared/ui'
29import { ErrorPanel, Markdown } from '@/shared/ui'
2830import {
2931 PRIORITIES,
3032 bindLabel,
@@ -33,10 +35,14 @@
3335 dateInputValue,
3436 deleteComment,
3537 deleteIssue,
38 deleteIssueAttachment,
3639 eventLabel,
3740 getIssue,
41 issueAttachmentUrl,
42 issueLabel,
3843 listCommentTree,
3944 listEvents,
45 listIssueAttachments,
4046 listIssueLabels,
4147 normalizeColor,
4248 reopenIssue,
@@ -67,9 +73,15 @@
6773 ignore: 'issue.watch.ignore',
6874}
6975
70/** Entries of the merged activity stream: change events + comment threads. */
76type IssueEvent = import('@/entities/issue').IssueEvent
77
78/**
79 * Entries of the merged activity stream: change events + comment threads.
80 * A run of the same change by the same person collapses into one entry, so
81 * "调整了里程碑 ×4" reads as one line with a count instead of four rows.
82 */
7183type FeedEntry =
72 | { kind: 'event'; key: string; at: number; event: import('@/entities/issue').IssueEvent }
84 | { kind: 'events'; key: string; at: number; events: IssueEvent[] }
7385 | { kind: 'comment'; key: string; at: number; node: import('@/entities/issue').CommentNode }
7486
7587/** Timeline rows shown before the "show earlier" toggle appears. */
@@ -81,6 +93,15 @@
8193 return Number.isNaN(ms) ? 0 : ms
8294}
8395
96/** `09-23` this year, `2025-09-23` otherwise — a timeline stays scannable. */
97function fmtDay(value: string): string {
98 const date = parseDateValue(value)
99 if (Number.isNaN(date.getTime())) return ''
100 const pad = (n: number) => String(n).padStart(2, '0')
101 const day = `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
102 return date.getFullYear() === new Date().getFullYear() ? day : `${date.getFullYear()}-${day}`
103}
104
84105export interface IssueDetailProps {
85106 boardRef: BoardRef
86107 /** Board-scoped number or uuid, straight from the route. */
@@ -115,6 +136,9 @@
115136 const events = useAsync(() => listEvents(boardRef, issueId), stamp, {
116137 cacheKey: issueKey(boardRef.space, boardRef.board, issueId, 'events'),
117138 })
139 const attachments = useAsync(() => listIssueAttachments(boardRef, issueId), stamp, {
140 cacheKey: issueKey(boardRef.space, boardRef.board, issueId, 'attachments'),
141 })
118142 const issue = detail.data
119143 const failure = detail.error as { code?: number; message?: string } | undefined
120144
@@ -166,6 +190,7 @@
166190 }
167191
168192 const boundLabels = labels.data ?? []
193 const attachmentRows = attachments.data ?? []
169194 const milestoneOptions = (data.milestones.data?.items ?? []).filter(m => !m.is_closed)
170195 const milestoneName = issue.milestone_id
171196 ? (milestoneOptions.find(m => m.id === issue.milestone_id)?.name ?? t('issue.board.milestoneUnknown'))
@@ -189,7 +214,7 @@
189214 (event.event_type === 'comment_created' || event.event_type === 'comment_updated')
190215 ),
191216 )
192 const feed: FeedEntry[] = [
217 const rawFeed: FeedEntry[] = [
193218 ...rootNodes.map(node => ({
194219 kind: 'comment' as const,
195220 key: `c-${node.comment.id}`,
@@ -197,14 +222,34 @@
197222 node,
198223 })),
199224 ...eventItems.map(event => ({
200 kind: 'event' as const,
225 kind: 'events' as const,
201226 key: `e-${event.id}`,
202227 at: feedStamp(event.created_at),
203 event,
228 events: [event],
204229 })),
205230 ].sort((a, b) => a.at - b.at)
231 // Collapse adjacent events of the same type by the same actor.
232 const feed: FeedEntry[] = []
233 for (const entry of rawFeed) {
234 const previous = feed.at(-1)
235 const head = entry.kind === 'events' ? entry.events[0] : null
236 const previousHead = previous?.kind === 'events' ? previous.events[0] : null
237 const sameRun =
238 head &&
239 previousHead &&
240 head.event_type === previousHead.event_type &&
241 (head.user?.user_id ?? '') === (previousHead.user?.user_id ?? '')
242 if (sameRun && previous?.kind === 'events') {
243 previous.events.push(head!)
244 previous.at = entry.at
245 continue
246 }
247 feed.push(entry)
248 }
206249 const earlier = Math.max(0, feed.length - FEED_WINDOW)
207 const visibleFeed = feedExpanded ? feed : feed.slice(-FEED_WINDOW)
250 // Collapsing a single row hides more than it explains: only fold real runs.
251 const collapsible = earlier > 1
252 const visibleFeed = feedExpanded || !collapsible ? feed : feed.slice(-FEED_WINDOW)
208253
209254 return (
210255 <div className="issue-detail">
@@ -243,7 +288,9 @@
243288 {t('issue.board.reopen')}
244289 </button>
245290 )}
246 <DangerButton label={t('common.action.delete')} className="issue-action-danger" onConfirm={() => void deleteAndBack(issue.id)} />
291 {/* Deleting is rare and irreversible: it lives behind ⋯ with a
292 two-step confirm instead of sitting next to 关闭工单. */}
293 <IssueMoreMenu onDelete={() => void deleteAndBack(issue.id)} />
247294 </div>
248295 </div>
249296
@@ -251,13 +298,15 @@
251298 <div className="issue-detail-main">
252299 <header className="issue-detail-header">
253300 <p className="issue-detail-key">
254 <span className="issue-detail-number">#{issue.number}</span>
301 <span className="issue-detail-number">{issueLabel(data.board.data, issue.number)}</span>
255302 <StateBadge state={issue.state} />
256303 <PriorityBadge priority={issue.priority} />
257304 </p>
258 <h1 className="issue-detail-title">{issue.title}</h1>
259 {issue.description ? <p className="issue-detail-desc">{issue.description}</p> : null}
260 {issue.content ? <p className="issue-detail-desc">{issue.content}</p> : null}
305 <h1 className="issue-detail-title">
306 <Markdown inline>{issue.title}</Markdown>
307 </h1>
308 {issue.description ? <Markdown className="issue-detail-desc">{issue.description}</Markdown> : null}
309 {issue.content ? <Markdown className="issue-detail-desc">{issue.content}</Markdown> : null}
261310 {!issue.description && !issue.content ? (
262311 <p className="issue-detail-desc is-empty">{t('issue.board.noBody')}</p>
263312 ) : null}
@@ -274,38 +323,67 @@
274323 ) : null}
275324 {issue.closed_at ? <span>· {t('issue.board.closedAt', { time: fmtTime(issue.closed_at) })}</span> : null}
276325 </p>
326 {attachmentRows.length > 0 ? (
327 <div className="issue-attachments">
328 {attachmentRows.map(row => {
329 const url = issueAttachmentUrl(row.id)
330 const isImage = row.content_type.startsWith('image/')
331 const mine = Boolean(viewerId && row.uploader_id === viewerId)
332 const open = () => window.open(url, '_blank', 'noopener,noreferrer')
333 return (
334 <span key={row.id} className={`issue-attachment ${isImage ? 'is-image' : 'is-file'}`}>
335 {isImage ? (
336 <button
337 type="button"
338 className="issue-attachment-image"
339 onClick={open}
340 aria-label={t('issue.board.attachment.open', { name: row.filename })}
341 title={row.filename}
342 >
343 <img src={url} alt={row.filename} loading="lazy" />
344 </button>
345 ) : (
346 <button
347 type="button"
348 className="issue-attachment-link"
349 onClick={open}
350 aria-label={t('issue.board.attachment.open', { name: row.filename })}
351 title={row.filename}
352 >
353 <FileText className="h-3.5 w-3.5" aria-hidden />
354 <span className="issue-attachment-name">{row.filename}</span>
355 <span className="issue-attachment-size">{formatBytes(row.size)}</span>
356 </button>
357 )}
358 {mine ? (
359 <button
360 type="button"
361 className="issue-attachment-remove"
362 aria-label={t('issue.board.form.removeAttachment', { name: row.filename })}
363 title={t('issue.board.form.removeAttachment', { name: row.filename })}
364 onClick={() => void removeAttachment(row.id)}
365 >
366 <X className="h-3 w-3" aria-hidden />
367 </button>
368 ) : null}
369 </span>
370 )
371 })}
372 </div>
373 ) : null}
277374 </header>
278375
279376 <section className="issue-feed">
280377 <div className="issue-feed-head">
281378 <h2>{t('issue.board.activity')}</h2>
282379 <span className="issue-feed-count">{tc('issue.board.activityCount', events.data?.total ?? 0)}</span>
283 <div className="issue-feed-head-actions">
284 <FilterMenu
285 bare
286 align="right"
287 label={t('issue.board.notifyField')}
288 leading={
289 <>
290 <Bell className="h-3.5 w-3.5" aria-hidden />
291 <span>{t('issue.board.notifyField')}</span>
292 </>
293 }
294 value={issue.viewer_watch ?? 'unwatched'}
295 options={(Object.keys(WATCH_LABEL_KEYS) as WatchMode[]).map(mode => ({
296 value: mode,
297 label: t(WATCH_LABEL_KEYS[mode]),
298 }))}
299 onChange={value => void run(() => setIssueWatch(boardRef, issue.id, value as WatchMode))}
300 />
301 </div>
302380 </div>
303381
304382 {comments.loading && !comments.data ? <p className="issue-feed-note">{t('issue.board.loadingComments')}</p> : null}
305383 {comments.error ? <p className="issue-feed-note">{t('issue.board.commentsFailed')}</p> : null}
306384 {events.error ? <p className="issue-feed-note">{t('issue.board.activityFailed')}</p> : null}
307385
308 {earlier > 0 ? (
386 {collapsible ? (
309387 <button
310388 type="button"
311389 className="issue-feed-more"
@@ -335,7 +413,15 @@
335413 onReload={() => setRetry(v => v + 1)}
336414 />
337415 ) : (
338 <EventRow key={entry.key} event={entry.event} />
416 <EventRow
417 key={entry.key}
418 events={entry.events}
419 memberName={id =>
420 (data.members.data?.items ?? [])
421 .map(item => item.user)
422 .find(user => user?.user_id === id)?.display_name ?? undefined
423 }
424 />
339425 ),
340426 )}
341427
@@ -375,37 +461,34 @@
375461 options={milestoneOptions.map(m => ({ value: m.id, label: m.name }))}
376462 onChange={value => void run(() => updateIssue(boardRef, issue.id, { milestone_id: value || null }))}
377463 />
378 <div className="issue-prop-row">
379 <Calendar className="issue-prop-icon h-3.5 w-3.5" aria-hidden />
380 <span className="issue-prop-label">{t('issue.board.filter.due')}</span>
381 <span className="issue-prop-value">
382 {issue.due_at ? (
383 <button
384 type="button"
385 className="issue-prop-clear"
386 aria-label={t('issue.board.clearDate')}
387 title={t('issue.board.clearDate')}
388 onClick={() => void run(() => updateIssue(boardRef, issue.id, { due_at: null }))}
389 >
390 <X className="h-3 w-3" aria-hidden />
391 </button>
392 ) : null}
393 <input
394 type="date"
395 aria-label={t('issue.board.filter.due')}
396 value={dateInputValue(issue.due_at)}
397 onChange={event =>
398 void run(() => updateIssue(boardRef, issue.id, { due_at: dateApiValue(event.target.value) }))
399 }
400 className={`issue-prop-date bg-transparent ${issue.due_at ? '' : 'is-empty'}`}
401 />
402 </span>
403 </div>
464 {/* DueProp 自带 .issue-prop-row:再包一层会多 8px 内边距,右对齐差一行 */}
465 <DueProp
466 value={issue.due_at}
467 onChange={next => void run(() => updateIssue(boardRef, issue.id, { due_at: next }))}
468 />
404469 <AssigneeMenu
405470 members={data.members.data?.items ?? []}
406471 selected={issue.assignees.map(user => user.user_id)}
407472 onChange={ids => void run(() => updateIssue(boardRef, issue.id, { assignee_ids: ids }))}
408473 />
474 {/* Subscription is an issue property, not a feed control. */}
475 <FilterMenu
476 bare
477 label={t('issue.board.notifyField')}
478 leading={
479 <>
480 <Bell className="h-3.5 w-3.5" aria-hidden />
481 <span>{t('issue.board.notifyField')}</span>
482 </>
483 }
484 value={issue.viewer_watch ?? 'unwatched'}
485 active={t(WATCH_LABEL_KEYS[issue.viewer_watch ?? 'unwatched'])}
486 options={(Object.keys(WATCH_LABEL_KEYS) as WatchMode[]).map(mode => ({
487 value: mode,
488 label: t(WATCH_LABEL_KEYS[mode]),
489 }))}
490 onChange={value => void run(() => setIssueWatch(boardRef, issue.id, value as WatchMode))}
491 />
409492 </div>
410493 </div>
411494
@@ -452,16 +535,131 @@
452535 setNotice(t('issue.board.deleteFailed', { reason: (err as Error)?.message ?? t('common.state.unknown') }))
453536 }
454537 }
538
539 async function removeAttachment(id: string) {
540 try {
541 await deleteIssueAttachment(id)
542 setRetry(v => v + 1)
543 } catch (err) {
544 setNotice(t('issue.board.deleteFailed', { reason: (err as Error)?.message ?? t('common.state.unknown') }))
545 }
546 }
547}
548
549/**
550 * 截止日期:和优先级/里程碑/负责人一样是「点开选择器」的属性行,未设置时
551 * 保持灰字。`<input type="date">` 本体只用来看/收值,日历由 showPicker 打开。
552 */
553function DueProp({ value, onChange }: { value: string | null | undefined; onChange: (next: string | null) => void }) {
554 const { t } = useT()
555 const input = useRef<HTMLInputElement | null>(null)
556 const label = value ? fmtTime(value) : t('issue.board.unset')
557 return (
558 <div className="issue-prop-row issue-prop-row-action">
559 <button
560 type="button"
561 className="issue-prop-target"
562 onClick={() => {
563 const field = input.current
564 if (!field) return
565 if (typeof field.showPicker === 'function') {
566 try {
567 field.showPicker()
568 return
569 } catch {
570 /* fall through to a plain click when the picker is unavailable */
571 }
572 }
573 field.click()
574 }}
575 >
576 <Calendar className="issue-prop-icon h-3.5 w-3.5" aria-hidden />
577 <span className="issue-prop-label">{t('issue.board.filter.due')}</span>
578 <span className={`issue-prop-value${value ? '' : ' is-empty'}`}>
579 <span className="issue-prop-text">{label}</span>
580 <ChevronDown className="issue-prop-chevron h-3 w-3" aria-hidden />
581 </span>
582 </button>
583 <input
584 ref={input}
585 type="date"
586 aria-label={t('issue.board.filter.due')}
587 value={dateInputValue(value)}
588 onChange={event => onChange(dateApiValue(event.target.value))}
589 className="issue-prop-date-sr"
590 />
591 {value ? (
592 <button
593 type="button"
594 className="issue-prop-clear"
595 aria-label={t('issue.board.clearDate')}
596 title={t('issue.board.clearDate')}
597 onClick={() => onChange(null)}
598 >
599 <X className="h-3 w-3" aria-hidden />
600 </button>
601 ) : null}
602 </div>
603 )
455604}
456605
457606/**
458607 * 工单侧的 PR 关联面板:列出关联(含 relation id,可解除)并支持新增关联。
459608 */
609/** Top-right overflow: the low-frequency, destructive actions of one issue. */
610function IssueMoreMenu({ onDelete }: { onDelete: () => void }) {
611 const { t } = useT()
612 const [open, setOpen] = useState(false)
613 const box = useRef<HTMLDivElement | null>(null)
614 useEffect(() => {
615 if (!open) return
616 const onDown = (event: MouseEvent) => {
617 if (!box.current?.contains(event.target as Node)) setOpen(false)
618 }
619 const onKey = (event: KeyboardEvent) => {
620 if (event.key === 'Escape') setOpen(false)
621 }
622 window.addEventListener('mousedown', onDown)
623 window.addEventListener('keydown', onKey)
624 return () => {
625 window.removeEventListener('mousedown', onDown)
626 window.removeEventListener('keydown', onKey)
627 }
628 }, [open])
629 return (
630 <div ref={box} className="issue-more">
631 <button
632 type="button"
633 className="issue-action"
634 aria-label={t('issue.board.more')}
635 title={t('issue.board.more')}
636 aria-expanded={open}
637 aria-haspopup="menu"
638 onClick={() => setOpen(value => !value)}
639 >
640 <MoreHorizontal className="h-3.5 w-3.5" aria-hidden />
641 </button>
642 {open ? (
643 <div className="issue-more-menu" role="menu">
644 <DangerButton
645 label={t('issue.board.deleteIssue')}
646 className="issue-more-danger"
647 onConfirm={onDelete}
648 />
649 </div>
650 ) : null}
651 </div>
652 )
653}
654
460655function IssuePullLinks({ boardRef, issueId, onChanged }: { boardRef: BoardRef; issueId: string; onChanged: () => void }) {
461656 const { t } = useT()
462657 const [tick, setTick] = useState(0)
463658 const [repoName, setRepoName] = useState('')
464659 const [pullRef, setPullRef] = useState('')
660 // The picker is a response to "+ 关联", not permanent furniture: an issue
661 // with no links should read as one quiet row, not a three-step form.
662 const [adding, setAdding] = useState(false)
465663 const [error, setError] = useState<string | null>(null)
466664 const [busy, setBusy] = useState(false)
467665 const pulls = useAsync(() => listIssuePulls(boardRef.space, boardRef.board, issueId), [boardRef.space, boardRef.board, issueId, tick])
@@ -494,6 +692,7 @@
494692 pull_ref: pullRef.trim(),
495693 })
496694 setPullRef('')
695 setAdding(false)
497696 setTick(t => t + 1)
498697 onChanged()
499698 } catch (f) {
@@ -506,83 +705,115 @@
506705 return (
507706 <div className="issue-pull-links">
508707 {rows.map((link: IssuePullLink) => (
509 <p key={link.relation_id} className="issue-pull-row">
510 <GitPullRequest className="issue-prop-icon h-3.5 w-3.5" aria-hidden />
511 <span className="min-w-0 flex-1 truncate" title={link.pull.title}>
512 #{link.pull.number} {link.pull.title}
513 <span className="issue-pull-state">
514 {t('issue.board.pullState', {
515 state:
516 link.pull.state === 'open'
517 ? t('issue.board.stateOpen')
518 : link.pull.state === 'merged'
519 ? t('pull.state.merged')
520 : t('pull.state.closed'),
521 })}
708 <div key={link.relation_id} className="issue-pull-card">
709 <div className="issue-pull-card-head">
710 <GitPullRequest className="issue-prop-icon h-3.5 w-3.5" aria-hidden />
711 <span className="issue-pull-card-title" title={link.pull.title}>
712 #{link.pull.number} {link.pull.title}
522713 </span>
523 </span>
714 <span className="issue-pull-state" data-state={link.pull.state}>
715 {link.pull.state === 'open'
716 ? t('issue.board.stateOpen')
717 : link.pull.state === 'merged'
718 ? t('pull.state.merged')
719 : t('pull.state.closed')}
720 </span>
721 </div>
722 <div className="issue-pull-card-foot">
723 {/* 两个分支各自截断,箭头保持不动:长分支名不会把箭头挤到中间。 */}
724 <span className="issue-pull-branch" title={link.pull.head.branch}>
725 {link.pull.head.branch}
726 </span>
727 <span className="issue-pull-arrow" aria-hidden>
728 →
729 </span>
730 <span className="issue-pull-branch issue-pull-branch-base" title={link.pull.base.branch}>
731 {link.pull.base.branch}
732 </span>
733 </div>
524734 <button
525735 type="button"
526736 aria-label={t('issue.board.unlink')}
527737 title={t('issue.board.unlink')}
528 className="issue-prop-clear"
738 className="issue-prop-clear issue-pull-card-remove"
529739 onClick={() =>
530740 void unlinkIssuePull(boardRef.space, boardRef.board, issueId, link.relation_id)
531 .then(() => setTick(t => t + 1))
741 .then(() => {
742 setTick(t => t + 1)
743 // 解除关联也是一条活动记录(后端会写 pull_unlinked),刷新整页数据。
744 onChanged()
745 })
532746 .catch(() => undefined)
533747 }
534748 >
535749 <X className="h-3 w-3" aria-hidden />
536750 </button>
537 </p>
751 </div>
538752 ))}
539753 {rows.length === 0 ? <p className="issue-rail-empty">{t('issue.board.noPullLinks')}</p> : null}
540 {/*
541 A repo is addressed by its exact name, so it is picked from the space's
542 own repos instead of typed; the PR is then picked from that repo's own
543 numbering. Both pickers read as plain rail rows.
544 */}
545 {repoOptions.length > 0 ? (
546 <FilterMenu
547 bare
548 label={t('issue.board.repoPlaceholder')}
549 placeholder={t('issue.board.repoPlaceholder')}
550 leading={<FolderGit2 className="h-3.5 w-3.5" aria-hidden />}
551 value={repoName}
552 options={repoOptions}
553 onChange={next => {
554 setRepoName(next)
555 // A PR number only means something inside one repo.
556 setPullRef('')
557 }}
558 />
754 {adding ? (
755 <div className="issue-pull-picker">
756 {/*
757 A repo is addressed by its exact name, so it is picked from the
758 space's own repos; the PR then comes from that repo's numbering.
759 */}
760 {repoOptions.length > 0 ? (
761 <FilterMenu
762 bare
763 label={t('issue.board.repoPlaceholder')}
764 placeholder={t('issue.board.pickRepo')}
765 leading={<FolderGit2 className="h-3.5 w-3.5" aria-hidden />}
766 value={repoName}
767 active={repoName || undefined}
768 options={repoOptions}
769 onChange={next => {
770 setRepoName(next)
771 // A PR number only means something inside one repo.
772 setPullRef('')
773 }}
774 />
775 ) : (
776 <span className="issue-rail-empty">
777 {repos.loading ? t('ui.loading') : t('issue.board.noRepos')}
778 </span>
779 )}
780 {repoName ? (
781 pullOptions.length > 0 ? (
782 <FilterMenu
783 bare
784 label={t('issue.board.pullPlaceholder')}
785 placeholder={t('issue.board.pickPull')}
786 leading={<GitPullRequest className="h-3.5 w-3.5" aria-hidden />}
787 value={pullRef}
788 active={pullOptions.find(option => option.value === pullRef)?.label}
789 options={pullOptions}
790 onChange={setPullRef}
791 />
792 ) : (
793 <span className="issue-rail-empty">
794 {openPulls.loading ? t('ui.loading') : t('issue.board.noOpenPulls')}
795 </span>
796 )
797 ) : null}
798 <div className="issue-pull-picker-foot">
799 <button
800 type="button"
801 className="issue-link-button"
802 disabled={busy || !repoName || !pullRef}
803 onClick={() => void link()}
804 >
805 {t('issue.board.link')}
806 </button>
807 <button type="button" className="issue-pull-cancel" onClick={() => setAdding(false)}>
808 {t('common.action.cancel')}
809 </button>
810 </div>
811 </div>
559812 ) : (
560 <span className="issue-rail-empty">
561 {repos.loading ? t('ui.loading') : t('issue.board.noRepos')}
562 </span>
563 )}
564 {pullOptions.length > 0 ? (
565 <FilterMenu
566 bare
567 label={t('issue.board.pullPlaceholder')}
568 placeholder={t('issue.board.pickPull')}
569 leading={<GitPullRequest className="h-3.5 w-3.5" aria-hidden />}
570 value={pullRef}
571 options={pullOptions}
572 onChange={setPullRef}
573 />
574 ) : (
575 <span className="issue-rail-empty">
576 {!repoName
577 ? t('issue.board.pickRepoFirst')
578 : openPulls.loading
579 ? t('ui.loading')
580 : t('issue.board.noOpenPulls')}
581 </span>
813 <button type="button" className="issue-prop-add" onClick={() => setAdding(true)}>
814 <Plus className="h-3.5 w-3.5" aria-hidden /> {t('issue.board.linkPull')}
815 </button>
582816 )}
583 <button type="button" disabled={busy || !repoName || !pullRef} onClick={() => void link()} className="issue-link-button">
584 {t('issue.board.link')}
585 </button>
586817 {error ? <p className="issue-rail-error">{error}</p> : null}
587818 </div>
588819 )
@@ -819,10 +1050,19 @@
8191050 const comment: IssueComment = node.comment
8201051 const mine = Boolean(viewerId && comment.poster?.user_id === viewerId)
8211052 const [reply, setReply] = useState('')
1053 // The reply box is a response to an intent, not furniture: it appears under
1054 // the 回复 button instead of doubling every comment's footprint.
1055 const [replying, setReplying] = useState(false)
8221056 const [editing, setEditing] = useState(false)
8231057 const [text, setText] = useState(comment.content)
8241058 const [busy, setBusy] = useState(false)
8251059 const [error, setError] = useState<string | null>(null)
1060 const replyBox = useRef<HTMLTextAreaElement | null>(null)
1061
1062 useEffect(() => {
1063 if (!replying) return
1064 replyBox.current?.focus()
1065 }, [replying])
8261066
8271067 async function act(action: () => Promise<unknown>) {
8281068 setBusy(true)
@@ -846,6 +1086,7 @@
8461086 try {
8471087 await createComment(boardRef, issueId, content, comment.id)
8481088 setReply('')
1089 setReplying(false)
8491090 onReload()
8501091 } catch (err) {
8511092 setError((err as Error)?.message ?? tr('issue.board.replyFailed'))
@@ -864,6 +1105,11 @@
8641105 <span className="comment-time">{tr('issue.board.edited')}</span>
8651106 ) : null}
8661107 <span className="comment-actions">
1108 {!editing ? (
1109 <button type="button" className="comment-action" onClick={() => setReplying(value => !value)}>
1110 {tr('issue.board.reply')}
1111 </button>
1112 ) : null}
8671113 {mine ? (
8681114 <button
8691115 type="button"
@@ -924,16 +1170,22 @@
9241170 ))}
9251171 </div>
9261172 ) : null}
927 {!editing ? (
1173 {!editing && replying ? (
9281174 <div className="comment-reply-row">
929 <input
1175 <textarea
1176 ref={replyBox}
9301177 value={reply}
9311178 onChange={event => setReply(event.target.value)}
9321179 onKeyDown={event => {
933 if (event.key !== 'Enter' || event.nativeEvent.isComposing) return
1180 if (event.key === 'Escape') {
1181 setReplying(false)
1182 return
1183 }
1184 if (!(event.metaKey || event.ctrlKey) || event.key !== 'Enter' || event.nativeEvent.isComposing) return
9341185 event.preventDefault()
9351186 void submitReply()
9361187 }}
1188 rows={2}
9371189 placeholder={tr('issue.board.replyPlaceholder')}
9381190 aria-label={tr('issue.board.replyAria', { name: comment.poster?.display_name ?? tr('issue.board.commentFallback') })}
9391191 className="comment-reply-input bg-transparent"
@@ -955,25 +1207,142 @@
9551207 )
9561208}
9571209
958/** One change event on the timeline: small avatar, verb, timestamp. */
959function EventRow({ event }: { event: import('@/entities/issue').IssueEvent }) {
1210/**
1211 * One change on the timeline — or a run of them. The line reads
1212 * `actor 将里程碑 未设置 → v1.0 · 09-23`, with the changed values called out; a
1213 * run keeps its first line and offers the rest behind a count.
1214 */
1215function EventRow({ events, memberName }: { events: IssueEvent[]; memberName: (id: string) => string | undefined }) {
1216 const { t } = useT()
1217 const [open, setOpen] = useState(false)
1218 const first = events[0]
1219 const last = events[events.length - 1]
1220 const grouped = events.length > 1
1221 const rows = grouped && !open ? [first] : events
9601222 return (
9611223 <div className="issue-event">
9621224 <span className="issue-event-icon">
963 {event.user ? (
964 <UserAvatar user={event.user} size={20} />
1225 {first.user ? (
1226 <UserAvatar user={first.user} size={20} />
9651227 ) : (
9661228 <span className="issue-event-dot">
9671229 <Users className="h-3 w-3" aria-hidden />
9681230 </span>
9691231 )}
9701232 </span>
971 <p className="issue-event-body">
972 <span className="issue-event-actor">{event.user?.display_name ?? tr('issue.board.system')}</span>{' '}
973 {eventLabel(event.event_type)}
974 {event.new_title ? <span className="issue-event-extra"> → {event.new_title}</span> : null}
975 <span className="issue-event-time"> · {fmtTime(event.created_at)}</span>
976 </p>
1233 <div className="issue-event-stack">
1234 {rows.map(event => (
1235 <p key={event.id} className="issue-event-body">
1236 <span className="issue-event-actor">{event.user?.display_name ?? t('issue.board.system')}</span>{' '}
1237 {describeEvent(event, memberName, t)}
1238 <span className="issue-event-time" title={fmtTime(event.created_at)}>
1239 {' '}· {fmtDay(event.created_at)}
1240 </span>
1241 </p>
1242 ))}
1243 {grouped ? (
1244 <button
1245 type="button"
1246 className="issue-event-toggle"
1247 aria-expanded={open}
1248 onClick={() => setOpen(value => !value)}
1249 >
1250 <ChevronDown className={`h-3 w-3 transition-transform ${open ? 'rotate-180' : ''}`} aria-hidden />
1251 {open
1252 ? t('issue.board.event.runCollapse')
1253 : t('issue.board.event.runExpand', { count: events.length, time: fmtDay(last.created_at) })}
1254 </button>
1255 ) : null}
1256 </div>
9771257 </div>
9781258 )
9791259}
1260
1261/** Wrap each `value` inside the rendered sentence so changes stand out. */
1262function highlightValues(message: string, values: string[]): ReactNode {
1263 let parts: ReactNode[] = [message]
1264 values
1265 .filter(value => value.length > 0)
1266 .forEach((value, index) => {
1267 const next: ReactNode[] = []
1268 for (const part of parts) {
1269 if (typeof part !== 'string') {
1270 next.push(part)
1271 continue
1272 }
1273 const at = part.indexOf(value)
1274 if (at === -1) {
1275 next.push(part)
1276 continue
1277 }
1278 next.push(
1279 part.slice(0, at),
1280 <span key={`${index}-${at}`} className="issue-event-value">
1281 {value}
1282 </span>,
1283 part.slice(at + value.length),
1284 )
1285 }
1286 parts = next
1287 })
1288 return parts
1289}
1290
1291/**
1292 * The verb plus its payload. The backend already stores what changed —
1293 * `content` for milestones (`old -> new`), labels and members, `old_title` /
1294 * `new_title` for renames — so the line can say what happened instead of
1295 * repeating "调整了里程碑" four times.
1296 */
1297function describeEvent(
1298 event: IssueEvent,
1299 memberName: (id: string) => string | undefined,
1300 t: ReturnType<typeof useT>['t'],
1301): ReactNode {
1302 const unset = t('issue.board.unset')
1303 const content = (event.content ?? '').trim()
1304 switch (event.event_type) {
1305 case 'milestone_updated': {
1306 const [from, to] = content.split('->').map(part => part.trim())
1307 if (!from || !to) break
1308 const values = [from === 'none' ? unset : from, to === 'none' ? unset : to]
1309 return highlightValues(
1310 t('issue.event.verb.milestone_updated', { from: values[0], to: values[1] }),
1311 values,
1312 )
1313 }
1314 case 'title_updated': {
1315 if (!event.new_title) break
1316 const from = event.old_title ?? unset
1317 return highlightValues(
1318 t('issue.event.verb.title_updated', { from, to: event.new_title }),
1319 [from, event.new_title],
1320 )
1321 }
1322 case 'label_added':
1323 case 'label_removed': {
1324 if (!content) break
1325 const key = event.event_type === 'label_added' ? 'issue.event.verb.label_added' : 'issue.event.verb.label_removed'
1326 return highlightValues(t(key, { name: content }), [content])
1327 }
1328 case 'member_added':
1329 case 'member_removed': {
1330 // The payload is `Kind <uuid>`; a name we can resolve reads far better.
1331 const [kind, id] = content.split(' ')
1332 const name = id ? memberName(id) : undefined
1333 if (!kind || !name) break
1334 const key = event.event_type === 'member_added' ? 'issue.event.verb.member_added' : 'issue.event.verb.member_removed'
1335 return highlightValues(t(key, { name }), [name])
1336 }
1337 case 'pull_linked':
1338 case 'pull_unlinked': {
1339 // 后端存的是 `#<编号> <标题>`;整段作为高亮值即可。
1340 if (!content) break
1341 const key = event.event_type === 'pull_linked' ? 'issue.event.verb.pull_linked' : 'issue.event.verb.pull_unlinked'
1342 return highlightValues(t(key, { name: content }), [content])
1343 }
1344 default:
1345 break
1346 }
1347 return eventLabel(event.event_type)
1348}
Modified web/src/features/issue-board/ui/issue-form-modal.tsx View web/src/features/issue-board/ui/issue-form-modal.tsx
@@ -5,17 +5,34 @@
55 * bound labels, and PATCH uses the backend's three-state rules — an explicit
66 * `null` clears 摘要/里程碑/截止, a missing key leaves the field alone.
77 */
8import { useEffect, useId, useState } from 'react'
9import { Loader2 } from 'lucide-react'
10import { Alert, Button } from '@/shared/ui'
11import { ApiFailure } from '@/shared/lib'
12import { useT } from '@/shared/i18n'
8import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
9import {
10 CalendarDays,
11 Check,
12 ChevronRight,
13 Flag,
14 Loader2,
15 Milestone as MilestoneIcon,
16 Paperclip,
17 Tag,
18 Users,
19 X,
20} from 'lucide-react'
21import { Alert, Button, Markdown } from '@/shared/ui'
22import { ApiFailure, formatBytes } from '@/shared/lib'
23import { formatDate, useT } from '@/shared/i18n'
1324import {
25 MAX_ATTACHMENT_BYTES,
1426 PRIORITIES,
1527 createIssue,
28 createIssueAttachment,
1629 dateApiValue,
1730 dateInputValue,
31 deleteIssueAttachment,
32 finalizeIssueAttachment,
33 issueLabel,
1834 listIssueLabels,
35 putIssueUpload,
1936 updateIssue,
2037 type BoardRef,
2138 type IssueDetail,
@@ -25,8 +42,9 @@
2542 type UserDto,
2643} from '@/entities/issue'
2744import type { BoardData } from '../model/use-board-data'
28import { fieldInputClass, fieldLabelClass, Dialog } from './dialog'
29import { normalizeColor, readableText } from '@/entities/issue'
45import { Dialog } from './dialog'
46import { FilterMenu, UserAvatar } from './parts'
47import { normalizeColor } from '@/entities/issue'
3048
3149export interface IssueFormModalProps {
3250 open: boolean
@@ -43,6 +61,15 @@
4361
4462const TITLE_MAX = 255
4563
64/** One file picked in the dialog: uploading → ready (linkable) | failed. */
65interface DraftAttachment {
66 id: string
67 filename: string
68 size: number
69 status: 'uploading' | 'ready' | 'failed'
70 error?: string
71}
72
4673export function IssueFormModal({ open, boardRef, data, editing, presetPriority, onClose, onSaved }: IssueFormModalProps) {
4774 const { t } = useT()
4875 const fieldId = useId()
@@ -54,9 +81,12 @@
5481 const [due, setDue] = useState('')
5582 const [labelIds, setLabelIds] = useState<string[]>([])
5683 const [assigneeIds, setAssigneeIds] = useState<string[]>([])
84 const [drafts, setDrafts] = useState<DraftAttachment[]>([])
85 const [preview, setPreview] = useState(false)
5786 const [busy, setBusy] = useState(false)
5887 const [error, setError] = useState<string | null>(null)
5988 const [validation, setValidation] = useState<string | null>(null)
89 const fileInput = useRef<HTMLInputElement | null>(null)
6090
6191 // Reset the draft whenever the dialog opens for a different target, so a
6292 // half-typed new issue never leaks into an edit (or vice versa).
@@ -92,12 +122,81 @@
92122 setDue('')
93123 setLabelIds([])
94124 setAssigneeIds([])
125 setDrafts([])
126 setPreview(false)
95127 // eslint-disable-next-line react-hooks/exhaustive-deps
96128 }, [open, editing?.id])
97129
98130 const labels = data.labels.data?.items ?? []
99131 const milestones = (data.milestones.data?.items ?? []).filter(item => !item.is_closed || item.id === milestoneId)
100132 const members = (data.members.data?.items ?? []).filter(member => member.user)
133 const boardName = data.board.data?.name ?? ''
134 const contentRef = useRef<HTMLTextAreaElement | null>(null)
135 const uploading = drafts.some(draft => draft.status === 'uploading')
136
137 /**
138 * Pick files → reserve a row → PUT the bytes → finalize. A failure removes
139 * the half-created row again so the pending-upload cap stays honest.
140 */
141 async function attachFiles(files: FileList | null) {
142 if (!files || files.length === 0) return
143 for (const file of Array.from(files)) {
144 if (file.size <= 0 || file.size > MAX_ATTACHMENT_BYTES) {
145 setDrafts(prev => [
146 ...prev,
147 {
148 id: `local-${crypto.randomUUID()}`,
149 filename: file.name,
150 size: file.size,
151 status: 'failed',
152 error: t('issue.board.form.attachmentTooLarge', { max: formatBytes(MAX_ATTACHMENT_BYTES) }),
153 },
154 ])
155 continue
156 }
157 const localId = `local-${crypto.randomUUID()}`
158 setDrafts(prev => [...prev, { id: localId, filename: file.name, size: file.size, status: 'uploading' }])
159 let created: string | null = null
160 try {
161 const intent = await createIssueAttachment({
162 space_name: boardRef.space,
163 filename: file.name,
164 content_type: file.type || 'application/octet-stream',
165 size: file.size,
166 })
167 created = intent.id
168 setDrafts(prev => prev.map(draft => (draft.id === localId ? { ...draft, id: intent.id } : draft)))
169 await putIssueUpload(intent.upload_url, file)
170 const meta = await finalizeIssueAttachment(intent.id)
171 setDrafts(prev =>
172 prev.map(draft => (draft.id === intent.id ? { ...draft, id: meta.id, status: 'ready' } : draft)),
173 )
174 } catch (err) {
175 if (created) void deleteIssueAttachment(created).catch(() => undefined)
176 setDrafts(prev =>
177 prev.map(draft =>
178 draft.id === (created ?? localId)
179 ? { ...draft, status: 'failed', error: (err as Error)?.message ?? t('issue.board.form.uploadFailed') }
180 : draft,
181 ),
182 )
183 }
184 }
185 }
186
187 function removeDraft(id: string) {
188 setDrafts(prev => prev.filter(draft => draft.id !== id))
189 if (!id.startsWith('local-')) void deleteIssueAttachment(id).catch(() => undefined)
190 }
191
192 // The description grows with its content (Linear-style) instead of scrolling
193 // inside a fixed box.
194 useEffect(() => {
195 const node = contentRef.current
196 if (!node || !open) return
197 node.style.height = 'auto'
198 node.style.height = `${Math.min(node.scrollHeight, 300)}px`
199 }, [content, open])
101200
102201 async function submit() {
103202 if (busy) return
@@ -125,6 +224,7 @@
125224 due_at: dateApiValue(due),
126225 label_ids: labelIds,
127226 assignee_ids: assigneeIds,
227 attachment_ids: drafts.filter(draft => draft.status === 'ready').map(draft => draft.id),
128228 }
129229 const saved = editing
130230 ? await updateIssue(boardRef, editing.id, payload)
@@ -142,176 +242,339 @@
142242 <Dialog
143243 open={open}
144244 onClose={() => { if (!busy) onClose() }}
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')}
147 footer={
148 <>
149 {busy ? <Loader2 className="mr-auto h-4 w-4 animate-spin text-shell-ink-muted" aria-hidden /> : null}
150 <Button variant="secondary" onClick={onClose} disabled={busy}>
151 {t('common.action.cancel')}
152 </Button>
153 <Button onClick={() => void submit()} disabled={busy}>
154 {busy ? t('common.state.busy') : editing ? t('common.action.save') : t('issue.board.new')}
155 </Button>
156 </>
245 title={
246 editing
247 ? t('issue.board.form.editTitle', { number: issueLabel(data.board.data, editing.number) })
248 : t('issue.board.new')
157249 }
158 >
159 <div className="space-y-1.5">
160 <div className="flex items-center justify-between gap-2">
161 <label htmlFor={`${fieldId}-title`} className={fieldLabelClass}>{t('issue.board.form.title')} <span className="text-shell-ink-muted">*</span></label>
162 <span className="text-[11px] text-shell-ink-muted">{[...title].length} / {TITLE_MAX}</span>
250 width="max-w-[720px]"
251 panelClassName="issue-form-dialog"
252 header={
253 <div className="issue-form-head">
254 {boardName ? <span className="issue-form-team">{boardName}</span> : null}
255 <ChevronRight className="h-3.5 w-3.5 shrink-0" aria-hidden />
256 <span className="issue-form-kicker">
257 {editing
258 ? t('issue.board.form.editTitle', { number: issueLabel(data.board.data, editing.number) })
259 : t('issue.board.new')}
260 </span>
163261 </div>
164 <input
165 id={`${fieldId}-title`}
166 aria-required="true"
167 aria-invalid={Boolean(validation)}
168 aria-describedby={validation ? `${fieldId}-validation` : undefined}
169 className={fieldInputClass}
170 value={title}
171 onChange={event => setTitle(event.target.value)}
172 maxLength={TITLE_MAX + 20}
173 placeholder={t('issue.board.form.titlePlaceholder')}
174 autoFocus
175 />
176 </div>
177 {validation ? <div id={`${fieldId}-validation`}><Alert>{validation}</Alert></div> : null}
178 {error ? <Alert>{error}</Alert> : null}
179
180 <div className="space-y-1.5">
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>
182 <input
183 id={`${fieldId}-summary`}
184 className={fieldInputClass}
185 value={description}
186 onChange={event => setDescription(event.target.value)}
187 placeholder={t('issue.board.form.summaryPlaceholder')}
188 />
189 </div>
190
191 <div className="space-y-1.5">
192 <label htmlFor={`${fieldId}-content`} className={fieldLabelClass}>{t('issue.board.form.content')}</label>
193 <textarea
194 id={`${fieldId}-content`}
195 value={content}
196 onChange={event => setContent(event.target.value)}
197 rows={6}
198 placeholder={t('issue.board.form.contentPlaceholder')}
199 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"
200 />
201 </div>
202
203 <div className="issue-form-metadata grid grid-cols-3 gap-3">
204 <label className="space-y-1.5">
205 <span className={fieldLabelClass}>{t('issue.board.filter.priority')}</span>
206 <select
207 value={priority}
208 onChange={event => setPriority(event.target.value as IssuePriority)}
209 className={fieldInputClass}
210 >
211 {PRIORITIES.map(meta => (
212 <option key={meta.id} value={meta.id}>
213 {meta.label}
214 </option>
262 }
263 footer={
264 <div className="issue-form-bar">
265 <div className="issue-form-chips">
266 <input
267 ref={fileInput}
268 type="file"
269 multiple
270 className="sr-only"
271 tabIndex={-1}
272 aria-hidden="true"
273 onChange={event => {
274 void attachFiles(event.target.files)
275 event.target.value = ''
276 }}
277 />
278 <button
279 type="button"
280 className="issue-form-attach"
281 aria-label={t('issue.board.form.attach')}
282 title={t('issue.board.form.attach')}
283 onClick={() => fileInput.current?.click()}
284 >
285 <Paperclip className="h-3.5 w-3.5" aria-hidden />
286 </button>
287 {drafts.map(draft => (
288 <span
289 key={draft.id}
290 className={`issue-form-chip is-draft ${draft.status === 'failed' ? 'is-failed' : ''}`}
291 title={draft.error ?? draft.filename}
292 >
293 {draft.status === 'uploading' ? (
294 <Loader2 className="h-3.5 w-3.5 animate-spin text-shell-ink-muted" aria-hidden />
295 ) : (
296 <Paperclip className="h-3.5 w-3.5" aria-hidden />
297 )}
298 <span className="issue-chip-text">{draft.filename}</span>
299 <span className="issue-chip-size">{formatBytes(draft.size)}</span>
300 <button
301 type="button"
302 className="issue-chip-clear"
303 aria-label={t('issue.board.form.removeAttachment', { name: draft.filename })}
304 title={t('issue.board.form.removeAttachment', { name: draft.filename })}
305 onClick={() => removeDraft(draft.id)}
306 >
307 <X className="h-3 w-3" aria-hidden />
308 </button>
309 </span>
215310 ))}
216 </select>
217 </label>
218 <label className="space-y-1.5">
219 <span className={fieldLabelClass}>{t('issue.board.filter.milestone')}</span>
220 <select
221 value={milestoneId}
222 onChange={event => setMilestoneId(event.target.value)}
223 className={fieldInputClass}
224 >
225 <option value="">{t('issue.board.unset')}</option>
226 {milestones.map(item => (
227 <option key={item.id} value={item.id}>
228 {item.name}
229 </option>
230 ))}
231 </select>
232 </label>
233 <label className="space-y-1.5">
234 <span className={fieldLabelClass}>{t('issue.board.filter.due')}</span>
235 <input type="date" value={due} onChange={event => setDue(event.target.value)} className={fieldInputClass} />
236 </label>
311 <FilterMenu
312 label={t('issue.board.filter.priority')}
313 leading={<Flag className="h-3.5 w-3.5" aria-hidden />}
314 value={priority}
315 options={PRIORITIES.map(meta => ({ value: meta.id, label: meta.label, color: meta.color }))}
316 onChange={value => setPriority(value as IssuePriority)}
317 />
318 <FilterMenu
319 label={t('issue.board.filter.milestone')}
320 leading={<MilestoneIcon className="h-3.5 w-3.5" aria-hidden />}
321 value={milestoneId}
322 placeholder={t('issue.board.unset')}
323 active={milestoneId ? milestones.find(item => item.id === milestoneId)?.name : undefined}
324 options={milestones.map(item => ({ value: item.id, label: item.name }))}
325 onChange={setMilestoneId}
326 />
327 <DueChip value={due} onChange={setDue} />
328 <PickerChip
329 icon={<Tag className="h-3.5 w-3.5" aria-hidden />}
330 label={t('issue.board.labelsField')}
331 empty={t('issue.board.form.noLabels')}
332 options={labels.map(label => ({ id: label.id, name: label.name, color: normalizeColor(label.color) }))}
333 selected={labelIds}
334 onToggle={id =>
335 setLabelIds(current => (current.includes(id) ? current.filter(v => v !== id) : [...current, id]))
336 }
337 />
338 <PickerChip
339 icon={<Users className="h-3.5 w-3.5" aria-hidden />}
340 label={t('issue.board.filter.assignee')}
341 empty={t('issue.board.form.noAssignees')}
342 align="right"
343 options={members.map(member => {
344 const user = member.user as UserDto
345 return { id: user.user_id, name: user.display_name || user.username, user }
346 })}
347 selected={assigneeIds}
348 onToggle={id =>
349 setAssigneeIds(current => (current.includes(id) ? current.filter(v => v !== id) : [...current, id]))
350 }
351 />
352 </div>
353 <div className="issue-form-submit">
354 {busy ? <Loader2 className="h-4 w-4 animate-spin text-shell-ink-muted" aria-hidden /> : null}
355 <Button onClick={() => void submit()} disabled={busy || uploading}>
356 {busy ? t('common.state.busy') : editing ? t('common.action.save') : t('issue.board.new')}
357 </Button>
358 </div>
359 </div>
360 }
361 >
362 <div className="issue-form-body">
363 <div className="issue-form-title-row">
364 <input
365 id={`${fieldId}-title`}
366 aria-label={t('issue.board.form.title')}
367 aria-required="true"
368 aria-invalid={Boolean(validation)}
369 aria-describedby={validation ? `${fieldId}-validation` : undefined}
370 className="issue-form-title bg-transparent"
371 value={title}
372 onChange={event => setTitle(event.target.value)}
373 maxLength={TITLE_MAX + 20}
374 placeholder={t('issue.board.form.titlePlaceholder')}
375 autoFocus
376 />
377 {[...title].length > TITLE_MAX - 55 ? (
378 <span className="issue-form-count">{[...title].length} / {TITLE_MAX}</span>
379 ) : null}
380 </div>
381 <input
382 id={`${fieldId}-summary`}
383 aria-label={t('issue.board.form.summary')}
384 className="issue-form-summary bg-transparent"
385 value={description}
386 onChange={event => setDescription(event.target.value)}
387 placeholder={t('issue.board.form.summaryPlaceholder')}
388 />
389 <textarea
390 id={`${fieldId}-content`}
391 ref={contentRef}
392 aria-label={t('issue.board.form.content')}
393 value={content}
394 onChange={event => setContent(event.target.value)}
395 rows={3}
396 placeholder={t('issue.board.form.contentPlaceholder')}
397 className="issue-form-desc bg-transparent"
398 hidden={preview}
399 />
400 {preview ? <Markdown className="issue-form-preview">{content}</Markdown> : null}
401 {preview || content.trim().length > 0 ? (
402 <div className="issue-form-desc-tools">
403 <button
404 type="button"
405 className="issue-form-preview-toggle"
406 aria-pressed={preview}
407 onClick={() => setPreview(v => !v)}
408 >
409 {preview ? t('issue.board.form.editSource') : t('issue.board.form.preview')}
410 </button>
411 </div>
412 ) : null}
413 {validation ? <div id={`${fieldId}-validation`}><Alert>{validation}</Alert></div> : null}
414 {error ? <Alert>{error}</Alert> : null}
237415 </div>
238
239 <Picker
240 label={t('issue.board.labelsField')}
241 empty={t('issue.board.form.noLabels')}
242 options={labels.map(label => ({
243 id: label.id,
244 name: label.name,
245 color: normalizeColor(label.color),
246 }))}
247 selected={labelIds}
248 onToggle={id => setLabelIds(current => (current.includes(id) ? current.filter(v => v !== id) : [...current, id]))}
249 />
250
251 <Picker
252 label={t('issue.board.filter.assignee')}
253 empty={t('issue.board.form.noAssignees')}
254 options={members.map(member => ({
255 id: (member.user as UserDto).user_id,
256 name: (member.user as UserDto).display_name || (member.user as UserDto).username,
257 }))}
258 selected={assigneeIds}
259 onToggle={id =>
260 setAssigneeIds(current => (current.includes(id) ? current.filter(v => v !== id) : [...current, id]))
261 }
262 />
263416 </Dialog>
264417 )
265418}
266419
267function Picker({
420/** 多选 chip:收起时是一枚胶囊,展开后在上方列出可勾选项。 */
421function PickerChip({
422 icon,
268423 label,
269424 options,
270425 selected,
271426 onToggle,
272427 empty,
428 align = 'left',
273429}: {
430 icon: ReactNode
274431 label: string
275 options: Array<{ id: string; name: string; color?: string }>
432 options: Array<{ id: string; name: string; color?: string; user?: UserDto }>
276433 selected: string[]
277434 onToggle: (id: string) => void
278435 empty: string
436 align?: 'left' | 'right'
279437}) {
438 const [open, setOpen] = useState(false)
439 const box = useRef<HTMLDivElement | null>(null)
440 const picked = options.filter(option => selected.includes(option.id))
441
442 useEffect(() => {
443 if (!open) return
444 const onDown = (event: MouseEvent) => {
445 if (box.current && !box.current.contains(event.target as Node)) setOpen(false)
446 }
447 const onKey = (event: KeyboardEvent) => {
448 if (event.key === 'Escape') setOpen(false)
449 }
450 window.addEventListener('mousedown', onDown)
451 window.addEventListener('keydown', onKey)
452 return () => {
453 window.removeEventListener('mousedown', onDown)
454 window.removeEventListener('keydown', onKey)
455 }
456 }, [open])
457
280458 if (options.length === 0) {
281459 return (
282 <div className="space-y-1.5">
283 <span className={fieldLabelClass}>{label}</span>
284 <p className="text-[12px] text-shell-ink-muted">{empty}</p>
285 </div>
460 <span className="issue-form-chip is-disabled" title={empty}>
461 {icon}
462 <span className="issue-chip-text is-empty">{label}</span>
463 </span>
286464 )
287465 }
466
288467 return (
289 <div className="space-y-1.5">
290 <span className={fieldLabelClass}>{label}</span>
291 <div className="flex max-h-[140px] flex-wrap gap-1.5 overflow-y-auto">
292 {options.map(option => {
293 const on = selected.includes(option.id)
294 return (
295 <button
296 key={option.id}
297 type="button"
298 aria-pressed={on}
299 onClick={() => onToggle(option.id)}
300 className="issue-picker-option rounded-full px-2 py-1 text-[12px] transition-colors"
301 style={
302 option.color
303 ? { backgroundColor: on ? option.color : '#f2f3f6', color: on ? readableText(option.color) : '#8b8f99' }
304 : { backgroundColor: on ? '#dee1e8' : '#f2f3f6', color: on ? '#1c1e22' : '#8b8f99' }
305 }
306 >
307 {option.name}
308 </button>
309 )
310 })}
311 </div>
468 <div ref={box} className="relative">
469 <button
470 type="button"
471 className="issue-form-chip is-button"
472 aria-haspopup="listbox"
473 aria-expanded={open}
474 onClick={() => setOpen(v => !v)}
475 >
476 {icon}
477 {picked.length === 0 ? (
478 <span className="issue-chip-text is-empty">{label}</span>
479 ) : (
480 <>
481 {picked.slice(0, 3).map(option =>
482 option.user ? (
483 <UserAvatar key={option.id} user={option.user} size={16} />
484 ) : option.color ? (
485 <span key={option.id} className="issue-chip-dot" style={{ backgroundColor: option.color }} aria-hidden />
486 ) : null,
487 )}
488 <span className="issue-chip-text">
489 {picked.length === 1 ? picked[0].name : `${label} · ${picked.length}`}
490 </span>
491 </>
492 )}
493 </button>
494 {open ? (
495 <div
496 className={`board-filter-popover issue-form-popover ${align === 'right' ? 'is-right' : ''}`}
497 role="listbox"
498 aria-multiselectable="true"
499 aria-label={label}
500 >
501 {options.map(option => {
502 const on = selected.includes(option.id)
503 return (
504 <button
505 key={option.id}
506 type="button"
507 role="option"
508 aria-selected={on}
509 onClick={() => onToggle(option.id)}
510 className="board-filter-option flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12.5px] transition-colors hover:bg-shell-field"
511 >
512 <span className={`issue-check ${on ? 'is-on' : ''}`} aria-hidden>
513 {on ? <Check className="h-3 w-3" /> : null}
514 </span>
515 {option.user ? (
516 <UserAvatar user={option.user} size={18} />
517 ) : option.color ? (
518 <span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: option.color }} aria-hidden />
519 ) : null}
520 <span className={`min-w-0 flex-1 truncate ${on ? 'font-medium' : ''}`}>{option.name}</span>
521 </button>
522 )
523 })}
524 </div>
525 ) : null}
312526 </div>
313527 )
314528}
315529
530/** 截止日期 chip:显示格式化日期,点击调起原生日期选择器。 */
531function DueChip({ value, onChange }: { value: string; onChange: (next: string) => void }) {
532 const { t } = useT()
533 const input = useRef<HTMLInputElement | null>(null)
534 return (
535 <span className="issue-form-chip is-button">
536 <button
537 type="button"
538 className="issue-form-chip-main"
539 onClick={() => {
540 const node = input.current
541 if (!node) return
542 try {
543 ;(node as HTMLInputElement & { showPicker?: () => void }).showPicker?.()
544 } catch {
545 // 浏览器拒绝 showPicker 时退化为聚焦,用户仍可手动输入日期。
546 }
547 node.focus()
548 }}
549 >
550 <CalendarDays className="h-3.5 w-3.5" aria-hidden />
551 <span className={`issue-chip-text ${value ? '' : 'is-empty'}`}>
552 {value ? formatDate(value) : t('issue.board.filter.due')}
553 </span>
554 </button>
555 {value ? (
556 <button
557 type="button"
558 className="issue-chip-clear"
559 aria-label={t('issue.board.clearDate')}
560 title={t('issue.board.clearDate')}
561 onClick={() => onChange('')}
562 >
563 <X className="h-3 w-3" aria-hidden />
564 </button>
565 ) : null}
566 <input
567 ref={input}
568 type="date"
569 tabIndex={-1}
570 aria-hidden="true"
571 className="issue-chip-date"
572 value={value}
573 onChange={event => onChange(event.target.value)}
574 />
575 </span>
576 )
577}
578
316579/** Types kept explicit so the modal cannot drift from the API payloads. */
317580export type { IssueLabel, Milestone }
Modified web/src/features/issue-board/ui/parts.tsx View web/src/features/issue-board/ui/parts.tsx
@@ -37,6 +37,34 @@
3737 )
3838}
3939
40/**
41 * Linear-style priority mark: three bars filled up to the priority's rank, an
42 * outline-only mark for 无优先级. Urgent keeps a red accent; everything else
43 * inherits the row's ink so a page of issues does not turn into a colour chart.
44 */
45export function PriorityGlyph({ priority }: { priority: IssuePriority }) {
46 const meta = priorityOf(priority)
47 const filled = meta.rank <= 0 ? 0 : Math.min(meta.rank, 3)
48 return (
49 <span className="issue-priority-glyph" data-priority={meta.id} title={meta.label}>
50 <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden focusable="false">
51 {[0, 1, 2].map(index => (
52 <rect
53 key={index}
54 className={index < filled ? 'is-on' : undefined}
55 x={2.5 + index * 4.25}
56 y={9.5 - index * 3.25}
57 width="3"
58 height={4.5 + index * 3.25}
59 rx="1"
60 />
61 ))}
62 </svg>
63 <span className="sr-only">{meta.label}</span>
64 </span>
65 )
66}
67
4068export function LabelChip({ label, onRemove }: { label: IssueLabel; onRemove?: () => void }) {
4169 const { t } = useT()
4270 const color = normalizeColor(label.color)
@@ -142,6 +170,7 @@
142170 placeholder,
143171 align = 'left',
144172 bare = false,
173 iconOnly = false,
145174 leading,
146175 extra,
147176}: {
@@ -155,6 +184,8 @@
155184 align?: 'left' | 'right'
156185 /** Bordered chip for toolbars vs. a full-width hover row for the detail rail. */
157186 bare?: boolean
187 /** Ghost icon-only trigger (list rows): no value text, colour dot or chevron. */
188 iconOnly?: boolean
158189 /** Icon + label pinned to the start of the trigger (rail rows). */
159190 leading?: ReactNode
160191 /** Free-form block under the option list (an inline "new label" row, …). */
@@ -211,19 +242,25 @@
211242 }}
212243 onClick={() => setOpen(v => !v)}
213244 className={`board-filter-trigger flex h-7 items-center gap-1 rounded-md px-2 text-[12.5px] transition-colors ${
214 bare ? 'board-filter-trigger-bare' : ''
245 iconOnly ? 'board-filter-trigger-icon' : bare ? 'board-filter-trigger-bare' : ''
215246 } ${
216 value ? 'bg-shell-active text-shell-ink' : 'bg-shell-field text-shell-ink-muted hover:text-shell-ink'
247 iconOnly
248 ? ''
249 : value
250 ? 'bg-shell-active text-shell-ink'
251 : 'bg-shell-field text-shell-ink-muted hover:text-shell-ink'
217252 }`}
218253 >
219254 {leading ? <span className="board-filter-leading flex shrink-0 items-center gap-2">{leading}</span> : null}
220 {current?.color ? (
255 {!iconOnly && current?.color ? (
221256 <span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: current.color }} aria-hidden />
222257 ) : null}
223 <span className="board-filter-trigger-value max-w-[120px] truncate">
224 {active ?? current?.label ?? placeholder ?? label}
225 </span>
226 <ChevronDown className="board-filter-chevron h-3 w-3 shrink-0 opacity-70" aria-hidden />
258 {iconOnly ? null : (
259 <span className="board-filter-trigger-value max-w-[120px] truncate">
260 {active ?? current?.label ?? placeholder ?? label}
261 </span>
262 )}
263 {iconOnly ? null : <ChevronDown className="board-filter-chevron h-3 w-3 shrink-0 opacity-70" aria-hidden />}
227264 </button>
228265 {open ? (
229266 <div
Modified web/src/features/issue-board/index.ts View web/src/features/issue-board/index.ts
@@ -3,7 +3,7 @@
33// dialogs. Consumers import from '@/features/issue-board', never deeper.
44export { ISSUE_PAGE_SIZE, useBoardData, type BoardData } from './model/use-board-data'
55export { prefetchBoardView } from './model/prefetch'
6export { BoardActions, BoardFilters } from './ui/board-filters'
6export { BoardActions, BoardFilters, BoardViewTabs } from './ui/board-filters'
77export { BoardSkeleton, IssueBoardBody } from './ui/issue-board'
88export { IssueDetail } from './ui/issue-detail'
99export { IssueFormModal } from './ui/issue-form-modal'
Modified web/src/shared/i18n/messages/en/issues.ts View web/src/shared/i18n/messages/en/issues.ts
@@ -111,6 +111,14 @@
111111 'issue.event.unpinned': 'Unpinned the issue',
112112 'issue.event.repo_relation_added': 'Linked a repository',
113113 'issue.event.repo_relation_removed': 'Unlinked a repository',
114 'issue.event.verb.milestone_updated': 'changed milestone {from} → {to}',
115 'issue.event.verb.title_updated': 'renamed {from} → {to}',
116 'issue.event.verb.label_added': 'added label {name}',
117 'issue.event.verb.label_removed': 'removed label {name}',
118 'issue.event.verb.member_added': 'added member {name}',
119 'issue.event.verb.member_removed': 'removed member {name}',
120 'issue.event.verb.pull_linked': 'linked pull request {name}',
121 'issue.event.verb.pull_unlinked': 'unlinked pull request {name}',
114122 'issue.filter.searchPlaceholder': 'Search issues…',
115123 'issue.filter.searchAria': 'Search issue titles',
116124 'issue.filter.clearSearch': 'Clear search',
@@ -124,6 +132,16 @@
124132 'issue.filter.sortActive': 'Sort: {label}',
125133 'issue.filter.viewGroup': 'View switch',
126134 'issue.filter.clear': 'Clear filters',
135 'issue.filter.panelTitle': 'Filter',
136 'issue.filter.searchIn': 'Search {title}',
137 'issue.filter.searchHint': 'Press / to search',
138 'issue.filter.clearAll': 'Clear all',
139 'issue.filter.done': 'Done',
140 'issue.board.resultCount': '{count} results',
141 'issue.board.more': 'More actions',
142 'issue.board.settingsAction': 'Issue settings',
143 'issue.board.newHint': 'New issue · press C',
144 'issue.filter.chip.sort': 'Sort: {value}',
127145 'issue.filter.chip.state': 'State: {value}',
128146 'issue.filter.chip.query': 'Keyword: {value}',
129147 'issue.filter.chip.priority': 'Priority: {value}',
@@ -150,7 +168,7 @@
150168 'issue.board.addInColumn': 'New issue in "{column}"',
151169 'issue.board.addCard': 'Add an issue',
152170 'issue.board.addCardHint': 'You can also drop a card here',
153 'issue.board.cardAria': 'Issue #{number} {title}',
171 'issue.board.cardAria': 'Issue {number} {title}',
154172 'issue.board.milestoneTitle': 'Milestone: {name}',
155173 'issue.board.tableCaption': 'Issue list; select a title to see the detail',
156174 'issue.board.col.state': 'State',
@@ -230,7 +248,13 @@
230248 'issue.board.edited': 'edited',
231249 'issue.board.editCommentAria': 'Edit comment',
232250 'issue.board.replyFailed': 'Could not reply.',
233 'issue.board.replyPlaceholder': 'Reply… (Enter to send)',
251 'issue.board.replyPlaceholder': 'Reply… (Ctrl+Enter to send, Esc to close)',
252 'issue.board.reply': 'Reply',
253 'issue.board.deleteIssue': 'Delete issue',
254 'issue.board.linkPull': 'Link MR',
255 'issue.board.pickRepo': 'Pick a repository',
256 'issue.board.event.runExpand': '{count} changes in a row — expand (latest {time})',
257 'issue.board.event.runCollapse': 'Collapse changes',
234258 'issue.board.replyAria': 'Reply to {name}',
235259 'issue.board.commentFallback': 'comment',
236260 'issue.board.loadingActivity': 'Loading activity…',
@@ -238,18 +262,25 @@
238262 'issue.board.noActivity': 'No activity yet.',
239263 'issue.board.system': 'System',
240264 'issue.board.form.createTitle': 'New issue',
241 'issue.board.form.editTitle': 'Edit issue #{number}',
265 'issue.board.form.editTitle': 'Edit issue {number}',
242266 'issue.board.form.editDescription': 'Clear information keeps collaboration fast.',
243267 'issue.board.form.validationTitle': 'Enter a title.',
244268 'issue.board.form.validationTitleMax': 'The title may be at most {max} characters.',
245269 'issue.board.form.saveFailed': 'Could not save. Please try again later.',
270 'issue.board.form.attach': 'Attach files',
271 'issue.board.form.removeAttachment': 'Remove attachment {name}',
272 'issue.board.form.attachmentTooLarge': 'File is too large; the limit is {max}',
273 'issue.board.form.uploadFailed': 'Attachment upload failed',
274 'issue.board.form.preview': 'Preview markdown',
275 'issue.board.form.editSource': 'Back to editing',
276 'issue.board.attachment.open': 'Open attachment {name}',
246277 'issue.board.form.title': 'Title',
247278 'issue.board.form.titlePlaceholder': 'Give this issue a clear title',
248279 'issue.board.form.summary': 'Summary',
249280 'issue.board.form.summaryOptional': '(optional)',
250281 'issue.board.form.summaryPlaceholder': 'One sentence on the impact',
251282 'issue.board.form.content': 'Details',
252 'issue.board.form.contentPlaceholder': 'Steps to reproduce:\n1. …\n2. …\n\nExpected:\nActual:',
283 'issue.board.form.contentPlaceholder': 'Add details (steps to reproduce, expected result…)',
253284 'issue.board.form.noLabels': 'This board has no labels yet — create them under Manage · Labels.',
254285 'issue.board.form.noAssignees': 'No members can be assigned yet; you can add an assignee later.',
255286 'issue.board.manage.title': 'Board management',
@@ -260,6 +291,10 @@
260291 'issue.board.manage.runFailed': 'The operation failed. Please try again later.',
261292 'issue.board.manage.processing': 'Working…',
262293 'issue.board.manage.name': 'Name',
294 'issue.board.manage.prefix': 'Issue prefix',
295 'issue.board.manage.prefixHint': 'Issue keys carry this prefix, e.g. CAD-12. Leave it empty to keep the #1 form. Start with a letter; up to 10 letters or digits.',
296 'issue.board.manage.prefixPlaceholder': 'e.g. CAD',
297 'issue.board.manage.prefixInvalid': 'Use a letter followed by letters or digits, up to 10 characters.',
263298 'issue.board.manage.descriptionField': 'Description',
264299 'issue.board.manage.stats': 'Totals: {open} open · {closed} closed',
265300 'issue.board.manage.deleteBoard': 'Delete board',
@@ -309,13 +344,15 @@
309344 'pull.detail.reviewers': 'Reviewers',
310345 'pull.detail.required': 'Required',
311346 'pull.detail.noReviewers': 'No review requested yet.',
312 'pull.detail.reviewRequestPlaceholder': 'Username or UUID to request a review',
347 'pull.detail.reviewRequestPlaceholder': 'Search members to request a review',
313348 'pull.detail.assignees': 'Assignees',
314349 'pull.detail.noAssignees': 'No assignees yet.',
315 'pull.detail.assigneePlaceholder': 'Username to add as assignee',
350 'pull.detail.assigneePlaceholder': 'Search members to add an assignee',
316351 'pull.detail.labels': 'Labels',
317352 'pull.detail.noLabels': 'No labels.',
318 'pull.detail.labelPlaceholder': 'Label name or UUID to attach',
353 'pull.detail.labelPlaceholder': 'Search labels to attach',
354 'pull.detail.pickNoMatch': 'No matching options',
355 'pull.detail.analyzingMerge': 'Analyzing merge conflicts…',
319356 'pull.detail.stats': 'Stats',
320357 'pull.detail.aheadBehind': 'Ahead / behind',
321358 'pull.detail.filesChanged': 'Files changed',
@@ -491,7 +528,9 @@
491528 'pull.form.titlePlaceholderFallback': 'Briefly describe this change',
492529 'pull.form.bodyPlaceholder': 'Explain the motivation, how you verified it, and the risks…',
493530 'pull.form.draftCheckbox': 'Create as draft (mark ready before requesting reviews)',
494 'pull.form.descriptionField': 'Description (plain text, line breaks kept)',
531 'pull.form.aiFill': 'AI fill',
532 'pull.form.aiFilling': 'Generating…',
533 'pull.form.aiFillFailed': 'AI fill failed, try again later', 'pull.form.descriptionField': 'Description (plain text, line breaks kept)',
495534 'issue.board.removeLabel': 'Remove label {name}',
496535 'issue.board.filterLabelPrefix': 'Priority',
497536 'issue.board.form.createDescription': 'Record the problem so collaboration has something to stand on.',
Modified web/src/shared/i18n/messages/zh-CN/issues.ts View web/src/shared/i18n/messages/zh-CN/issues.ts
@@ -111,6 +111,14 @@
111111 'issue.event.unpinned': '取消置顶',
112112 'issue.event.repo_relation_added': '关联了仓库',
113113 'issue.event.repo_relation_removed': '取消了仓库关联',
114 'issue.event.verb.milestone_updated': '将里程碑 {from} → {to}',
115 'issue.event.verb.title_updated': '将标题 {from} → {to}',
116 'issue.event.verb.label_added': '添加了标签 {name}',
117 'issue.event.verb.label_removed': '移除了标签 {name}',
118 'issue.event.verb.member_added': '添加了成员 {name}',
119 'issue.event.verb.member_removed': '移除了成员 {name}',
120 'issue.event.verb.pull_linked': '关联了合并请求 {name}',
121 'issue.event.verb.pull_unlinked': '解除了合并请求关联 {name}',
114122 'issue.filter.searchPlaceholder': '搜索工单…',
115123 'issue.filter.searchAria': '搜索工单标题',
116124 'issue.filter.clearSearch': '清空搜索',
@@ -124,6 +132,16 @@
124132 'issue.filter.sortActive': '排序:{label}',
125133 'issue.filter.viewGroup': '视图切换',
126134 'issue.filter.clear': '清除筛选',
135 'issue.filter.panelTitle': '筛选',
136 'issue.filter.searchIn': '搜索{title}',
137 'issue.filter.searchHint': '按 / 聚焦搜索',
138 'issue.filter.clearAll': '全部清除',
139 'issue.filter.done': '完成',
140 'issue.board.resultCount': '共 {count} 个结果',
141 'issue.board.more': '更多操作',
142 'issue.board.settingsAction': '工单设置',
143 'issue.board.newHint': '新建工单 · 快捷键 C',
144 'issue.filter.chip.sort': '排序:{value}',
127145 'issue.filter.chip.state': '状态:{value}',
128146 'issue.filter.chip.query': '关键词:{value}',
129147 'issue.filter.chip.priority': '优先级:{value}',
@@ -150,7 +168,7 @@
150168 'issue.board.addInColumn': '在「{column}」新建工单',
151169 'issue.board.addCard': '添加一个工单',
152170 'issue.board.addCardHint': '也可以将卡片拖到这里',
153 'issue.board.cardAria': '工单 #{number} {title}',
171 'issue.board.cardAria': '工单 {number} {title}',
154172 'issue.board.milestoneTitle': '里程碑:{name}',
155173 'issue.board.tableCaption': '工单列表,可选择标题查看详情',
156174 'issue.board.col.state': '状态',
@@ -230,7 +248,13 @@
230248 'issue.board.edited': '已编辑',
231249 'issue.board.editCommentAria': '编辑评论',
232250 'issue.board.replyFailed': '回复失败。',
233 'issue.board.replyPlaceholder': '回复…(Enter 发送)',
251 'issue.board.replyPlaceholder': '回复…(Ctrl+Enter 发送,Esc 收起)',
252 'issue.board.reply': '回复',
253 'issue.board.deleteIssue': '删除工单',
254 'issue.board.linkPull': '关联 MR',
255 'issue.board.pickRepo': '选择仓库',
256 'issue.board.event.runExpand': '共 {count} 次改动,展开(最近 {time})',
257 'issue.board.event.runCollapse': '收起改动',
234258 'issue.board.replyAria': '回复 {name}',
235259 'issue.board.commentFallback': '评论',
236260 'issue.board.loadingActivity': '加载活动…',
@@ -238,18 +262,25 @@
238262 'issue.board.noActivity': '暂无活动记录。',
239263 'issue.board.system': '系统',
240264 'issue.board.form.createTitle': '新建工单',
241 'issue.board.form.editTitle': '编辑工单 #{number}',
265 'issue.board.form.editTitle': '编辑工单 {number}',
242266 'issue.board.form.editDescription': '让信息保持清晰,协作就更快。',
243267 'issue.board.form.validationTitle': '请填写标题。',
244268 'issue.board.form.validationTitleMax': '标题最长 {max} 个字符。',
245269 'issue.board.form.saveFailed': '保存失败,请稍后重试。',
270 'issue.board.form.attach': '添加附件',
271 'issue.board.form.removeAttachment': '移除附件 {name}',
272 'issue.board.form.attachmentTooLarge': '文件过大,上限 {max}',
273 'issue.board.form.uploadFailed': '附件上传失败',
274 'issue.board.form.preview': '预览 Markdown',
275 'issue.board.form.editSource': '继续编辑',
276 'issue.board.attachment.open': '打开附件 {name}',
246277 'issue.board.form.title': '标题',
247278 'issue.board.form.titlePlaceholder': '给这个工单一个清晰的标题',
248279 'issue.board.form.summary': '摘要',
249280 'issue.board.form.summaryOptional': '(选填)',
250281 'issue.board.form.summaryPlaceholder': '一句话说明影响范围',
251282 'issue.board.form.content': '详细描述',
252 'issue.board.form.contentPlaceholder': '复现步骤:\n1. …\n2. …\n\n期望结果:\n实际结果:',
283 'issue.board.form.contentPlaceholder': '补充详细描述(复现步骤、期望结果…)',
253284 'issue.board.form.noLabels': '这个问题集还没有标签,可在「管理 · 标签」中创建。',
254285 'issue.board.form.noAssignees': '暂时没有可指派的成员,可以稍后再添加负责人。',
255286 'issue.board.manage.title': '问题集管理',
@@ -260,6 +291,10 @@
260291 'issue.board.manage.runFailed': '操作失败,请稍后重试。',
261292 'issue.board.manage.processing': '处理中…',
262293 'issue.board.manage.name': '名称',
294 'issue.board.manage.prefix': '编号前缀',
295 'issue.board.manage.prefixHint': '工单编号会带上该前缀,例如 CAD-12;留空则使用 #1 形式。字母开头,1–10 位字母或数字。',
296 'issue.board.manage.prefixPlaceholder': '例如 CAD',
297 'issue.board.manage.prefixInvalid': '前缀需以字母开头,仅含字母和数字,最多 10 位。',
263298 'issue.board.manage.descriptionField': '描述',
264299 'issue.board.manage.stats': '统计:未关闭 {open} · 已关闭 {closed}',
265300 'issue.board.manage.deleteBoard': '删除问题集',
@@ -309,13 +344,15 @@
309344 'pull.detail.reviewers': '评审人',
310345 'pull.detail.required': '必需',
311346 'pull.detail.noReviewers': '尚未请求评审。',
312 'pull.detail.reviewRequestPlaceholder': '用户名或 UUID,请求评审',
347 'pull.detail.reviewRequestPlaceholder': '搜索成员,请求评审',
313348 'pull.detail.assignees': '受理人',
314349 'pull.detail.noAssignees': '还没有受理人。',
315 'pull.detail.assigneePlaceholder': '用户名,添加受理人',
350 'pull.detail.assigneePlaceholder': '搜索成员,添加受理人',
316351 'pull.detail.labels': '标签',
317352 'pull.detail.noLabels': '没有标签。',
318 'pull.detail.labelPlaceholder': '标签名或 UUID,附加标签',
353 'pull.detail.labelPlaceholder': '搜索标签,附加到本 PR',
354 'pull.detail.pickNoMatch': '没有匹配的选项',
355 'pull.detail.analyzingMerge': '正在分析合并冲突…',
319356 'pull.detail.stats': '统计',
320357 'pull.detail.aheadBehind': '领先 / 落后',
321358 'pull.detail.filesChanged': '文件变更',
@@ -491,7 +528,9 @@
491528 'pull.form.titlePlaceholderFallback': '简短描述这次改动',
492529 'pull.form.bodyPlaceholder': '说明改动动机、验证方式与风险点…',
493530 'pull.form.draftCheckbox': '以草稿创建(标记为 Draft,ready 后再请求评审)',
494 'pull.form.descriptionField': '描述(支持换行,纯文本渲染)',
531 'pull.form.aiFill': 'AI 填充',
532 'pull.form.aiFilling': '生成中…',
533 'pull.form.aiFillFailed': 'AI 填充失败,请稍后重试', 'pull.form.descriptionField': '描述(支持换行,纯文本渲染)',
495534 'issue.board.removeLabel': '移除标签 {name}',
496535 'issue.board.filterLabelPrefix': '优先级',
497536 'issue.board.form.createDescription': '记录一个问题,让协作有据可循。',