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
6 changes: 3 additions & 3 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { loadPluginFrontendModules } from './plugins/runtime'
import { naiveThemeOverrides } from './theme/naive'

const router = useRouter()
const { locale, toggleLocale, tr } = useI18n()
const { locale, toggleLocale, tr, translateSeededText } = useI18n()

const accountOptions = computed(() => [
{ key: 'account', label: tr('账号设置', 'Account Settings') },
Expand Down Expand Up @@ -111,8 +111,8 @@ onBeforeUnmount(() => {
</NBadge>
<NDropdown :options="accountOptions" trigger="click" @select="onAccountSelect">
<button type="button" class="account-menu-trigger" :aria-label="tr('账户菜单', 'Account menu')">
<AppAvatar :name="session.user.display_name" :color="session.user.avatar_color" :size="30" />
<span>{{ session.user.display_name }}</span>
<AppAvatar :name="translateSeededText(session.user.display_name)" :color="session.user.avatar_color" :size="30" />
<span>{{ translateSeededText(session.user.display_name) }}</span>
</button>
</NDropdown>
</div>
Expand Down
54 changes: 50 additions & 4 deletions frontend/src/components/FocusMode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import OutcomeComposer from './OutcomeComposer.vue'
import { useI18n } from '../i18n'
import { Check, ChevronLeft, ChevronRight } from '@lucide/vue'

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

const props = defineProps<{
show: boolean
Expand Down Expand Up @@ -96,6 +96,7 @@ watch(() => currentItem.value, (item) => {
}, { immediate: true })

watch(locale, (nextLocale) => {
message.destroyAll()
localNotes.value = translateNotesMarkdown(localNotes.value, nextLocale)
})

Expand Down Expand Up @@ -174,6 +175,7 @@ async function persistCurrentNotes(): Promise<boolean> {
emit('reload')
return true
} catch (caught) {
message.destroyAll()
message.error(errorMessage(caught, tr('议题笔记保存失败', 'Failed to save agenda notes')))
return false
} finally {
Expand All @@ -190,16 +192,18 @@ async function completeAndAdvance() {
method: 'POST',
body: JSON.stringify({ expected_version: localVersion.value }),
})
message.destroyAll()
message.success(
locale.value === 'en'
? `Agenda "${currentItem.value.title}" completed`
: `议题「${currentItem.value.title}」已完成`,
? `Agenda "${translateSeededText(currentItem.value.title, 'en')}" completed`
: `议题「${translateSeededText(currentItem.value.title, 'zh')}」已完成`,
)
emit('reload')
if (result.next_agenda_item_id) {
selectedAgendaId.value = result.next_agenda_item_id
}
} catch (caught) {
message.destroyAll()
message.error(errorMessage(caught, tr('完成议题失败', 'Failed to complete agenda item')))
} finally {
advancing.value = false
Expand Down Expand Up @@ -232,13 +236,15 @@ async function smartExtractFocusNotes() {
const decisions = res.decisions ?? res.extracted?.decisions ?? 0
const actions = res.actions ?? res.extracted?.actions ?? 0
const questions = res.questions ?? res.extracted?.questions ?? 0
message.destroyAll()
message.success(
locale.value === 'en'
? `Extracted ${decisions} decisions, ${actions} actions, and ${questions} open questions`
: `已智能提炼 ${decisions} 项决策、${actions} 项行动、${questions} 项开放问题`,
)
emit('reload')
} catch (caught) {
message.destroyAll()
message.error(errorMessage(caught, tr('AI 提炼失败,请检查笔记标签格式', 'AI extraction failed, please check notes format')))
} finally {
extracting.value = false
Expand Down Expand Up @@ -270,13 +276,15 @@ async function toggleActionStatus(action: ActionItem, targetStatus?: ActionStatu
})
action.status = updated.status
action.version = updated.version
message.destroyAll()
message.success(
locale.value === 'en'
? `Action status updated to: ${actionStatusLabel(nextStatus)}`
: `行动项状态更新为: ${actionStatusLabel(nextStatus)}`,
)
emit('reload')
} catch (caught) {
message.destroyAll()
message.error(errorMessage(caught, tr('更新行动项失败', 'Failed to update action item')))
}
}
Expand Down Expand Up @@ -400,6 +408,16 @@ const noteActionTags = computed(() => {
{{ saving ? tr('保存中…', 'Saving…') : tr('保存笔记', 'Save Notes') }}
</button>

<button
type="button"
class="focus-lang-btn"
data-testid="focus-lang-toggle"
:aria-label="tr('切换语言', 'Switch Language')"
@click="toggleLocale"
>
🌐 {{ locale === 'zh' ? 'EN' : '中文' }}
</button>

<button type="button" class="focus-exit-btn" @click="exitFocus" :title="tr('退出专注模式 (Esc)', 'Exit Focus Mode (Esc)')">
{{ tr('退出专注', 'Exit Focus') }} <kbd>Esc</kbd>
</button>
Expand Down Expand Up @@ -780,6 +798,28 @@ const noteActionTags = computed(() => {
font-family: inherit;
}

.focus-lang-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid #cbd5e1;
background: white;
color: #334155;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
flex-shrink: 0;
}

.focus-lang-btn:hover {
background: #f1f5f9;
border-color: #94a3b8;
}

.focus-exit-btn {
display: inline-flex;
align-items: center;
Expand Down Expand Up @@ -836,18 +876,24 @@ const noteActionTags = computed(() => {
.agenda-title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}

.focus-agenda-title {
margin: 0;
font-size: 1.65rem;
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
font-size: 1.5rem;
font-weight: 800;
color: #0f172a;
}

.focus-agenda-type {
flex-shrink: 0;
white-space: nowrap;
font-size: 0.78rem;
color: #64748b;
font-weight: 600;
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/components/MarkdownEditor.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { Crepe } from '@milkdown/crepe'
import { placeholderConfig } from '@milkdown/crepe/feature/placeholder'
import { editorViewCtx } from '@milkdown/kit/core'

export type MarkdownEditorHandle = { flush: () => string }
import { getMarkdown, replaceAll } from '@milkdown/kit/utils'
Expand Down Expand Up @@ -80,6 +82,26 @@ watch(() => props.disabled, (value) => {
if (ready) editor?.setReadonly(value)
})

watch(effectivePlaceholder, (nextText) => {
if (ready && editor) {
try {
editor.editor.action((ctx) => {
ctx.update(placeholderConfig.key, (prev) => ({
...prev,
text: nextText,
}))
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr)
})
} catch {
// Fallback to direct DOM attribute update below
}
}
root.value?.querySelectorAll('.crepe-placeholder, [data-placeholder]').forEach((el) => {
el.setAttribute('data-placeholder', nextText)
})
})

watch(() => props.modelValue, (value) => {
if (!ready || !editor || value === latestMarkdown) return
latestMarkdown = value
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/MeetingCreateDrawer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useI18n } from '../i18n'
import { errorMessage } from '../utils/errors'
import MeetingParticipantEditor from './MeetingParticipantEditor.vue'

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

type ProjectOption = { id: string; name: string }
type ParticipantValue = { user_id: string; participation_role: ParticipationRole }
Expand Down Expand Up @@ -40,7 +40,7 @@ const form = ref({
})

const projectOptions = computed(() => props.projects.map((project) => ({
label: project.name,
label: translateSeededText(project.name),
value: project.id,
})))

Expand Down
12 changes: 6 additions & 6 deletions frontend/src/components/ProjectActivityTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import PluginEditorSlot from './PluginEditorSlot.vue'
import ProjectUpdateComposer from './ProjectUpdateComposer.vue'
import VersionConflictDialog from './VersionConflictDialog.vue'

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

const props = defineProps<{ project: ProjectDetail; canContribute: boolean }>()
const emit = defineEmits<{ reload: [] }>()
Expand Down Expand Up @@ -45,7 +45,8 @@ const editForm = ref({ health: 'unset' as Health, content_markdown: '' })
const conflictServer = ref<ProjectUpdate | null>(null)

function actorName(item: ProjectActivityItem) {
return item.actor?.display_name || item.actor?.username || tr('系统', 'System')
const raw = item.actor?.display_name || item.actor?.username
return raw ? translateSeededText(raw) : tr('系统', 'System')
}

async function loadActivity(before?: number) {
Expand Down Expand Up @@ -217,8 +218,7 @@ onMounted(() => {
<NTimeline v-if="activityItems.length" class="project-activity-timeline">
<NTimelineItem v-for="item in activityItems" :key="item.id" :time="formatDateTime(item.created_at)">
<p class="activity-entry">
<strong>{{ actorName(item) }}</strong>
<span>{{ activityLabel(item.event_type, item.payload) }}</span>
<strong style="margin-right: 6px">{{ actorName(item) }}</strong><span>{{ activityLabel(item.event_type, item.payload) }}</span>
</p>
<div v-if="canContribute && item.subject.type === 'project_update'" class="row-actions">
<button class="button button-quiet" type="button" :aria-label="tr(`编辑进展 ${item.subject.id}`, `Edit update ${item.subject.id}`)" @click="openUpdateEdit(item)">{{ tr('编辑', 'Edit') }}</button>
Expand All @@ -235,9 +235,9 @@ onMounted(() => {
<header class="section-heading"><h3>{{ tr('进展记录', 'Progress History') }}</h3></header>
<div v-if="updates.length" class="project-activity-list">
<article v-for="item in updates" :key="item.id" class="latest-update">
<MarkdownView :source="item.content_markdown" />
<MarkdownView :source="translateNotesMarkdown(item.content_markdown)" />
<p class="attribution">
{{ item.created_by?.display_name || item.created_by?.username || tr('系统', 'System') }} · {{ formatDateTime(item.created_at) }}
{{ item.created_by ? translateSeededText(item.created_by.display_name || item.created_by.username) : tr('系统', 'System') }} · {{ formatDateTime(item.created_at) }}
<template v-if="item.source === 'ai_draft_applied'"> · {{ tr('AI 草稿', 'AI Draft') }}</template>
</p>
</article>
Expand Down
19 changes: 10 additions & 9 deletions frontend/src/components/ProjectQuestionsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { ProjectDetail } from '../domain/projects'
import { useI18n } from '../i18n'
import { errorMessage } from '../utils/errors'

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

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

Expand Down Expand Up @@ -43,22 +43,23 @@ const resolveSaving = ref(false)
const resolveError = ref('')

const memberOptions = computed(() => props.project.memberships.map((row) => ({
label: row.user.display_name || row.user.username,
label: translateSeededText(row.user.display_name || row.user.username),
value: row.user.id,
})))
const scheduleOptions = computed(() => scheduleMeetings.value.map((meeting) => ({
label: `${meeting.title} · ${meeting.scheduled_start.slice(0, 10)}`,
label: `${translateSeededText(meeting.title)} · ${meeting.scheduled_start.slice(0, 10)}`,
value: meeting.id,
})))
const resolveDecisionOptions = computed(() => resolveDecisions.value.map((decision) => ({
label: decision.title,
label: translateSeededText(decision.title),
value: decision.id,
})))

function ownerName(userId: string | null | undefined) {
if (!userId) return tr('未指派', 'Unassigned')
const row = props.project.memberships.find((item) => item.user.id === userId)
return row?.user.display_name || row?.user.username || tr('未指派', 'Unassigned')
const raw = row?.user.display_name || row?.user.username
return raw ? translateSeededText(raw) : tr('未指派', 'Unassigned')
}

async function load() {
Expand Down Expand Up @@ -242,7 +243,7 @@ onMounted(load)
<div v-else-if="questions.length" class="project-record-list">
<div v-for="item in questions" :key="item.id" class="project-record-row question-row">
<div class="question-row-main">
<strong>{{ item.question_markdown }}</strong>
<strong>{{ translateSeededText(item.question_markdown) }}</strong>
<span>
<StatusPill :status="item.status" kind="question" />
· {{ ownerName(item.owner_user_id) }}
Expand All @@ -259,12 +260,12 @@ onMounted(load)
</span>
</div>
<div v-if="canContribute && !item.is_derived" class="row-actions">
<button class="button button-quiet" type="button" :aria-label="tr(`编辑开放问题“${item.question_markdown}”`, `Edit question &quot;${item.question_markdown}&quot;`)" @click="openEdit(item)">{{ tr('编辑', 'Edit') }}</button>
<button class="button button-quiet" type="button" :aria-label="tr(`编辑开放问题“${translateSeededText(item.question_markdown)}”`, `Edit question &quot;${translateSeededText(item.question_markdown)}&quot;`)" @click="openEdit(item)">{{ tr('编辑', 'Edit') }}</button>
<button
v-if="item.status === 'open' && !item.scheduled_meeting_id"
class="button button-primary"
type="button"
:aria-label="tr(`排期开放问题“${item.question_markdown}”`, `Schedule question &quot;${item.question_markdown}&quot;`)"
:aria-label="tr(`排期开放问题“${translateSeededText(item.question_markdown)}”`, `Schedule question &quot;${translateSeededText(item.question_markdown)}&quot;`)"
@click="openSchedule(item)"
>
{{ tr('排期', 'Schedule') }}
Expand All @@ -273,7 +274,7 @@ onMounted(load)
v-if="canResolve(item)"
class="button button-quiet"
type="button"
:aria-label="tr(`解决开放问题“${item.question_markdown}”`, `Resolve question &quot;${item.question_markdown}&quot;`)"
:aria-label="tr(`解决开放问题“${translateSeededText(item.question_markdown)}”`, `Resolve question &quot;${translateSeededText(item.question_markdown)}&quot;`)"
@click="openResolve(item)"
>
{{ tr('解决', 'Resolve') }}
Expand Down
Loading
Loading