From 71c0a25103c0b1f34383f29e03db96d0c817e6bc Mon Sep 17 00:00:00 2001 From: Daniel Raab Date: Tue, 13 Oct 2026 17:13:27 +0200 Subject: [PATCH] feat: add watcher functionality to work items - Implemented the ability to add and remove watchers for work items. - Added API endpoints to list, add, and remove watchers. - Updated item detail view to display the number of watchers and allow users to manage watchers. - Enhanced localization files to include new watcher-related strings in English, German, French, Russian, and Chinese. - Introduced a new internal model for item watchers to store user details. - Updated the item application service to handle watcher management and count. --- api/openapi-v2.json | 397 ++++++++++++++++++ frontend/src/lib/api/items.js | 10 + .../features/items/ItemDetailSidebar.svelte | 7 + .../lib/features/items/WatchersSection.svelte | 220 ++++++++++ frontend/src/lib/locales/de/workspace.js | 8 + frontend/src/lib/locales/en/workspace.js | 8 + frontend/src/lib/locales/fr/workspace.js | 8 + frontend/src/lib/locales/ru/workspace.js | 2 +- frontend/src/lib/locales/zh-CN/workspace.js | 8 + .../src/lib/mobile/MobileItemDetail.svelte | 24 +- .../src/lib/stores/itemDetailStore.svelte.js | 55 ++- internal/models/item_watcher.go | 16 + internal/repository/item_watch.go | 61 +++ internal/restapi/v2/contract-metadata.json | 102 +++++ internal/restapi/v2/items.go | 35 ++ internal/services/item_application_service.go | 123 +++++- .../item_detail_application_service.go | 2 + 17 files changed, 1059 insertions(+), 27 deletions(-) create mode 100644 frontend/src/lib/features/items/WatchersSection.svelte create mode 100644 internal/models/item_watcher.go diff --git a/api/openapi-v2.json b/api/openapi-v2.json index 1e0cae3e8..8bcde8833 100644 --- a/api/openapi-v2.json +++ b/api/openapi-v2.json @@ -1374,6 +1374,10 @@ "description": "The item id value.", "type": "integer" }, + "watcher_count": { + "description": "The watcher count value.", + "type": "integer" + }, "watching": { "description": "The watching value.", "type": "boolean" @@ -1381,6 +1385,7 @@ }, "required": [ "item_id", + "watcher_count", "watching" ], "type": "object" @@ -1391,6 +1396,67 @@ ], "type": "object" }, + "DeleteItemsItemIdWatchersUserIdResult": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "description": "The avatar url value.", + "nullable": true, + "type": "string" + }, + "email": { + "description": "The email value.", + "type": "string" + }, + "first_name": { + "description": "The first name value.", + "type": "string" + }, + "last_name": { + "description": "The last name value.", + "type": "string" + }, + "user_id": { + "description": "The user id value.", + "type": "integer" + }, + "username": { + "description": "The username value.", + "type": "string" + }, + "watch_reason": { + "description": "The watch reason value.", + "nullable": true, + "type": "string" + }, + "watched_at": { + "description": "The watched at value.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "email", + "first_name", + "last_name", + "user_id", + "username", + "watched_at" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "ErrorContent": { "properties": { "code": { @@ -19064,6 +19130,10 @@ ], "type": "object" }, + "watcher_count": { + "description": "The watcher count value.", + "type": "integer" + }, "watching": { "description": "The watching value.", "type": "boolean" @@ -19084,6 +19154,7 @@ "screen_context", "section_errors", "transitions", + "watcher_count", "watching" ], "type": "object" @@ -22900,6 +22971,10 @@ "description": "The item id value.", "type": "integer" }, + "watcher_count": { + "description": "The watcher count value.", + "type": "integer" + }, "watching": { "description": "The watching value.", "type": "boolean" @@ -22907,6 +22982,7 @@ }, "required": [ "item_id", + "watcher_count", "watching" ], "type": "object" @@ -22917,6 +22993,67 @@ ], "type": "object" }, + "GetItemsItemIdWatchersResult": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "description": "The avatar url value.", + "nullable": true, + "type": "string" + }, + "email": { + "description": "The email value.", + "type": "string" + }, + "first_name": { + "description": "The first name value.", + "type": "string" + }, + "last_name": { + "description": "The last name value.", + "type": "string" + }, + "user_id": { + "description": "The user id value.", + "type": "integer" + }, + "username": { + "description": "The username value.", + "type": "string" + }, + "watch_reason": { + "description": "The watch reason value.", + "nullable": true, + "type": "string" + }, + "watched_at": { + "description": "The watched at value.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "email", + "first_name", + "last_name", + "user_id", + "username", + "watched_at" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "GetItemsItemIdWorklogsResult": { "additionalProperties": false, "properties": { @@ -48769,6 +48906,10 @@ ], "type": "object" }, + "watcher_count": { + "description": "The watcher count value.", + "type": "integer" + }, "watching": { "description": "The watching value.", "type": "boolean" @@ -48789,6 +48930,7 @@ "screen_context", "section_errors", "transitions", + "watcher_count", "watching" ], "type": "object" @@ -94741,6 +94883,10 @@ "description": "The item id value.", "type": "integer" }, + "watcher_count": { + "description": "The watcher count value.", + "type": "integer" + }, "watching": { "description": "The watching value.", "type": "boolean" @@ -94748,6 +94894,7 @@ }, "required": [ "item_id", + "watcher_count", "watching" ], "type": "object" @@ -94758,6 +94905,67 @@ ], "type": "object" }, + "PutItemsItemIdWatchersUserIdResult": { + "additionalProperties": false, + "properties": { + "data": { + "items": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "description": "The avatar url value.", + "nullable": true, + "type": "string" + }, + "email": { + "description": "The email value.", + "type": "string" + }, + "first_name": { + "description": "The first name value.", + "type": "string" + }, + "last_name": { + "description": "The last name value.", + "type": "string" + }, + "user_id": { + "description": "The user id value.", + "type": "integer" + }, + "username": { + "description": "The username value.", + "type": "string" + }, + "watch_reason": { + "description": "The watch reason value.", + "nullable": true, + "type": "string" + }, + "watched_at": { + "description": "The watched at value.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "email", + "first_name", + "last_name", + "user_id", + "username", + "watched_at" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "PutScreensScreenIdFieldsPayload": { "additionalProperties": false, "properties": { @@ -109957,6 +110165,195 @@ ] } }, + "/items/{item_id}/watchers": { + "get": { + "description": "List item watchers. Results include only resources visible to the authenticated caller; collections use their documented stable order and bounds.", + "operationId": "getItemsItemIdWatchers", + "parameters": [ + { + "description": "The item identifier.", + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetItemsItemIdWatchersResult" + } + } + }, + "description": "List item watchers response." + }, + "401": { + "$ref": "#/components/responses/AuthenticationError" + }, + "403": { + "$ref": "#/components/responses/PermissionError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + }, + "429": { + "$ref": "#/components/responses/RateLimitError" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "List item watchers", + "tags": [ + "Work items" + ], + "x-required-scopes": [ + "items:read" + ] + } + }, + "/items/{item_id}/watchers/{user_id}": { + "delete": { + "description": "Remove item watcher. The server verifies resource ownership and deletion preconditions before removing the resource.", + "operationId": "deleteItemsItemIdWatchersUserId", + "parameters": [ + { + "description": "The item identifier.", + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "minimum": 1, + "type": "integer" + } + }, + { + "description": "The user identifier.", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteItemsItemIdWatchersUserIdResult" + } + } + }, + "description": "Remove item watcher response." + }, + "401": { + "$ref": "#/components/responses/AuthenticationError" + }, + "403": { + "$ref": "#/components/responses/PermissionError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + }, + "429": { + "$ref": "#/components/responses/RateLimitError" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Remove item watcher", + "tags": [ + "Work items" + ], + "x-required-scopes": [ + "items:write" + ] + }, + "put": { + "description": "Add item watcher. The server validates resource ownership and command preconditions before persisting changes. Unknown JSON fields are rejected when the operation accepts a body.", + "operationId": "putItemsItemIdWatchersUserId", + "parameters": [ + { + "description": "The item identifier.", + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "minimum": 1, + "type": "integer" + } + }, + { + "description": "The user identifier.", + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutItemsItemIdWatchersUserIdResult" + } + } + }, + "description": "Add item watcher response." + }, + "401": { + "$ref": "#/components/responses/AuthenticationError" + }, + "403": { + "$ref": "#/components/responses/PermissionError" + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + }, + "429": { + "$ref": "#/components/responses/RateLimitError" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Add item watcher", + "tags": [ + "Work items" + ], + "x-required-scopes": [ + "items:write" + ] + } + }, "/items/{item_id}/worklogs": { "get": { "description": "List item worklogs. Results include only resources visible to the authenticated caller; collections use their documented stable order and bounds.", diff --git a/frontend/src/lib/api/items.js b/frontend/src/lib/api/items.js index 99572a28b..71e41fa28 100644 --- a/frontend/src/lib/api/items.js +++ b/frontend/src/lib/api/items.js @@ -382,6 +382,16 @@ export const items = { method: 'DELETE', }), getWatchStatus: (id, requestOptions = {}) => fetchV2Data(`/items/${id}/watch`, requestOptions), + // Everyone watching an item; add/remove return the updated watcher list. + listWatchers: (id) => fetchV2Data(`/items/${id}/watchers`), + addWatcher: (id, userId) => + fetchV2Data(`/items/${id}/watchers/${userId}`, { + method: 'PUT', + }), + removeWatcher: (id, userId) => + fetchV2Data(`/items/${id}/watchers/${userId}`, { + method: 'DELETE', + }), // Personal tasks relationship getPersonalTasks: (itemId, requestOptions = {}) => diff --git a/frontend/src/lib/features/items/ItemDetailSidebar.svelte b/frontend/src/lib/features/items/ItemDetailSidebar.svelte index 5b00427cf..b4a38a642 100644 --- a/frontend/src/lib/features/items/ItemDetailSidebar.svelte +++ b/frontend/src/lib/features/items/ItemDetailSidebar.svelte @@ -24,6 +24,7 @@ import RequesterOpenTickets from './RequesterOpenTickets.svelte'; import CustomerContextSection from './CustomerContextSection.svelte'; import ParticipantsSection from './ParticipantsSection.svelte'; + import WatchersSection from './WatchersSection.svelte'; import AddSCMLinkModal from '../../dialogs/AddSCMLinkModal.svelte'; import AddIntegrationLinkModal from '../../dialogs/AddIntegrationLinkModal.svelte'; import CreateBranchModal from '../../dialogs/CreateBranchModal.svelte'; @@ -1371,6 +1372,12 @@ {/if} + + {#if item?.id} + + {/if} + diff --git a/frontend/src/lib/features/items/WatchersSection.svelte b/frontend/src/lib/features/items/WatchersSection.svelte new file mode 100644 index 000000000..8c9d09c84 --- /dev/null +++ b/frontend/src/lib/features/items/WatchersSection.svelte @@ -0,0 +1,220 @@ + + +
+
+ +
+ {#if expanded && canEdit && watchers !== null} + + {/if} + +
+
+ + {#if !expanded} + + {:else if watchers === null && !loadFailed} +
+ +
+ {:else if watchers !== null} +
+ {#if watchers.length === 0} + {t('items.watchersEmpty')} + {/if} + {#each watchers as watcher (watcher.user_id)} + {@const isSelf = watcher.user_id === currentUserId} +
+
+ + + {displayName(watcher)} + {#if isSelf} + {t('items.watcherYou')} + {/if} + +
+ {#if canEdit || isSelf} + + {/if} +
+ {/each} +
+ + {#if canEdit && showAddForm} +
+ (showAddForm = false)} + /> + +
+ {/if} + {/if} +
diff --git a/frontend/src/lib/locales/de/workspace.js b/frontend/src/lib/locales/de/workspace.js index fccf3bc39..cd22de69a 100644 --- a/frontend/src/lib/locales/de/workspace.js +++ b/frontend/src/lib/locales/de/workspace.js @@ -260,6 +260,14 @@ export default { copyWorkItem: 'Vorgang kopieren', unwatchWorkItem: 'Vorgang nicht mehr beobachten', watchWorkItem: 'Vorgang beobachten', + watchers: 'Beobachter', + watchersEmpty: 'Noch niemand beobachtet diesen Vorgang.', + watcherAdd: 'Beobachter hinzufügen', + watcherRemove: 'Beobachter entfernen', + watcherYou: '(du)', + watcherPickUser: 'Benutzer zum Hinzufügen auswählen', + watchersAddFailed: 'Beobachter konnte nicht hinzugefügt werden', + watchersRemoveFailed: 'Beobachter konnte nicht entfernt werden', noSubIssueTypes: 'Keine Untereintragstypen verfügbar', cannotCreateChildItems: 'Für diese Hierarchieebene können keine untergeordneten Vorgänge erstellt werden.', workItems: 'Vorgänge', diff --git a/frontend/src/lib/locales/en/workspace.js b/frontend/src/lib/locales/en/workspace.js index aefaf495e..21125639c 100644 --- a/frontend/src/lib/locales/en/workspace.js +++ b/frontend/src/lib/locales/en/workspace.js @@ -437,6 +437,14 @@ export default { copyWorkItem: 'Copy Work Item', unwatchWorkItem: 'Unwatch Work Item', watchWorkItem: 'Watch Work Item', + watchers: 'Watchers', + watchersEmpty: 'Nobody is watching this item yet.', + watcherAdd: 'Add watcher', + watcherRemove: 'Remove watcher', + watcherYou: '(you)', + watcherPickUser: 'Select a user to add', + watchersAddFailed: 'Failed to add watcher', + watchersRemoveFailed: 'Failed to remove watcher', noSubIssueTypes: 'No sub-issue types available', cannotCreateChildItems: 'Cannot create child work items for this item level.', diff --git a/frontend/src/lib/locales/fr/workspace.js b/frontend/src/lib/locales/fr/workspace.js index 9c3976a67..25fffb5ac 100644 --- a/frontend/src/lib/locales/fr/workspace.js +++ b/frontend/src/lib/locales/fr/workspace.js @@ -329,6 +329,14 @@ export default { copyWorkItem: 'Copier l’élément de travail', unwatchWorkItem: 'Ne plus suivre l’élément de travail', watchWorkItem: 'Suivre l’élément de travail', + watchers: 'Observateurs', + watchersEmpty: 'Personne ne suit encore cet élément.', + watcherAdd: 'Ajouter un observateur', + watcherRemove: 'Retirer l’observateur', + watcherYou: '(vous)', + watcherPickUser: 'Sélectionner un utilisateur à ajouter', + watchersAddFailed: 'Impossible d’ajouter l’observateur', + watchersRemoveFailed: 'Impossible de retirer l’observateur', noSubIssueTypes: 'Aucun type de sous-demande disponible', cannotCreateChildItems: 'Impossible de créer des éléments de travail enfants pour ce niveau d’élément.', diff --git a/frontend/src/lib/locales/ru/workspace.js b/frontend/src/lib/locales/ru/workspace.js index 9dd3a6f7a..1f2e4e130 100644 --- a/frontend/src/lib/locales/ru/workspace.js +++ b/frontend/src/lib/locales/ru/workspace.js @@ -42,7 +42,7 @@ export default { timeTracking: 'Учёт времени', details: 'Сведения', agentLog: 'Журнал агента', agentLogEmpty: 'Для этого элемента ещё не было запусков агента', agentLogTriggeredBy: 'запустил', agentLogWaiting: 'Ожидание результата…', agentRunSingular: 'запуск', agentRunPlural: 'запуски', agentRerunLabel: 'Запустить агента снова', agentRerunStarting: 'Запуск…', agentRerunBusy: 'Выполняется запуск', agentRerunTitle: 'Снова запустить агента для этого элемента', agentRerunFailed: 'Не удалось начать новый запуск', agentReviewFlagTitle: 'Требуется проверка человеком', agentReviewFlagBody: 'Агент неверно использовал инструмент и не смог исправиться — результат может быть ненадёжным.', created: 'Создан', lastUpdated: 'Последнее обновление', healthOverview: 'Состояние элемента', healthOverviewDescription: 'Признаки на основе недавней активности, срока и истории статусов.', activityHealthCompleted: 'Завершено', activityHealthUnknown: 'Неизвестно', activityHealthStale: 'Нет активности', activityHealthActive: 'Активно', activityMonitoringComplete: 'Отслеживание активности завершено', activityToday: 'Активность сегодня', activityIdleDays: 'Без активности: {count} дн.', activityIdleDays_one: 'Без активности {count} день', activityIdleDays_few: 'Без активности {count} дня', activityIdleDays_many: 'Без активности {count} дней', activityIdleDays_other: 'Без активности {count} дня', lastActivityAt: 'Последняя активность: {date}', activityUnavailable: 'Время последней активности неизвестно', dueHealthCompleted: 'Завершено', dueHealthUnscheduled: 'Не запланировано', dueHealthOverdue: 'Просрочено', dueHealthToday: 'Срок сегодня', dueHealthSoon: 'Срок скоро', dueHealthScheduled: 'Запланировано', workCompleted: 'Работа завершена', completedOn: 'Завершено {date}', dueOn: 'Срок: {date}', dueDateUnavailable: 'Срок не указан', timeInStatus: 'Время в статусе', unknown: 'Неизвестно', statusSince: 'В этом статусе с {date}', statusHistoryUnavailable: 'Данные о времени в статусах недоступны', statusDurationsDescription: 'Общее календарное время, накопленное в каждом статусе.', statusDurationsLoading: 'Расчёт времени в статусах…', statusDurationsLoadError: 'Не удалось рассчитать время в статусах.', statusDurationsRetry: 'Повторить', statusDurationsEmpty: 'Истории статусов пока нет.', currentStatus: 'Текущий', durationLessThanMinute: '< 1 мин.', timeline: 'Хронология', by: 'от', workItemInformation: 'Сведения о рабочем элементе', id: 'ID', type: 'Тип', workItem: 'Рабочий элемент', noProjectConfigured: 'Проект учёта времени не настроен', setDefaultProject: 'Чтобы записывать время, задайте проект по умолчанию в настройках рабочего пространства или элемента', timeEntries: 'Записи времени', startTimer: 'Запустить таймер', logTime: 'Записать время', startTimerTitle: 'Начать учитывать время для этого элемента', logTimeTitle: 'Вручную записать время работы над элементом', noTimeLogged: 'Время пока не записано', logged: 'записано', loggedOf: 'записано из', estimated: 'оценено', includeChildItems: 'Учитывать дочерние элементы', rollupTruncated: 'Учитываются только первые {max} дочерних элементов', rollupItemCount: 'Сумма по элементам: {count}', rollupItemCount_one: 'Сумма по {count} элементу', rollupItemCount_few: 'Сумма по {count} элементам', rollupItemCount_many: 'Сумма по {count} элементам', rollupItemCount_other: 'Сумма по {count} элемента', workItemDetails: 'Сведения о рабочем элементе', fullDetails: 'Все сведения', errorLoadingWorkItem: 'Не удалось загрузить рабочий элемент', workItemNotFound: 'Рабочий элемент не найден', timerBusy: 'Таймер занят', timerSyncingMessage: 'Таймер синхронизируется. Подождите и повторите попытку.', timerAlreadyRunning: 'Таймер уже запущен', stopTimerFirst: 'Остановите текущий таймер перед запуском нового.', workingOn: 'Работа над «{title}»', failedToStartTimer: 'Не удалось запустить таймер', failedToSaveTimeEntry: 'Не удалось сохранить запись времени', failedToDeleteTimeEntry: 'Не удалось удалить запись времени', deleteTimeEntry: 'Удалить запись времени', deleteTimeEntryConfirm: 'Удалить эту запись времени? Это действие нельзя отменить.', noDescription: 'Без описания', itemCopiedAs: 'Рабочий элемент скопирован как {key}', clickToViewCopied: 'Нажмите, чтобы открыть копию', failedToCopy: 'Не удалось скопировать элемент', moveWorkspaceMenu: 'Переместить в рабочее пространство', moveWorkspaceTitle: 'Переместить «{title}»', moveWorkspaceSubtitle: 'Выберите рабочее пространство и проверьте, как будут перенесены поля.', moveWorkspaceDestination: 'Целевое рабочее пространство', moveWorkspaceDestinationPlaceholder: 'Выберите рабочее пространство…', moveWorkspaceLoadingPreview: 'Подготовка предпросмотра перемещения…', moveWorkspaceNewKeyHint: 'Следующий свободный номер в целевом пространстве будет назначен при подтверждении.', moveWorkspaceChildrenDetached: 'Дочерние элементы станут корневыми в исходном пространстве: {count}', moveWorkspaceDeadKeyWarning: 'Ключ {key} перестанет работать и никогда не будет использован повторно.', moveWorkspaceNoPriority: 'Без приоритета', moveWorkspaceConfirm: 'Переместить элемент', moveWorkspaceCancel: 'Отмена', moveWorkspaceMoving: 'Перемещение…', moveWorkspaceSuccess: 'Элемент перемещён как {key}', moveWorkspaceLoadError: 'Не удалось загрузить целевые рабочие пространства', moveWorkspacePreviewError: 'Не удалось создать предпросмотр перемещения', moveWorkspaceError: 'Не удалось переместить элемент', mergeMenu: 'Объединить дубликат с этим', mergeTitle: 'Объединить дубликат с этой заявкой', mergeDuplicateLabel: 'Дублирующая заявка', mergeHelp: 'Найдите дубликат заявки. Его комментарии, вложения и переписка переедут сюда, а на дубликате появится ссылка на эту заявку.', mergeSelfError: 'Заявку нельзя объединить с самой собой', mergeConfirm: 'Объединить', mergeWorking: 'Объединение…', mergeFailed: 'Не удалось объединить дубликат', mergeSuccess: 'Дубликат объединён с этой заявкой', splitMenu: 'Выделить в подзадачу', splitTitle: 'Выделить в подзадачу', splitNewTitleLabel: 'Заголовок новой подзадачи', splitNewTitlePlaceholder: 'О чём это продолжение?', splitSelectComments: 'Выберите комментарии для переноса', splitNoComments: 'Нет комментариев для выделения', splitTitleRequired: 'Укажите заголовок новой подзадачи', splitSelectionRequired: 'Выберите хотя бы один комментарий', splitConfirm: 'Создать подзадачу', splitWorking: 'Создание…', splitFailed: 'Не удалось выделить подзадачу', splitSuccess: 'Подзадача создана', splitLoadError: 'Не удалось загрузить комментарии', mergedBanner: 'Эта заявка была объединена с другой заявкой.', mergedBannerOpen: 'Открыть основную заявку', deleteWorkItem: 'Удалить рабочий элемент', confirmDeleteItem: 'Удалить «{title}»? Это действие нельзя отменить.', failedToDelete: 'Не удалось удалить элемент', - deleteItemWithChildren: 'Удалить элемент с дочерними', itemHasChildren: 'У элемента есть дочерние элементы: {count}.', itemHasChildrenSingular: 'У элемента есть 1 дочерний элемент.', deleteAllOption: 'Удалить всё ({count} элементов)', deleteAllDescription: 'Безвозвратно удалить этот элемент и всех его потомков', reparentOption: 'Переместить дочерние элементы', reparentDescription: 'Переместить дочерние элементы на уровень выше, затем удалить только этот элемент', typeToConfirm: 'Введите «{title}», чтобы подтвердить удаление', confirmationPlaceholder: 'Введите название для подтверждения…', deleteAllItems: 'Удалить все элементы', reparentAndDelete: 'Переместить дочерние и удалить', reparentFailed: 'Не удалось переместить дочерние элементы', cascadeDeleteFailed: 'Не удалось удалить дерево элементов', deletedItemsCount: 'Удалено элементов: {count}', reparentedAndDeleted: 'Дочерние элементы перемещены, элемент удалён', selectNewParent: 'Куда переместить дочерние элементы', selectNewParentPlaceholder: 'Выберите родительский элемент…', makeRootItem: 'Сделать корневыми элементами (без родителя)', reparentLevelHint: 'Показаны только элементы того же уровня иерархии', noOtherItemsAtLevel: 'На этом уровне нет других элементов — выберите «Сделать корневыми элементами» или вариант выше', reparentToGrandparent: 'Дочерние элементы будут перемещены к родителю удаляемого элемента', childrenWillBecomeRoot: 'Дочерние элементы станут корневыми', failedToUpdateWatchStatus: 'Не удалось изменить статус отслеживания', copyWorkItem: 'Копировать рабочий элемент', unwatchWorkItem: 'Перестать отслеживать', watchWorkItem: 'Отслеживать рабочий элемент', noSubIssueTypes: 'Нет доступных типов подзадач', cannotCreateChildItems: 'Для элемента этого уровня нельзя создавать дочерние рабочие элементы.', + deleteItemWithChildren: 'Удалить элемент с дочерними', itemHasChildren: 'У элемента есть дочерние элементы: {count}.', itemHasChildrenSingular: 'У элемента есть 1 дочерний элемент.', deleteAllOption: 'Удалить всё ({count} элементов)', deleteAllDescription: 'Безвозвратно удалить этот элемент и всех его потомков', reparentOption: 'Переместить дочерние элементы', reparentDescription: 'Переместить дочерние элементы на уровень выше, затем удалить только этот элемент', typeToConfirm: 'Введите «{title}», чтобы подтвердить удаление', confirmationPlaceholder: 'Введите название для подтверждения…', deleteAllItems: 'Удалить все элементы', reparentAndDelete: 'Переместить дочерние и удалить', reparentFailed: 'Не удалось переместить дочерние элементы', cascadeDeleteFailed: 'Не удалось удалить дерево элементов', deletedItemsCount: 'Удалено элементов: {count}', reparentedAndDeleted: 'Дочерние элементы перемещены, элемент удалён', selectNewParent: 'Куда переместить дочерние элементы', selectNewParentPlaceholder: 'Выберите родительский элемент…', makeRootItem: 'Сделать корневыми элементами (без родителя)', reparentLevelHint: 'Показаны только элементы того же уровня иерархии', noOtherItemsAtLevel: 'На этом уровне нет других элементов — выберите «Сделать корневыми элементами» или вариант выше', reparentToGrandparent: 'Дочерние элементы будут перемещены к родителю удаляемого элемента', childrenWillBecomeRoot: 'Дочерние элементы станут корневыми', failedToUpdateWatchStatus: 'Не удалось изменить статус отслеживания', copyWorkItem: 'Копировать рабочий элемент', unwatchWorkItem: 'Перестать отслеживать', watchWorkItem: 'Отслеживать рабочий элемент', watchers: 'Наблюдатели', watchersEmpty: 'Этот элемент пока никто не отслеживает.', watcherAdd: 'Добавить наблюдателя', watcherRemove: 'Удалить наблюдателя', watcherYou: '(вы)', watcherPickUser: 'Выберите пользователя', watchersAddFailed: 'Не удалось добавить наблюдателя', watchersRemoveFailed: 'Не удалось удалить наблюдателя', noSubIssueTypes: 'Нет доступных типов подзадач', cannotCreateChildItems: 'Для элемента этого уровня нельзя создавать дочерние рабочие элементы.', workItems: 'Рабочие элементы', linkedTo: 'связан с', goToLinkedWorkItem: 'Перейти к связанному элементу', goTo: 'Перейти к «{title}»', noParent: 'Без родителя', setParent: 'Задать родителя', changeParent: 'Изменить родителя', searchForParentItem: 'Поиск родительского элемента…', showingItemsFromLevel: 'Показаны только элементы уровня {level}', oneLevelAbove: 'на уровень выше {name}', searchParentAcrossWorkspaces: 'Искать родительский элемент во всех пространствах', removeParent: 'Удалить родителя', noItemsAtLevel: 'На уровне иерархии {level} элементы не найдены', failedToUpdateParent: 'Не удалось изменить родителя', failedToRemoveParent: 'Не удалось удалить родителя', clickToCopyKey: 'Нажмите, чтобы скопировать ключ', hierarchyMismatchTitle: 'Элемент больше не соответствует своему родителю', hierarchyMismatchDescription: 'Тип {type} настроен на уровне {currentLevel}, а его существующий родитель также находится на уровне {parentLevel}. Связь сохранена, но для смены родителя теперь требуется родитель уровня {requiredLevel}. Выберите тип элемента уровня {compatibleLevel}, чтобы работать с текущим уровнем родителя.', chooseCompatibleType: 'Выбрать совместимый тип', parentPickerMismatchTitle: 'Текущая конфигурация иерархии отличается от сохранённой связи', parentPickerMismatchDescription: 'Тип {type} теперь принимает родителей уровня {requiredLevel}. Существующий родитель находится на уровне {currentParentLevel}; чтобы выбрать другого родителя этого уровня, сначала используйте тип элемента уровня {compatibleLevel}.', compatibleTypeHint: 'Тип уровня {compatibleLevel} соответствует текущему родителю уровня {parentLevel}. При смене типа существующие связи сохраняются.', hierarchyLevelLabel: 'Уровень иерархии {level}', genericSubtaskLevelLabel: 'Обычная подзадача · любой уровень родителя', genericSubtaskParentHint: 'Обычные подзадачи можно перемещать под любой стандартный элемент иерархии.', fitsCurrentParent: 'Соответствует текущему родителю', enterDescription: 'Введите описание…', clickToEditDescription: 'Нажмите, чтобы изменить описание', clickToAddDescription: 'Нажмите, чтобы добавить описание', noDescriptionProvided: 'Описание не указано — нажмите, чтобы добавить', addLink: 'Добавить связь', createChild: 'Создать дочерний элемент', child: 'Дочерний', attachFile: 'Прикрепить файл', attach: 'Прикрепить', newDiagram: 'Новая диаграмма', diagram: 'Диаграмма', previousValueRemains: 'Предыдущее значение не изменено', titleCannotBeEmpty: 'Название не может быть пустым', enterTitle: 'Введите название…', clickToEditTitle: 'Нажмите, чтобы изменить название', searchTestCases: 'Поиск тест-кейсов…', searchWorkItems: 'Поиск рабочих элементов…', loadingLinks: 'Загрузка связей…', removeLink: 'Удалить связь', linkType: 'Тип связи', chooseRelationshipType: 'Выберите тип отношения…', linkToTestCase: 'Связать элемент с тест-кейсом.', linkToPage: 'Связать элемент со страницей базы знаний в этом рабочем пространстве.', targetItem: 'Целевой элемент', targetPage: 'Целевая страница', testCase: 'Тест-кейс', page: 'Страница', linkedPages: 'Страницы', pagePickerPlaceholder: 'Поиск страниц…', selectLinkTypeToSearch: 'Выберите тип связи, чтобы начать поиск.', childWorkItems: 'Дочерние рабочие элементы', loadingChildItems: 'Загрузка дочерних элементов…', diff --git a/frontend/src/lib/locales/zh-CN/workspace.js b/frontend/src/lib/locales/zh-CN/workspace.js index 5b91b4aed..fab25c02b 100644 --- a/frontend/src/lib/locales/zh-CN/workspace.js +++ b/frontend/src/lib/locales/zh-CN/workspace.js @@ -257,6 +257,14 @@ export default { copyWorkItem: '复制工作项', unwatchWorkItem: '取消关注工作项', watchWorkItem: '关注工作项', + watchers: '关注者', + watchersEmpty: '还没有人关注此工作项。', + watcherAdd: '添加关注者', + watcherRemove: '移除关注者', + watcherYou: '(你)', + watcherPickUser: '选择要添加的用户', + watchersAddFailed: '添加关注者失败', + watchersRemoveFailed: '移除关注者失败', noSubIssueTypes: '没有可用的子事项类型', cannotCreateChildItems: '无法为此事项层级创建子工作项。', workItems: '工作项', diff --git a/frontend/src/lib/mobile/MobileItemDetail.svelte b/frontend/src/lib/mobile/MobileItemDetail.svelte index 44c4caa26..25a24a2ee 100644 --- a/frontend/src/lib/mobile/MobileItemDetail.svelte +++ b/frontend/src/lib/mobile/MobileItemDetail.svelte @@ -31,6 +31,7 @@ let transitions = $state([]); let transitioning = $state(false); let isWatching = $state(false); + let watcherCount = $state(0); let watchBusy = $state(false); // Workflow-less personal tasks use the permitted transition endpoint to toggle @@ -114,6 +115,7 @@ item = summary?.item ?? null; transitions = summary?.transitions?.available_transitions ?? []; isWatching = summary?.watching || false; + watcherCount = summary?.watcher_count ?? 0; personalTaskCount = summary?.personal_task_count ?? 0; const childItems = Array.isArray(summary?.children) ? summary.children : []; children = childItems.filter((child) => child?.id).map(normalizeChild); @@ -206,13 +208,11 @@ if (watchBusy) return; watchBusy = true; try { - if (isWatching) { - await api.items.removeWatch(itemId); - isWatching = false; - } else { - await api.items.addWatch(itemId); - isWatching = true; - } + const status = isWatching + ? await api.items.removeWatch(itemId) + : await api.items.addWatch(itemId); + isWatching = status?.watching ?? !isWatching; + watcherCount = status?.watcher_count ?? watcherCount; } catch (err) { console.error('Failed to toggle watch:', err); } finally { @@ -268,6 +268,7 @@ item = null; transitions = []; isWatching = false; + watcherCount = 0; personalTaskCount = 0; children = []; ancestors = []; @@ -322,7 +323,10 @@ } catch { /* keep prior */ } try { const res = await api.items.getWatchStatus(itemId); - if (token === loadToken) isWatching = res?.watching || false; + if (token === loadToken) { + isWatching = res?.watching || false; + watcherCount = res?.watcher_count ?? watcherCount; + } } catch { /* keep prior */ } } @@ -507,6 +511,9 @@ {#if canStartTimer}