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
Binary file modified docs/assets/demo_meetflow.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 5 additions & 4 deletions frontend/src/api/meetings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri
return [
`### ${title} · 讨论要点`,
`@决策: 确认「${title}」核心实施方案与演进路径`,
`@行动:[open] 完成「${title}」接口联调与自动化回归测试`,
`@行动:[待办] 完成「${title}」接口联调与自动化回归测试`,
`@开放问题: 「${title}」存量数据迁移窗口是否需要灰度验证?`,
].join('\n')
}
Expand All @@ -108,6 +108,7 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri

const decPrefix = useEn ? '@Decision:' : '@决策:'
const actPrefix = useEn ? '@Action:' : '@行动:'
const actStatus = useEn ? '[open]' : '[待办]'
const qPrefix = useEn ? '@Question:' : '@开放问题:'

for (const originalLine of lines) {
Expand Down Expand Up @@ -160,7 +161,7 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri
if (content) {
const ownerMatch = content.match(/@([a-zA-Z0-9_-]+)/)
const ownerTag = ownerMatch ? `[@${ownerMatch[1]}]` : ''
const tag = `${actPrefix}[open]${ownerTag} ${content}`
const tag = `${actPrefix}${actStatus}${ownerTag} ${content}`
if (!existingTags.has(tag)) extractedTags.push(tag)
}
} else if (
Expand All @@ -185,7 +186,7 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri
if (content) {
const ownerMatch = content.match(/@([a-zA-Z0-9_-]+)/)
const ownerTag = ownerMatch ? `[@${ownerMatch[1]}]` : ''
const tag = `${actPrefix}[open]${ownerTag} ${content}`
const tag = `${actPrefix}${actStatus}${ownerTag} ${content}`
if (!existingTags.has(tag)) extractedTags.push(tag)
}
}
Expand Down Expand Up @@ -213,7 +214,7 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri
'',
`@决策: 确认推进「${title}」方案落地`,
'',
`@行动:[open] 跟进「${title}」后续实施与联调验证`,
`@行动:[待办] 跟进「${title}」后续实施与联调验证`,
'',
`@开放问题: 「${title}」跨模块边界细节待最终确认`,
].join('\n')
Expand Down
48 changes: 32 additions & 16 deletions frontend/src/components/AgendaDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { NButton, NDrawer, NDrawerContent, NInputNumber, NPopconfirm, NSelect, NTag } from 'naive-ui'
import StatusPill from './StatusPill.vue'
import { errorMessage } from '../utils/errors'
import { errorMessage, translateBackendError } from '../utils/errors'

import { api, ApiError } from '../api/client'
import type { Page, UserRef } from '../api/contracts'
Expand All @@ -20,7 +20,7 @@ import VersionConflictDialog from './VersionConflictDialog.vue'
import { useI18n } from '../i18n'
import { formatDateTime, formatDuration, parseUtcTimestamp } from '../utils/time'

const { locale, tr } = useI18n()
const { locale, tr, translateSeededText, translateNotesMarkdown } = useI18n()
const props = defineProps<{ meeting: Meeting; item: AgendaItem; canContribute: boolean }>()
const emit = defineEmits<{ changed: []; advance: [nextId: string | null] }>()
type AgendaAdvanceResult = { next_agenda_item_id: string | null }
Expand All @@ -32,9 +32,9 @@ type MeetingOption = { id: string; title: string; version: number }

