Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion frontend/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { locale } from '../i18n'

export class ApiError extends Error {
constructor(
public readonly status: number,
Expand All @@ -10,7 +12,10 @@ export class ApiError extends Error {
}
}

export function apiErrorMessage(payload: unknown, fallback = '请求失败,请稍后重试'): string {
export function apiErrorMessage(
payload: unknown,
fallback = locale.value === 'en' ? 'Request failed, please try again later' : '请求失败,请稍后重试',
): string {
if (
payload
&& typeof payload === 'object'
Expand Down
12 changes: 7 additions & 5 deletions frontend/src/api/plugin-stream.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { locale } from '../i18n'
import { apiErrorMessage, dispatchAuthExpired } from './client'

function consumeEvent(block: string, onDelta: (text: string) => void): boolean {
const lines = block.split('\n')
const event = lines.find((line) => line.startsWith('event:'))?.slice(6).trim() ?? 'message'
Expand All @@ -11,7 +14,7 @@ function consumeEvent(block: string, onDelta: (text: string) => void): boolean {
try {
payload = JSON.parse(data)
} catch {
throw new Error('AI 工作简报返回格式无效')
throw new Error(locale.value === 'en' ? 'Invalid AI work brief response format' : 'AI 工作简报返回格式无效')
}
if (event === 'delta') {
if (payload && typeof payload === 'object' && 'text' in payload && typeof payload.text === 'string') {
Expand All @@ -23,12 +26,11 @@ function consumeEvent(block: string, onDelta: (text: string) => void): boolean {
if (payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string') {
throw new Error(payload.message)
}
throw new Error('AI 工作简报生成失败,请稍后重试')
throw new Error(locale.value === 'en' ? 'Failed to generate AI work brief, please try again later' : 'AI 工作简报生成失败,请稍后重试')
}
return event === 'done'
}

import { apiErrorMessage, dispatchAuthExpired } from './client'
export async function streamPluginAction(
actionId: string,
onDelta: (text: string) => void,
Expand All @@ -44,9 +46,9 @@ export async function streamPluginAction(
if (!response.ok) {
const payload = await response.json().catch(() => null)
if (response.status === 401) dispatchAuthExpired()
throw new Error(apiErrorMessage(payload, 'AI 工作简报请求失败,请稍后重试'))
throw new Error(apiErrorMessage(payload, locale.value === 'en' ? 'AI work brief request failed, please try again later' : 'AI 工作简报请求失败,请稍后重试'))
}
if (!response.body) throw new Error('AI 工作简报未返回流式内容')
if (!response.body) throw new Error(locale.value === 'en' ? 'AI work brief returned no stream content' : 'AI 工作简报未返回流式内容')

const reader = response.body.getReader()
const decoder = new TextDecoder()
Expand Down
37 changes: 20 additions & 17 deletions frontend/src/components/ActionEditDrawer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import { api } from '../api/client'
import type { UserRef } from '../api/contracts'
import type { ActionPriority, ActionStatus } from '../domain/outcomes'
import type { ProjectActionSummary } from '../domain/projects'
import { useI18n } from '../i18n'
import { errorMessage } from '../utils/errors'
import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../utils/labels'
import { ACTION_STATUS_LABELS, PRIORITY_LABELS, actionStatusLabel, priorityLabel } from '../utils/labels'

const { tr } = useI18n()

const props = defineProps<{
show: boolean
Expand All @@ -30,10 +33,10 @@ const memberOptions = computed(() => props.members.map((member) => ({
label: member.display_name || member.username,
value: member.id,
})))
const priorityOptions = (Object.keys(PRIORITY_LABELS) as ActionPriority[])
.map((value) => ({ label: PRIORITY_LABELS[value], value }))
const statusOptions = (Object.keys(ACTION_STATUS_LABELS) as ActionStatus[])
.map((value) => ({ label: ACTION_STATUS_LABELS[value], value }))
const priorityOptions = computed(() => (Object.keys(PRIORITY_LABELS) as ActionPriority[])
.map((value) => ({ label: priorityLabel(value), value })))
const statusOptions = computed(() => (Object.keys(ACTION_STATUS_LABELS) as ActionStatus[])
.map((value) => ({ label: actionStatusLabel(value), value })))

watch(() => [props.show, props.action?.id], () => {
const action = props.action
Expand Down Expand Up @@ -72,7 +75,7 @@ async function save() {
})
emit('saved', saved)
} catch (reason) {
error.value = errorMessage(reason, '行动项保存失败')
error.value = errorMessage(reason, tr('行动项保存失败', 'Failed to save action item'))
} finally {
saving.value = false
}
Expand All @@ -87,41 +90,41 @@ async function save() {
:mask-closable="!saving"
@update:show="(value: boolean) => { if (!value) close() }"
>
<NDrawerContent title="编辑行动项" closable>
<NDrawerContent :title="tr('编辑行动项', 'Edit Action Item')" closable>
<NForm label-placement="top" :show-require-mark="false">
<NFormItem label="行动项内容">
<NInput v-model:value="form.content" type="textarea" :autosize="{ minRows: 3 }" :input-props="{ 'aria-label': '行动项内容' }" />
<NFormItem :label="tr('行动项内容', 'Action Content')">
<NInput v-model:value="form.content" type="textarea" :autosize="{ minRows: 3 }" :input-props="{ 'aria-label': tr('行动项内容', 'Action Content') }" />
</NFormItem>
<NFormItem label="负责人">
<NFormItem :label="tr('负责人', 'Owner')">
<NSelect
v-model:value="form.owner_user_id"
class="action-edit-owner"
:options="memberOptions"
:virtual-scroll="false"
clearable
placeholder="选择负责人"
:placeholder="tr('选择负责人', 'Select owner')"
/>
</NFormItem>
<NFormItem label="截止日期">
<input v-model="form.due_date" class="native-date-input" type="date" aria-label="截止日期" />
<NFormItem :label="tr('截止日期', 'Due Date')">
<input v-model="form.due_date" class="native-date-input" type="date" :aria-label="tr('截止日期', 'Due Date')" />
</NFormItem>
<NFormItem label="优先级">
<NFormItem :label="tr('优先级', 'Priority')">
<NSelect v-model:value="form.priority" class="action-edit-priority" :options="priorityOptions" :virtual-scroll="false" />
</NFormItem>
<NFormItem label="状态">
<NFormItem :label="tr('状态', 'Status')">
<NSelect v-model:value="form.status" class="action-edit-status" :options="statusOptions" :virtual-scroll="false" />
</NFormItem>
</NForm>
<p v-if="error" class="notice notice-error" role="alert">{{ error }}</p>
<template #footer>
<NButton quaternary :disabled="saving" @click="close">取消</NButton>
<NButton quaternary :disabled="saving" @click="close">{{ tr('取消', 'Cancel') }}</NButton>
<NButton
type="primary"
:loading="saving"
:disabled="saving || !form.content.trim()"
@click="save"
>
保存行动项
{{ tr('保存行动项', 'Save Action Item') }}
</NButton>
</template>
</NDrawerContent>
Expand Down
20 changes: 10 additions & 10 deletions frontend/src/components/AgendaDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ async function persistIfDirty(expectedVersion = currentVersion.value): Promise<b
// Fallback to conflict details and current props if fetching latest meeting fails.
}
conflict.value = { version: actualVersion, server: serverNotes }
} else error.value = errorMessage(caught, '议题保存失败')
} else error.value = errorMessage(caught, tr('议题保存失败', 'Failed to save agenda item'))
throw caught
} finally {
saving.value = false
Expand Down Expand Up @@ -283,7 +283,7 @@ async function smartExtractNotes() {
: `✨ 已智能提炼 ${decisions} 项决策 · ${actions} 项行动 · ${questions} 项开放问题`
emit('changed')
} catch (caught) {
error.value = errorMessage(caught, 'AI 提炼失败,请检查议题记录格式')
error.value = errorMessage(caught, tr('AI 提炼失败,请检查议题记录格式', 'AI extraction failed, please check notes format'))
} finally {
extractingNotes.value = false
}
Expand All @@ -306,7 +306,7 @@ async function complete() {
})
emit('advance', result.next_agenda_item_id)
} catch (caught) {
error.value = errorMessage(caught, '议题状态更新失败')
error.value = errorMessage(caught, tr('议题状态更新失败', 'Failed to update agenda status'))
} finally {
saving.value = false
}
Expand All @@ -322,7 +322,7 @@ async function submitMigrate() {
if (migrateSaving.value) return
const target = props.meeting.agenda_items.find((row) => row.id === migrateTargetId.value)
if (!target) {
migrateError.value = '请选择目标议题'
migrateError.value = tr('请选择目标议题', 'Please select a target agenda item')
return
}
migrateSaving.value = true
Expand All @@ -341,7 +341,7 @@ async function submitMigrate() {
migrateOpen.value = false
emit('changed')
} catch (caught) {
migrateError.value = errorMessage(caught, '产出迁移失败')
migrateError.value = errorMessage(caught, tr('产出迁移失败', 'Failed to migrate outcomes'))
} finally {
migrateSaving.value = false
}
Expand All @@ -361,7 +361,7 @@ async function convertToQuestion() {
})
emit('changed')
} catch (caught) {
error.value = errorMessage(caught, '转为开放问题失败')
error.value = errorMessage(caught, tr('转为开放问题失败', 'Failed to convert to open question'))
} finally {
converting.value = false
}
Expand All @@ -376,7 +376,7 @@ async function openCopy() {
const page = await api<Page<MeetingOption>>(`/api/meetings?project_id=${props.meeting.project.id}&limit=200`)
copyOptions.value = (page.items ?? []).filter((row) => row.id !== props.meeting.id)
} catch (caught) {
copyError.value = errorMessage(caught, '目标会议加载失败')
copyError.value = errorMessage(caught, tr('目标会议加载失败', 'Failed to load target meetings'))
} finally {
copyLoading.value = false
}
Expand All @@ -385,7 +385,7 @@ async function openCopy() {
async function submitCopy() {
if (copySaving.value) return
if (!copyTargetId.value) {
copyError.value = '请选择目标会议'
copyError.value = tr('请选择目标会议', 'Please select a target meeting')
return
}
copySaving.value = true
Expand All @@ -408,7 +408,7 @@ async function submitCopy() {
copyOpen.value = false
emit('changed')
} catch (caught) {
copyError.value = errorMessage(caught, '议题复制失败')
copyError.value = errorMessage(caught, tr('议题复制失败', 'Failed to copy agenda item'))
} finally {
copySaving.value = false
}
Expand All @@ -431,7 +431,7 @@ async function toggleActionStatus(action: ActionItem, newStatus?: ActionStatus)
action.version = updated.version
emit('changed')
} catch (caught) {
error.value = errorMessage(caught, '更新行动项失败')
error.value = errorMessage(caught, tr('更新行动项失败', 'Failed to update action item'))
}
}

Expand Down
18 changes: 9 additions & 9 deletions frontend/src/components/AgendaQueue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ async function dropOn(targetId: string) {
emit('changed')
} catch (caught) {
ordered.value = previous
error.value = errorMessage(caught, '议题排序失败')
error.value = errorMessage(caught, tr('议题排序失败', 'Failed to reorder agenda items'))
emit('changed')
} finally {
saving.value = false
Expand All @@ -91,7 +91,7 @@ async function addAgenda() {
emit('select', item.id)
emit('changed')
} catch (caught) {
error.value = errorMessage(caught, '议题添加失败')
error.value = errorMessage(caught, tr('议题添加失败', 'Failed to add agenda item'))
} finally {
saving.value = false
}
Expand All @@ -106,7 +106,7 @@ async function command(item: AgendaItem, action: 'cancel') {
guardedId.value = ''
emit('changed')
} catch (caught) {
error.value = errorMessage(caught, '议题操作失败')
error.value = errorMessage(caught, tr('议题操作失败', 'Agenda action failed'))
} finally {
saving.value = false
}
Expand All @@ -121,7 +121,7 @@ async function skip(item: AgendaItem) {
skipTargetId.value = ''
emit('changed')
} catch (caught) {
error.value = errorMessage(caught, '议题操作失败')
error.value = errorMessage(caught, tr('议题操作失败', 'Agenda action failed'))
} finally {
saving.value = false
}
Expand All @@ -140,8 +140,8 @@ async function remove(item: AgendaItem) {
} catch (caught) {
if (caught instanceof ApiError && caught.code === 'agenda_has_outcomes') {
guardedId.value = item.id
error.value = '议题已有产出,请先迁移产出,或将议题标记为取消。'
} else error.value = errorMessage(caught, '议题删除失败')
error.value = tr('议题已有产出,请先迁移产出,或将议题标记为取消。', 'This agenda item already has outcomes. Please migrate its outcomes or cancel the item first.')
} else error.value = errorMessage(caught, tr('议题删除失败', 'Failed to delete agenda item'))
} finally {
saving.value = false
}
Expand All @@ -158,7 +158,7 @@ async function openMove(item: AgendaItem) {
const page = await api<Page<MeetingOption>>(`/api/meetings?project_id=${props.meeting.project.id}&limit=200`)
moveOptions.value = (page.items ?? []).filter((row) => row.id !== props.meeting.id)
} catch (caught) {
moveError.value = errorMessage(caught, '目标会议加载失败')
moveError.value = errorMessage(caught, tr('目标会议加载失败', 'Failed to load target meetings'))
} finally {
moveLoading.value = false
}
Expand All @@ -167,7 +167,7 @@ async function openMove(item: AgendaItem) {
async function submitMove() {
if (!moveTarget.value || moveSaving.value) return
if (!moveForm.value.target_meeting_id) {
moveError.value = '请选择目标会议'
moveError.value = tr('请选择目标会议', 'Please select a target meeting')
return
}
moveSaving.value = true
Expand All @@ -192,7 +192,7 @@ async function submitMove() {
moveTarget.value = null
emit('changed')
} catch (caught) {
moveError.value = errorMessage(caught, '议题移动失败')
moveError.value = errorMessage(caught, tr('议题移动失败', 'Failed to move agenda item'))
} finally {
moveSaving.value = false
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/AgendaWorkbench.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async function openAgenda(itemId: string) {
selectedId.value = itemId
emit('reload')
} catch (caught) {
openError.value = errorMessage(caught, '议题开始失败')
openError.value = errorMessage(caught, tr('议题开始失败', 'Failed to start agenda item'))
} finally {
openingId.value = ''
}
Expand Down
Loading
Loading