feat(issues): remember each account's 问题集 view
user_preferences gains board_view_mode (`board` by default, added by the pre-sync block that came with the rooms fix) and PATCH /account/preferences now answers with the canonical row instead of an empty envelope, so the writer can publish the truth rather than guess it. The board URL is the source of truth for state, filter and sort; the view is the one field with a *user* default: readIssueBoardView takes defaultView from the saved preference and an explicit ?view= always wins (and is what a shared link carries). normalizeBoardView maps anything unknown back to the kanban. shell-api caches the preference row under the same settings:preferences key the settings modal uses and writes the canonical response back, so flipping the toolbar switch in a board and opening the appearance section never disagree, and no refetch is needed. The appearance section gained the same switch. Also fixes mute_until on the same path: the field is now three-state (absent = unchanged, null = clear, value = set), where before "if cmd.mute_until.is_some()" meant it could only ever be set. openapi.json, web/openapi.json and globals.d.ts are regenerated and therefore also carry the chat embed/oembed changes above.
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified lib/auth/src/handlers/update_preferences.rs lib/auth/src/handlers/update_preferences.rs +5 −5 View lib/auth/src/handlers/update_preferences.rs
@@ -6,7 +6,7 @@
66
77use crate::errors::ApiError;
88use crate::services::AuthService;
9use crate::services::preferences::UpdatePreferencesCmd;
9use crate::services::preferences::{UpdatePreferencesCmd, UserPreferencesDto};
1010use session::share::session::require_uid;
1111
1212#[utoipa::path(
@@ -16,7 +16,7 @@
1616 description = "增量更新当前用户的通知、界面和静音等偏好设置。",
1717 request_body = UpdatePreferencesCmd,
1818 responses(
19 (status = 200, description = "preferences updated", body = AppResponseData<NoData>),
19 (status = 200, description = "preferences updated, returning the canonical row", body = AppResponseData<UserPreferencesDto>),
2020 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
2121 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
2222 )
@@ -25,11 +25,11 @@
2525 State(service): State<AuthService>,
2626 session: Session,
2727 Json(cmd): Json<UpdatePreferencesCmd>,
28) -> Result<AppResponseData<NoData>, ApiError> {
28) -> Result<AppResponseData<UserPreferencesDto>, ApiError> {
2929 let uid = require_uid(&session)?;
30 service
30 let prefs = service
3131 .update_preferences(uid, cmd)
3232 .await
3333 .map_err(ApiError::from)?;
34 Ok(AppResponseData::empty())
34 Ok(AppResponseData::ok(prefs))
3535}
Modified lib/auth/src/services/preferences.rs lib/auth/src/services/preferences.rs +21 −6 View lib/auth/src/services/preferences.rs
@@ -12,6 +12,8 @@
1212pub struct UserPreferencesDto {
1313 pub theme_mode: String,
1414 pub diff_view_style: Option<String>,
15 /// Default view for the 问题集 page: `board` or `list`.
16 pub board_view_mode: String,
1517 pub in_app_flags: i32,
1618 pub email_flags: i32,
1719 pub push_flags: i32,
@@ -26,11 +28,18 @@
2628pub struct UpdatePreferencesCmd {
2729 pub theme_mode: Option<String>,
2830 pub diff_view_style: Option<String>,
31 pub board_view_mode: Option<String>,
2932 pub in_app_flags: Option<i32>,
3033 pub email_flags: Option<i32>,
3134 pub push_flags: Option<i32>,
3235 pub email_digest: Option<bool>,
33 pub mute_until: Option<OffsetDateTime>,
36 /// 三态:缺省不动、`null` 清除、给值设置(与 `lib/webapi/src/openapi.rs`
37 /// 声明的 PATCH 语义一致)。
38 #[serde(
39 default,
40 deserialize_with = "export::serde_datetime::deserialize_opt_opt_datetime"
41 )]
42 pub mute_until: Option<Option<OffsetDateTime>>,
3443 pub allow_dms_from: Option<Audience>,
3544 pub allow_mentions_from: Option<Audience>,
3645 pub show_online_status: Option<bool>,
@@ -41,6 +50,7 @@
4150 Self {
4251 theme_mode: "system".to_string(),
4352 diff_view_style: None,
53 board_view_mode: "board".to_string(),
4454 in_app_flags: 0,
4555 email_flags: 0,
4656 push_flags: 0,
@@ -64,6 +74,7 @@
6474 Ok(UserPreferencesDto {
6575 theme_mode: row.theme_mode,
6676 diff_view_style: row.diff_view_style,
77 board_view_mode: row.board_view_mode,
6778 in_app_flags: row.in_app_flags,
6879 email_flags: row.email_flags,
6980 push_flags: row.push_flags,
@@ -79,7 +90,7 @@
7990 &self,
8091 uid: Uuid,
8192 cmd: UpdatePreferencesCmd,
82 ) -> Result<(), AuthError> {
93 ) -> Result<UserPreferencesDto, AuthError> {
8394 let db = self.db();
8495 let now = OffsetDateTime::now_utc();
8596 let existing = user_preferences::Entity::find_by_id(uid)
@@ -93,6 +104,9 @@
93104 if cmd.diff_view_style.is_some() {
94105 active.diff_view_style = Set(cmd.diff_view_style);
95106 }
107 if let Some(view) = cmd.board_view_mode {
108 active.board_view_mode = Set(view);
109 }
96110 if let Some(flags) = cmd.in_app_flags {
97111 active.in_app_flags = Set(flags);
98112 }
@@ -105,8 +119,8 @@
105119 if let Some(digest) = cmd.email_digest {
106120 active.email_digest = Set(digest);
107121 }
108 if cmd.mute_until.is_some() {
109 active.mute_until = Set(cmd.mute_until);
122 if let Some(mute_until) = cmd.mute_until {
123 active.mute_until = Set(mute_until);
110124 }
111125 if let Some(audience) = cmd.allow_dms_from {
112126 active.allow_dms_from = Set(audience);
@@ -127,11 +141,12 @@
127141 theme_token: Set(None),
128142 theme_version: Set(None),
129143 diff_view_style: Set(cmd.diff_view_style.or(defaults.diff_view_style)),
144 board_view_mode: Set(cmd.board_view_mode.unwrap_or(defaults.board_view_mode)),
130145 in_app_flags: Set(cmd.in_app_flags.unwrap_or(defaults.in_app_flags)),
131146 email_flags: Set(cmd.email_flags.unwrap_or(defaults.email_flags)),
132147 push_flags: Set(cmd.push_flags.unwrap_or(defaults.push_flags)),
133148 email_digest: Set(cmd.email_digest.unwrap_or(defaults.email_digest)),
134 mute_until: Set(cmd.mute_until.or(defaults.mute_until)),
149 mute_until: Set(cmd.mute_until.flatten().or(defaults.mute_until)),
135150 allow_dms_from: Set(cmd.allow_dms_from.unwrap_or(defaults.allow_dms_from)),
136151 allow_mentions_from: Set(cmd
137152 .allow_mentions_from
@@ -144,6 +159,6 @@
144159 };
145160 row.insert(db.write()).await?;
146161 }
147 Ok(())
162 self.preferences(uid).await
148163 }
149164}
Modified lib/models/src/users/user_preferences.rs lib/models/src/users/user_preferences.rs +4 −0 View lib/models/src/users/user_preferences.rs
@@ -13,6 +13,10 @@
1313 pub theme_token: Option<String>,
1414 pub theme_version: Option<i64>,
1515 pub diff_view_style: Option<String>,
16 /// Problem-set (issue board) default view: `board` or `list`. Written when
17 /// the user flips the toolbar switch, read back as every board's startup.
18 #[sea_orm(default_value = "board")]
19 pub board_view_mode: String,
1620 #[sea_orm(default_value = 0)]
1721 pub in_app_flags: i32,
1822 #[sea_orm(default_value = 0)]
Modified web/src/entities/issue/model/meta.ts web/src/entities/issue/model/meta.ts +9 −1 View web/src/entities/issue/model/meta.ts
@@ -5,7 +5,7 @@
55 * the detail sidebar all have to agree on them.
66 */
77import { getLocale, isMessageKey, t } from '@/shared/i18n'
8import type { IssuePriority, IssueState, IssueSort, WatchMode } from './issue'
8import type { IssueBoardView, IssuePriority, IssueState, IssueSort, WatchMode } from './issue'
99
1010export interface PriorityMeta {
1111 id: IssuePriority
@@ -55,6 +55,14 @@
5555 { id: 'number', get label() { return t('label.issueSort.number') } },
5656]
5757
58/**
59 * The two layouts a 问题集 can open in. Stored per user (`board_view_mode`);
60 * an unknown or absent value falls back to the kanban.
61 */
62export function normalizeBoardView(value: string | null | undefined): IssueBoardView['view'] {
63 return value === 'list' ? 'list' : 'board'
64}
65
5866export const WATCH_LABELS: Readonly<Record<WatchMode, string>> = {
5967 get watching() {
6068 return t('issue.watch.watching')
Modified web/src/features/issue-board/ui/issue-board.tsx web/src/features/issue-board/ui/issue-board.tsx +2 −1 View web/src/features/issue-board/ui/issue-board.tsx
@@ -447,7 +447,8 @@
447447 )
448448}
449449
450function BoardSkeleton({ columns }: { columns: number }) {
450/** Public so the route layer can hold the frame while a preference decides the layout. */
451export function BoardSkeleton({ columns }: { columns: number }) {
451452 const { t } = useT()
452453 return (
453454 <div className="issue-board-columns issue-board-skeleton flex min-h-0 flex-1 gap-3" role="status" aria-label={t('issue.board.loading')} aria-busy>
Modified web/src/features/issue-board/ui/issue-detail.tsx web/src/features/issue-board/ui/issue-detail.tsx +72 −10 View web/src/features/issue-board/ui/issue-detail.tsx
@@ -6,7 +6,7 @@
66 */
77import { useEffect, useState } from 'react'
88import { ArrowLeft, Link2, Loader2, Pencil, Tag, Users } from 'lucide-react'
9import { fmtTime, issueKey, useAsync } from '@/shared/lib'
9import { fmtTime, issueKey, spaceReposPickKey, useAsync } from '@/shared/lib'
1010import { t as tr, useT } from '@/shared/i18n'
1111import { ErrorPanel, PanelNote } from '@/shared/ui'
1212import {
@@ -34,7 +34,14 @@
3434 type WatchMode,
3535} from '@/entities/issue'
3636import { DangerButton, FilterMenu, LabelChip, PriorityBadge, StateBadge, UserAvatar } from './parts'
37import { listIssuePulls, linkIssuePull, unlinkIssuePull, type IssuePullLink } from '@/entities/pull-request'
37import {
38 listIssuePulls,
39 listPulls,
40 linkIssuePull,
41 unlinkIssuePull,
42 type IssuePullLink,
43} from '@/entities/pull-request'
44import { listSpaceRepos } from '@/entities/repo'
3845import type { BoardData } from '../model/use-board-data'
3946
4047const WATCH_LABEL_KEYS: Record<WatchMode, 'issue.watch.watching' | 'issue.watch.unwatched' | 'issue.watch.ignore'> = {
@@ -351,7 +358,23 @@
351358 const [error, setError] = useState<string | null>(null)
352359 const [busy, setBusy] = useState(false)
353360 const pulls = useAsync(() => listIssuePulls(boardRef.space, boardRef.board, issueId), [boardRef.space, boardRef.board, issueId, tick])
361 // 仓库从这里选,不再靠手打:社区里的仓库名是唯一合法输入,列出来才能避免拼错。
362 const repos = useAsync(() => listSpaceRepos(boardRef.space), [boardRef.space], {
363 cacheKey: spaceReposPickKey(boardRef.space),
364 })
354365 const rows = pulls.data ?? []
366 const repoOptions = (repos.data ?? []).map(repo => ({ value: repo.name, label: repo.name }))
367 // The PR is picked from the chosen repo's own list for the same reason the
368 // repo is: `pull_ref` is matched against the repo's numbering, not typed.
369 // No repo chosen yet means no list to offer, so the query stays idle.
370 const openPulls = useAsync(
371 () => (repoName ? listPulls(boardRef.space, repoName, { state: 'open', limit: 50 }) : Promise.resolve({ items: [], total: 0, limit: 0, offset: 0 })),
372 [boardRef.space, repoName],
373 )
374 const pullOptions = (openPulls.data?.items ?? []).map(pull => ({
375 value: String(pull.number),
376 label: `#${pull.number} ${pull.title}`,
377 }))
355378
356379 async function link() {
357380 if (!repoName.trim() || !pullRef.trim()) return
@@ -374,7 +397,10 @@
374397 }
375398
376399 return (
377 <div className="space-y-1.5">
400 // `w-full` keeps this panel on its own line inside `Field`'s flex-wrap
401 // body: as a bare flex item its automatic minimum size is min-content, so
402 // the repo/PR inputs pushed the row past the 240px sidebar column.
403 <div className="w-full min-w-0 space-y-1.5">
378404 {rows.map((link: IssuePullLink) => (
379405 <p key={link.relation_id} className="flex items-start gap-1 text-[12.5px]">
380406 <Link2 className="mt-1 inline h-3 w-3 shrink-0 text-shell-ink-muted" aria-hidden />
@@ -398,13 +424,49 @@
398424 </p>
399425 ))}
400426 {rows.length === 0 ? <p className="text-[12px] text-shell-ink-muted">{t('issue.board.noPullLinks')}</p> : null}
401 <div className="flex gap-1">
402 <input value={repoName} onChange={event => setRepoName(event.target.value)} placeholder={t('issue.board.repoPlaceholder')} className="h-7 min-w-0 flex-1 rounded-md border border-shell-hairline bg-shell-field px-2 text-[12px] outline-none focus-visible:border-shell-ink" />
403 <input value={pullRef} onChange={event => setPullRef(event.target.value)} placeholder={t('issue.board.pullPlaceholder')} className="h-7 w-16 rounded-md border border-shell-hairline bg-shell-field px-2 text-[12px] outline-none focus-visible:border-shell-ink" />
404 <button type="button" disabled={busy || !repoName.trim() || !pullRef.trim()} onClick={() => void link()} className="h-7 shrink-0 rounded-md bg-shell-active px-2 text-[12px] font-medium disabled:opacity-50">
405 {t('issue.board.link')}
406 </button>
407 </div>
427 {/*
428 A repo is addressed by its exact name, so it is picked from the space's
429 own repos instead of typed. The menu sits on its own line: it is a
430 `shrink-0` item sized to 100% of the sidebar, which cannot share a row
431 with the PR field inside this 240px column.
432 */}
433 {repoOptions.length > 0 ? (
434 <FilterMenu
435 label={t('issue.board.repoPlaceholder')}
436 placeholder={t('issue.board.repoPlaceholder')}
437 value={repoName}
438 options={repoOptions}
439 onChange={next => {
440 setRepoName(next)
441 // A PR number only means something inside one repo.
442 setPullRef('')
443 }}
444 />
445 ) : (
446 <span className="block h-7 truncate text-[12px] leading-7 text-shell-ink-muted">
447 {repos.loading ? t('ui.loading') : t('issue.board.noRepos')}
448 </span>
449 )}
450 {pullOptions.length > 0 ? (
451 <FilterMenu
452 label={t('issue.board.pullPlaceholder')}
453 placeholder={t('issue.board.pickPull')}
454 value={pullRef}
455 options={pullOptions}
456 onChange={setPullRef}
457 />
458 ) : (
459 <span className="block h-7 truncate text-[12px] leading-7 text-shell-ink-muted">
460 {!repoName
461 ? t('issue.board.pickRepoFirst')
462 : openPulls.loading
463 ? t('ui.loading')
464 : t('issue.board.noOpenPulls')}
465 </span>
466 )}
467 <button type="button" disabled={busy || !repoName || !pullRef} onClick={() => void link()} className="h-7 w-full shrink-0 rounded-md bg-shell-active px-2 text-[12px] font-medium disabled:opacity-50">
468 {t('issue.board.link')}
469 </button>
408470 {error ? <p className="text-[11.5px] text-[#b42318]">{error}</p> : null}
409471 </div>
410472 )
Modified web/src/features/issue-board/index.ts web/src/features/issue-board/index.ts +1 −1 View web/src/features/issue-board/index.ts
@@ -4,7 +4,7 @@
44export { ISSUE_PAGE_SIZE, useBoardData, type BoardData } from './model/use-board-data'
55export { prefetchBoardView } from './model/prefetch'
66export { BoardActions, BoardFilters } from './ui/board-filters'
7export { IssueBoardBody } from './ui/issue-board'
7export { BoardSkeleton, IssueBoardBody } from './ui/issue-board'
88export { IssueDetail } from './ui/issue-detail'
99export { IssueFormModal } from './ui/issue-form-modal'
1010export { BoardManageModal } from './ui/board-manage-modal'
Modified web/src/features/user-settings/ui/sections/appearance-section.tsx web/src/features/user-settings/ui/sections/appearance-section.tsx +34 −3 View web/src/features/user-settings/ui/sections/appearance-section.tsx
@@ -1,6 +1,7 @@
11import { useState } from 'react'
22import { useT, type MessageKey } from '@/shared/i18n'
33import { previewColors, setTheme } from '@/shared/lib'
4import { normalizeBoardView } from '@/entities/issue'
45import { Busy, Field, SaveBar, SectionTitle, inputCls, useSave, useThemeContext } from '@/shared/ui'
56import * as api from '../../model/api'
67import { usePreferences } from '../../model/preferences'
@@ -11,6 +12,12 @@
1112 { value: 'split', key: 'settings.appearance.diffSplit', noteKey: 'settings.appearance.diffSplitNote' },
1213]
1314
15/** `board_view_mode`: the layout a 问题集 opens in when its URL names none. */
16const BOARD_VIEW_OPTIONS: ReadonlyArray<{ value: string; key: MessageKey }> = [
17 { value: 'board', key: 'issue.board.viewBoard' },
18 { value: 'list', key: 'issue.board.viewList' },
19]
20
1421/** Static illustration of the chosen layout — the setting is otherwise
1522 * invisible until you open a commit. */
1623function DiffStylePreview({ style }: { style: 'unified' | 'split' }) {
@@ -147,9 +154,16 @@
147154 const { t } = useT()
148155 const prefs = usePreferences()
149156 const { busy, notice, error, run } = useSave()
150 const [form, setForm] = useState<{ theme: string; diff: string } | null>(null)
157 const [form, setForm] = useState<{ theme: string; diff: string; boardView: string } | null>(null)
151158 const current =
152 form ?? (prefs.data ? { theme: prefs.data.theme_mode || 'system', diff: prefs.data.diff_view_style ?? '' } : null)
159 form ??
160 (prefs.data
161 ? {
162 theme: prefs.data.theme_mode || 'system',
163 diff: prefs.data.diff_view_style ?? '',
164 boardView: normalizeBoardView(prefs.data.board_view_mode),
165 }
166 : null)
153167 if (prefs.loading || !current)
154168 return (
155169 <>
@@ -197,13 +211,30 @@
197211 <div className="mb-5">
198212 <DiffStylePreview style={current.diff === 'split' ? 'split' : 'unified'} />
199213 </div>
214 <Field label={t('settings.appearance.boardView')} hint={t('settings.appearance.boardViewHint')}>
215 <select
216 className={inputCls}
217 value={current.boardView}
218 onChange={e => setForm({ ...current, boardView: e.target.value })}
219 >
220 {BOARD_VIEW_OPTIONS.map(item => (
221 <option key={item.value} value={item.value}>
222 {t(item.key)}
223 </option>
224 ))}
225 </select>
226 </Field>
200227 <SaveBar
201228 busy={busy}
202229 notice={notice}
203230 error={error}
204231 onSave={() =>
205232 void run(async () => {
206 await api.updatePreferences({ theme_mode: current.theme, diff_view_style: current.diff || null })
233 await api.updatePreferences({
234 theme_mode: current.theme,
235 diff_view_style: current.diff || null,
236 board_view_mode: current.boardView,
237 })
207238 applyMode(current.theme)
208239 }, t('settings.appearance.saved'))
209240 }
Modified web/src/pages/workspace/model/board-location.ts web/src/pages/workspace/model/board-location.ts +60 −15 View web/src/pages/workspace/model/board-location.ts
@@ -20,6 +20,11 @@
2020 *
2121 * Not in the URL: which modal is open, the composer draft, the in-progress
2222 * drag — ephemeral interaction state that would only spam the history stack.
23 *
24 * 视图 (board/list) is the one field with a *user* default: it comes from the
25 * saved preference (`board_view_mode`), so a plain board URL renders whatever
26 * layout this account prefers. An explicit `?view=` always wins and is what a
27 * shared link carries.
2328 */
2429/** Defaults are omitted from the URL, so the plain board reads as `/app/…/board/<id>`. */
2530export const BOARD_VIEW_DEFAULTS: IssueBoardView = {
@@ -50,11 +55,18 @@
5055 return allowed.includes((raw ?? '') as T) ? (raw as T) : fallback
5156}
5257
53/** Parse the URL into board state; unknown values fall back to their default. */
54export function readIssueBoardView(params: URLSearchParams): IssueBoardView {
58/**
59 * Parse the URL into board state; unknown values fall back to their default.
60 * `defaultView` is the account's saved 问题集 view, used only while the URL
61 * does not name one itself.
62 */
63export function readIssueBoardView(
64 params: URLSearchParams,
65 defaultView: IssueBoardView['view'] = 'board',
66): IssueBoardView {
5567 const rawPage = Number.parseInt(params.get('page') ?? '', 10)
5668 return {
57 view: pick(params.get('view'), VIEWS, 'board'),
69 view: pick(params.get('view'), VIEWS, defaultView),
5870 state: pick(params.get('state'), STATES, 'open'),
5971 q: params.get('q') ?? '',
6072 priority: pick(params.get('priority'), PRIORITIES, '') as IssuePriority | '',
@@ -68,10 +80,14 @@
6880}
6981
7082/** Encode state, dropping every default-valued key so plain URLs stay short. */
71export function writeIssueBoardView(base: URLSearchParams, state: IssueBoardView): URLSearchParams {
83export function writeIssueBoardView(
84 base: URLSearchParams,
85 state: IssueBoardView,
86 defaultView: IssueBoardView['view'] = 'board',
87): URLSearchParams {
7288 const next = new URLSearchParams(base)
7389 for (const key of OWNED_KEYS) next.delete(key)
74 if (state.view !== 'board') next.set('view', state.view)
90 if (state.view !== defaultView) next.set('view', state.view)
7591 if (state.state !== 'open') next.set('state', state.state)
7692 if (state.q) next.set('q', state.q)
7793 if (state.priority) next.set('priority', state.priority)
@@ -100,6 +116,10 @@
100116
101117export interface BoardViewLocation {
102118 view: IssueBoardView
119 /** The account's saved 问题集 view — what a URL without `?view=` renders. */
120 defaultView: IssueBoardView['view']
121 /** True when the URL itself pins the view, so no preference is consulted. */
122 pinnedView: boolean
103123 /** True while at least one filter narrows the result set. */
104124 filtered: boolean
105125 patch: (updates: Partial<IssueBoardView>, options?: { replace?: boolean }) => void
@@ -113,19 +133,35 @@
113133 normalizeForBoard: () => void
114134}
115135
116/** Board view state as URL state (no component-local mirrors). */
117export function useBoardViewLocation(): BoardViewLocation {
136/**
137 * Board view state as URL state (no component-local mirrors).
138 *
139 * `defaultView` is the saved preference, applied to any board URL that does not
140 * carry an explicit `?view=`. It is *not* synced into the URL: staying out of
141 * the URL is what keeps "follow my preference" distinct from "this link says
142 * list", and it means changing the preference re-lays out an already-open
143 * board.
144 */
145export function useBoardViewLocation(defaultView: IssueBoardView['view'] = 'board'): BoardViewLocation {
118146 const [params, setParams] = useSearchParams()
119 const view = useMemo(() => readIssueBoardView(params), [params])
147 const view = useMemo(() => readIssueBoardView(params, defaultView), [params, defaultView])
120148
121149 const patch = useCallback<BoardViewLocation['patch']>(
122150 (updates, options) => {
123 setParams(prev => writeIssueBoardView(prev, applyBoardViewPatch(readIssueBoardView(prev), updates)), {
124 replace: options?.replace ?? false,
125 preventScrollReset: true,
126 })
151 setParams(
152 prev =>
153 writeIssueBoardView(
154 prev,
155 applyBoardViewPatch(readIssueBoardView(prev, defaultView), updates),
156 defaultView,
157 ),
158 {
159 replace: options?.replace ?? false,
160 preventScrollReset: true,
161 },
162 )
127163 },
128 [setParams],
164 [setParams, defaultView],
129165 )
130166
131167 const setFilter = useCallback<BoardViewLocation['setFilter']>(updates => patch(updates), [patch])
@@ -147,8 +183,15 @@
147183 [patch],
148184 )
149185 const normalizeForBoard = useCallback<BoardViewLocation['normalizeForBoard']>(
150 () => setParams(prev => writeIssueBoardView(prev, BOARD_VIEW_DEFAULTS), { replace: true, preventScrollReset: true }),
151 [setParams],
186 () =>
187 setParams(
188 prev =>
189 // A fresh board starts at the account's own view: normalizing back to
190 // a literal `board` would pin the next board against the preference.
191 writeIssueBoardView(prev, { ...BOARD_VIEW_DEFAULTS, view: defaultView }, defaultView),
192 { replace: true, preventScrollReset: true },
193 ),
194 [setParams, defaultView],
152195 )
153196
154197 const filtered =
@@ -162,6 +205,8 @@
162205
163206 return {
164207 view,
208 defaultView,
209 pinnedView: VIEWS.includes((params.get('view') ?? '') as IssueBoardView['view']),
165210 filtered,
166211 patch,
167212 setView,
Modified web/src/pages/workspace/model/shell-api.ts web/src/pages/workspace/model/shell-api.ts +21 −1 View web/src/pages/workspace/model/shell-api.ts
@@ -5,7 +5,8 @@
55 * features/user-settings.
66 */
77import Apis from '@/shared/api'
8import { unwrapApi, type ApiEnvelope } from '@/shared/lib'
8import { settingsKey, unwrapApi, type ApiEnvelope } from '@/shared/lib'
9import { writeCache } from '@/shared/storage'
910import type {
1011 BoardItem,
1112 DmItem,
@@ -14,10 +15,29 @@
1415 RepoItem,
1516 RoomBrief,
1617 TagItem,
18 UserPreferences,
1719} from './workspace'
1820
1921export const fetchMe = () => unwrapApi<Me>(Apis.account.me())
2022
23/**
24 * The account's preference row, cached under the same key the settings modal
25 * writes so both surfaces read one payload.
26 */
27export const fetchPreferences = () =>
28 unwrapApi<UserPreferences>(Apis.account.get_preferences())
29
30/**
31 * Persist one preference (the issue-board view switch writes here). The
32 * response is the canonical row: store it so every other reader of
33 * `settings:preferences` sees the change without a refetch.
34 */
35export const updatePreferences = (cmd: { board_view_mode?: string }) =>
36 unwrapApi<UserPreferences>(Apis.account.update_preferences({ data: cmd })).then(prefs => {
37 writeCache(settingsKey('preferences'), prefs)
38 return prefs
39 })
40
2141export const fetchMySpaces = () => unwrapApi<MySpace[]>(Apis.user.my_spaces({ params: {} }))
2242
2343export const fetchSpaceRooms = (spaceName: string) =>
Modified web/src/pages/workspace/model/workspace.ts web/src/pages/workspace/model/workspace.ts +3 −0 View web/src/pages/workspace/model/workspace.ts
@@ -11,6 +11,7 @@
1111 AppResponseData_Vec_DirNodeDto,
1212 AppResponseData_Page_RepoSummaryDto,
1313 AppResponseData_Vec_MySpaceDto,
14 AppResponseData_UserPreferencesDto,
1415} from '@/shared/api'
1516
1617export type MySpace = NonNullable<AppResponseData_Vec_MySpaceDto['data']>[number]
@@ -25,6 +26,8 @@
2526export type Me = NonNullable<AppResponseData_MeResp['data']>
2627export type SpaceDto = NonNullable<AppResponseData_SpaceDto['data']>
2728export type Invite = NonNullable<AppResponseData_InviteDto['data']>
29/** The signed-in user's preference row — the same payload the settings modal edits. */
30export type UserPreferences = NonNullable<AppResponseData_UserPreferencesDto['data']>
2831
2932/** Directory node of the sidebar asset tree (rooms/repos/boards nested under dirs). */
3033export type DirNode = NonNullable<AppResponseData_Vec_DirNodeDto['data']>[number]
Modified web/src/pages/workspace/ui/board/board-view.tsx web/src/pages/workspace/ui/board/board-view.tsx +39 −3 View web/src/pages/workspace/ui/board/board-view.tsx
@@ -4,6 +4,7 @@
44import {
55 BoardActions,
66 BoardFilters,
7 BoardSkeleton,
78 IssueBoardBody,
89 IssueDetail,
910 IssueFormModal,
@@ -13,13 +14,17 @@
1314} from '@/features/issue-board'
1415import type {
1516 Board,
17 IssueBoardView,
1618 IssueDetail as IssueDetailDto,
1719 IssuePriority,
1820 IssueQuery,
1921 UserDto,
2022} from '@/entities/issue'
23import { normalizeBoardView } from '@/entities/issue'
2124import { useBoardViewLocation } from '../../model/board-location'
25import { fetchPreferences, updatePreferences } from '../../model/shell-api'
2226import { BoardLayout } from '../layout/board-layout'
27import { settingsKey, useAsync } from '@/shared/lib'
2328
2429export interface BoardViewProps {
2530 spaceName: string
@@ -50,13 +55,42 @@
5055export function BoardView({ spaceName, boardId, boardHint, viewerId, shell, onCountsChanged }: BoardViewProps) {
5156 const { t } = useT()
5257 const navigate = useNavigate()
53 const loc = useBoardViewLocation()
58 // 走哪一层视图是用户的偏好:URL 没有 `?view=` 时按 `board_view_mode` 起手,
59 // 用户在工具栏切换时写回偏好(下一块问题集也按新视图打开)。
60 const prefs = useAsync(fetchPreferences, [], { cacheKey: settingsKey('preferences') })
61 const loc = useBoardViewLocation(normalizeBoardView(prefs.data?.board_view_mode))
5462 const { view } = loc
5563 const [reload, setReload] = useState(0)
5664 const [creating, setCreating] = useState<{ priority?: IssuePriority } | null>(null)
5765 const [editing, setEditing] = useState<IssueDetailDto | null>(null)
5866 const [managing, setManaging] = useState(false)
5967
68 /**
69 * The 看板/列表 switch is a persisted preference, not just URL state: flipping
70 * it here is the user saying "this is how I read problem sets", so the next
71 * board opens the same way. Only a real change is written — re-clicking the
72 * active view, or a value the URL already pinned, must not rewrite the
73 * account's default.
74 */
75 const setFilter = useCallback(
76 (updates: Partial<IssueBoardView>) => {
77 loc.setFilter(updates)
78 const next = updates.view
79 // Only a click that actually changes the layout is a statement about the
80 // preference; merely rendering a pinned `?view=` link is not.
81 if (!next || next === view.view) return
82 void updatePreferences({ board_view_mode: next })
83 },
84 [loc, view.view],
85 )
86 /**
87 * The layout itself is unknown until the preference lands, and painting the
88 * wrong one would flash a five-column track at a reader whose default is the
89 * list. A URL that pins `?view=` needs nothing, so the wait only applies to
90 * the inherited case.
91 */
92 const viewPending = !loc.pinnedView && !prefs.data && prefs.loading
93
6094 const boardRef = useMemo(() => ({ space: spaceName, board: boardId }), [spaceName, boardId])
6195 const query = useMemo<IssueQuery>(
6296 () => ({
@@ -149,7 +183,7 @@
149183 labelsFailed={Boolean(data.labels.error)}
150184 milestones={data.milestones.data?.items ?? []}
151185 members={members}
152 onFilter={loc.setFilter}
186 onFilter={setFilter}
153187 onClear={loc.clearFilters}
154188 />
155189 }
@@ -169,13 +203,15 @@
169203 onChanged={onChanged}
170204 onEdit={issue => setEditing(issue)}
171205 />
206 ) : viewPending ? (
207 <BoardSkeleton columns={1} />
172208 ) : (
173209 <IssueBoardBody
174210 boardRef={boardRef}
175211 data={data}
176212 view={view}
177213 filtered={loc.filtered}
178 onFilter={loc.setFilter}
214 onFilter={setFilter}
179215 onClear={loc.clearFilters}
180216 onOpenIssue={loc.openIssue}
181217 onCreate={preset => setCreating(preset ?? {})}
Modified web/src/shared/api/globals.d.ts web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts
Diff too large to display.
Modified web/src/shared/i18n/messages/en/issues.ts web/src/shared/i18n/messages/en/issues.ts +4 −0 View web/src/shared/i18n/messages/en/issues.ts
@@ -202,7 +202,11 @@
202202 'issue.board.stateOpen': 'Open',
203203 'issue.board.noPullLinks': 'No pull requests linked yet.',
204204 'issue.board.repoPlaceholder': 'Repository',
205 'issue.board.noRepos': 'This space has no repositories yet',
205206 'issue.board.pullPlaceholder': 'PR no.',
207 'issue.board.pickPull': 'Pick a pull request',
208 'issue.board.pickRepoFirst': 'Pick a repository first',
209 'issue.board.noOpenPulls': 'No open pull requests in this repository',
206210 'issue.board.link': 'Link',
207211 'issue.board.noMembers': 'No members available',
208212 'issue.board.assignAria': 'Assign to {name}',
Modified web/src/shared/i18n/messages/en/settings.ts web/src/shared/i18n/messages/en/settings.ts +2 −0 View web/src/shared/i18n/messages/en/settings.ts
@@ -93,6 +93,8 @@
9393 'settings.appearance.diffSplit': 'Split · deletions left, additions right',
9494 'settings.appearance.diffSplitNote': 'Stores the value split',
9595 'settings.appearance.diffHintSuffix': 'the server only recognises split; any other value renders unified',
96 'settings.appearance.boardView': 'Default issue-tracker view',
97 'settings.appearance.boardViewHint': 'Whether a problem set opens as a board or a list; a link carrying ?view= still wins.',
9698 'userSettings.appearance.themeSystem': 'Follow system',
9799 'settings.appearance.saved': 'Appearance saved.',
98100 'settings.keys.title': 'Access keys',
Modified web/src/shared/i18n/messages/zh-CN/issues.ts web/src/shared/i18n/messages/zh-CN/issues.ts +4 −0 View web/src/shared/i18n/messages/zh-CN/issues.ts
@@ -202,7 +202,11 @@
202202 'issue.board.stateOpen': '进行中',
203203 'issue.board.noPullLinks': '尚未关联合并请求。',
204204 'issue.board.repoPlaceholder': '仓库名',
205 'issue.board.noRepos': '这个社区还没有仓库',
205206 'issue.board.pullPlaceholder': 'PR 编号',
207 'issue.board.pickPull': '选择 PR',
208 'issue.board.pickRepoFirst': '先选仓库',
209 'issue.board.noOpenPulls': '这个仓库没有待合并的 PR',
206210 'issue.board.link': '关联',
207211 'issue.board.noMembers': '没有可选成员',
208212 'issue.board.assignAria': '指派给 {name}',
Modified web/src/shared/i18n/messages/zh-CN/settings.ts web/src/shared/i18n/messages/zh-CN/settings.ts +2 −0 View web/src/shared/i18n/messages/zh-CN/settings.ts
@@ -93,6 +93,8 @@
9393 'settings.appearance.diffSplit': '分栏 · 左删右增两列对照',
9494 'settings.appearance.diffSplitNote': '保存值 split',
9595 'settings.appearance.diffHintSuffix': '服务端仅识别 split,其它值一律按统一渲染',
96 'settings.appearance.boardView': '问题集默认视图',
97 'settings.appearance.boardViewHint': '打开问题集时使用看板或列表;带 ?view= 的链接仍以链接为准。',
9698 'userSettings.appearance.themeSystem': '跟随系统',
9799 'settings.appearance.saved': '外观设置已保存。',
98100 'settings.keys.title': '访问密钥',
Modified web/src/shared/lib/cache-keys.ts web/src/shared/lib/cache-keys.ts +9 −0 View web/src/shared/lib/cache-keys.ts
@@ -26,6 +26,15 @@
2626export const spaceRoomsKey = (space: string) => `space:${space}:rooms`
2727export const spaceDirKey = (space: string) => `space:${space}:dir`
2828export const spaceReposKey = (space: string) => `space:${space}:repos`
29/**
30 * The full name-sorted repo list behind the repo pickers (link a PR, …).
31 *
32 * Deliberately separate from `spaceReposKey`: the sidebar fetches 50 rows in
33 * default order, this one up to 100 in name order, and `useAsync` caches by key
34 * alone — one key for both would let whichever loaded first decide the other's
35 * ordering and truncation.
36 */
37export const spaceReposPickKey = (space: string) => `space:${space}:repos:pick`
2938export const spaceBoardsKey = (space: string) => `space:${space}:boards`
3039export const spaceEmojisKey = (space: string) => `space:${space}:emojis`
3140
Modified web/openapi.json web/openapi.json View web/openapi.json
Diff too large to display.
Modified openapi.json openapi.json View openapi.json
Diff too large to display.