function draftFor(item: AgendaItem): AgendaDetailDraft {
return {
title: item.title,
title: translateSeededText(item.title, locale.value),
agenda_type: item.agenda_type,
notes_markdown: item.notes_markdown,
notes_markdown: translateNotesMarkdown(item.notes_markdown, locale.value),
estimated_minutes: item.estimated_minutes,
proposer_user_id: item.proposer?.id ?? null,
presenter_user_id: item.presenter?.id ?? null,
Expand All @@ -54,7 +54,14 @@ const dirty = computed(() => draft.title !== accepted.value.title
const composer = ref<'decision' | 'action' | 'question' | null>(null)
const saving = ref(false)
const extractingNotes = ref(false)
const extractStatus = ref('')
const extractCounts = ref<{ decisions: number; actions: number; questions: number } | null>(null)
const extractStatus = computed(() => {
if (!extractCounts.value) return ''
const { decisions, actions, questions } = extractCounts.value
return locale.value === 'en'
? `✨ Extracted ${decisions} decisions · ${actions} actions · ${questions} open questions`
: `✨ 已智能提炼 ${decisions} 项决策 · ${actions} 项行动 · ${questions} 项开放问题`
})
const error = ref('')
const conflict = ref<{ version: number; server: string } | null>(null)
const projectMembers = ref<UserRef[]>([])
Expand Down Expand Up @@ -86,7 +93,7 @@ const memberSelectOptions = computed(() => {
for (const person of candidates) {
if (seen.has(person.id)) continue
seen.add(person.id)
options.push({ label: person.display_name, value: person.id })
options.push({ label: translateSeededText(person.display_name), value: person.id })
}
return options
})
Expand All @@ -101,7 +108,7 @@ const canConvert = computed(() => props.canContribute && !meetingLocked.value &&
const canCopy = computed(() => props.canContribute && !meetingLocked.value && props.item.status === 'skipped')
const migrateOptions = computed(() => props.meeting.agenda_items
.filter((row) => row.id !== props.item.id)
.map((row) => ({ label: row.title, value: row.id })))
.map((row) => ({ label: translateSeededText(row.title), value: row.id })))
const actualDurationText = computed(() => {
const seconds = props.item.actual_duration_seconds
if (props.item.status !== 'completed' || seconds === null || seconds === undefined) return ''
Expand Down Expand Up @@ -137,9 +144,20 @@ watch(() => props.item, (item) => {
if (switchedItem) {
attachmentItems.value = null
conflict.value = null
extractCounts.value = null
}
}, { deep: true })

watch(locale, (nextLocale) => {
if (error.value) error.value = translateBackendError(error.value)
if (migrateError.value) migrateError.value = translateBackendError(migrateError.value)
if (copyError.value) copyError.value = translateBackendError(copyError.value)
draft.title = translateSeededText(draft.title, nextLocale)
accepted.value.title = translateSeededText(accepted.value.title, nextLocale)
draft.notes_markdown = translateNotesMarkdown(draft.notes_markdown, nextLocale)
accepted.value.notes_markdown = translateNotesMarkdown(accepted.value.notes_markdown, nextLocale)
})

let clockHandle: number | undefined
onMounted(() => {
clockHandle = window.setInterval(() => { now.value = Date.now() }, 1_000)
Expand Down Expand Up @@ -250,7 +268,7 @@ async function save(expectedVersion = currentVersion.value) {
async function smartExtractNotes() {
if (!props.canContribute || meetingLocked.value || extractingNotes.value || saving.value) return
extractingNotes.value = true
extractStatus.value = ''
extractCounts.value = null
error.value = ''
try {
const flushed = typeof notesEditor.value?.flush === 'function' ? notesEditor.value.flush() : undefined
Expand Down Expand Up @@ -278,9 +296,7 @@ async function smartExtractNotes() {
const decisions = res.decisions ?? res.extracted?.decisions ?? 0
const actions = res.actions ?? res.extracted?.actions ?? 0
const questions = res.questions ?? res.extracted?.questions ?? 0
extractStatus.value = locale.value === 'en'
? `✨ Extracted ${decisions} decisions · ${actions} actions · ${questions} open questions`
: `✨ 已智能提炼 ${decisions} 项决策 · ${actions} 项行动 · ${questions} 项开放问题`
extractCounts.value = { decisions, actions, questions }
emit('changed')
} catch (caught) {
error.value = errorMessage(caught, tr('AI 提炼失败,请检查议题记录格式', 'AI extraction failed, please check notes format'))
Expand Down Expand Up @@ -543,7 +559,7 @@ const noteActionTags = computed(() => {
@mouseleave="hoveredOutcomeId = null"
>
<span>{{ decision.is_derived ? tr('来自议题记录', 'From Notes') : tr('决策', 'Decision') }}</span>
<strong>{{ decision.title }}</strong>
<strong>{{ translateSeededText(decision.title) }}</strong>
</article>

<article
Expand All @@ -552,7 +568,7 @@ const noteActionTags = computed(() => {
class="outcome-action-item"
:class="{
'action-item--done': action.status === 'done',
'linked-glow': hoveredOutcomeId === action.id || noteActionTags.includes(action.content)
'linked-glow': hoveredOutcomeId === action.id || noteActionTags.includes(action.content) || noteActionTags.includes(translateSeededText(action.content))
}"
@mouseenter="hoveredOutcomeId = action.id"
@mouseleave="hoveredOutcomeId = null"
Expand All @@ -570,9 +586,9 @@ const noteActionTags = computed(() => {
<span v-if="action.status === 'done'" class="action-check-icon"><Check :size="12" :stroke-width="3" /></span>
<span v-else-if="action.status === 'in_progress'" class="action-dot-icon"></span>
</button>
<strong :class="{ 'text-strikethrough': action.status === 'done' }">{{ action.content }}</strong>
<strong :class="{ 'text-strikethrough': action.status === 'done' }">{{ translateSeededText(action.content) }}</strong>
<div class="action-tags-inline">
<span v-if="action.owner" class="action-user-pill">@{{ action.owner.display_name }}</span>
<span v-if="action.owner" class="action-user-pill">@{{ translateSeededText(action.owner.display_name) }}</span>
<span v-if="action.due_date" class="action-date-pill">{{ tr('截止', 'Due') }} {{ action.due_date }}</span>
<button
type="button"
Expand All @@ -596,7 +612,7 @@ const noteActionTags = computed(() => {
@mouseleave="hoveredOutcomeId = null"
>
<span>{{ question.is_derived ? tr('来自议题记录', 'From Notes') : tr('问题', 'Question') }}</span>
<strong>{{ question.question_markdown }}</strong>
<strong>{{ translateSeededText(question.question_markdown) }}</strong>
</article>
<p v-if="!item.decisions.length && !item.actions.length && !item.open_questions.length" class="empty-inline">{{ tr('讨论结果会在这里形成可追踪的链条。', 'Decisions, actions, and questions captured during discussion appear here.') }}</p>
</div>
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/components/AgendaQueue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { computed, ref, watch, h } from 'vue'
import { MoreHorizontal } from '@lucide/vue'
import { NButton, NDrawer, NDrawerContent, NDropdown, NIcon, NInputNumber, NPopconfirm, NSelect } from 'naive-ui'
import { statusLabel } from '../utils/labels'
import { errorMessage } from '../utils/errors'
import { errorMessage, translateBackendError } from '../utils/errors'

import { api, ApiError } from '../api/client'
import type { Page } from '../api/contracts'
import type { AgendaItem, AgendaType, Meeting } from '../domain/meetings'
import { useI18n } from '../i18n'

const { tr } = useI18n()
const { locale, tr, translateSeededText } = useI18n()

type MeetingOption = { id: string; title: string; version: number }

Expand Down Expand Up @@ -45,6 +45,11 @@ watch(() => props.meeting.agenda_items, (items) => {
ordered.value = [...items].sort((a, b) => a.position - b.position)
}, { deep: true })

watch(locale, () => {
if (error.value) error.value = translateBackendError(error.value)
if (moveError.value) moveError.value = translateBackendError(moveError.value)
})

function startDrag(id: string) {
if (props.canContribute && !meetingLocked.value && !saving.value) draggingId.value = id
}
Expand Down Expand Up @@ -252,7 +257,7 @@ function onMenuSelect(item: AgendaItem, key: string) {
<p v-if="error || openError" class="notice notice-error">{{ error || openError }}</p>
<div class="agenda-queue-list">
<article v-for="(item, index) in ordered" :key="item.id" :data-testid="`agenda-row-${item.id}`" class="agenda-queue-row" :class="[{ selected: item.id === selectedId }, `agenda-status-${item.status}`]" :draggable="canContribute && !meetingLocked" @dragstart="startDrag(item.id)" @dragover.prevent @drop.prevent="dropOn(item.id)">
<button class="agenda-select" :disabled="Boolean(openingId)" @click="emit('select', item.id)"><span class="agenda-index">{{ index + 1 }}</span><span><strong>{{ item.title }}</strong><small>{{ statusLabel('agenda', item.status) }} · {{ item.estimated_minutes ?? '—' }} {{ tr('分钟', 'min') }}</small></span></button>
<button class="agenda-select" :disabled="Boolean(openingId)" @click="emit('select', item.id)"><span class="agenda-index">{{ index + 1 }}</span><span><strong>{{ translateSeededText(item.title) }}</strong><small>{{ statusLabel('agenda', item.status) }} · {{ item.estimated_minutes ?? '—' }} {{ tr('分钟', 'min') }}</small></span></button>
<div v-if="canContribute && !meetingLocked" class="agenda-menu">
<n-dropdown
trigger="click"
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/AgendaWorkbench.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { errorMessage } from '../utils/errors'
import { errorMessage, translateBackendError } from '../utils/errors'

import { api } from '../api/client'
import type { AgendaItem, Meeting } from '../domain/meetings'
import { useI18n } from '../i18n'
import AgendaDetail from './AgendaDetail.vue'
import AgendaQueue from './AgendaQueue.vue'

const { tr } = useI18n()
const { locale, tr } = useI18n()
const props = defineProps<{
meeting: Meeting
canContribute: boolean
Expand All @@ -19,6 +19,10 @@ const detail = ref<{ flushIfDirty: () => Promise<boolean> } | null>(null)
const openingId = ref('')
const openError = ref('')

watch(locale, () => {
if (openError.value) openError.value = translateBackendError(openError.value)
})

watch(() => props.meeting.status, (status, previousStatus) => {
if (status !== 'in_progress' || previousStatus === 'in_progress') return
selectedId.value = props.meeting.agenda_items.find((item) => item.status === 'in_progress')?.id ?? props.meeting.agenda_items[0]?.id ?? ''
Expand Down
Loading
Loading