diff --git a/.task/checksum/build-frontend--DEV--RUNNER-npm- b/.task/checksum/build-frontend--DEV--RUNNER-npm- index d6b3dc8..e008a24 100644 --- a/.task/checksum/build-frontend--DEV--RUNNER-npm- +++ b/.task/checksum/build-frontend--DEV--RUNNER-npm- @@ -1 +1 @@ -8ef156c82c13dcfb55a861fc1e1b9483 +cd84020b5c141a3842ad91546a552d0 diff --git a/.task/checksum/windows-common-generate-bindings b/.task/checksum/windows-common-generate-bindings index fdd825b..843ebf7 100644 --- a/.task/checksum/windows-common-generate-bindings +++ b/.task/checksum/windows-common-generate-bindings @@ -1 +1 @@ -a59624793eae992b65534b38e0fef064 +d56685eca1698f613f797ff4d81f460a diff --git a/admin.go b/admin.go index bf0906c..31114ec 100644 --- a/admin.go +++ b/admin.go @@ -368,7 +368,7 @@ func (a *App) checkAIPolicy() error { } // reportAIUsage 上报一次 AI 用量(失败忽略)。 -func (a *App) reportAIUsage(provider string, prompt, completion int64, estimated bool) { +func (a *App) reportAIUsage(provider string, prompt, completion, cached int64, estimated bool) { if a.syncUserID() <= 0 || a.store.Meta("sync_access_token") == "" { return } @@ -376,6 +376,7 @@ func (a *App) reportAIUsage(provider string, prompt, completion int64, estimated "provider": provider, "promptTokens": prompt, "completionTokens": completion, + "cachedTokens": cached, "estimated": estimated, }, nil, true) } diff --git a/ai.go b/ai.go index ab1a4d0..1e98cf8 100644 --- a/ai.go +++ b/ai.go @@ -258,9 +258,7 @@ func (a *App) runAIStream(ctx context.Context, provider ai.Provider, convID int6 a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: e.Error(), Done: true}) return } - if usage != nil { - go a.reportAIUsage(provider.Name(), usage.PromptTokens, usage.CompletionTokens, usage.Estimated) - } + a.recordAIUsage(provider.Name(), "chat", usage, msgs, sb.String()) a.store.TouchAIConversation(convID, provider.Name()) a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Done: true, MessageID: m.ID}) } @@ -339,6 +337,21 @@ func (a *App) aiProvider() (ai.Provider, error) { return ai.New(st.AIProvider, key) } +// recordAIUsage 写入本地「AI」生成记录并上报云端日汇总。usage 为空时按消息粗估,避免生成成功却没有记录。 +func (a *App) recordAIUsage(provider, scene string, usage *ai.Usage, msgs []ai.Message, completion string) { + if usage == nil { + usage = ai.EstimateUsage(msgs, completion) + } + cache := "none" + if usage.CachedTokens > 0 { + cache = fmt.Sprintf("hit:%d", usage.CachedTokens) + } + a.store.Log("info", "AI", "AI 调用完成", fmt.Sprintf( + "scene=%s provider=%s prompt=%d completion=%d cached=%d cache=%s estimated=%v", + scene, provider, usage.PromptTokens, usage.CompletionTokens, usage.CachedTokens, cache, usage.Estimated)) + go a.reportAIUsage(provider, usage.PromptTokens, usage.CompletionTokens, usage.CachedTokens, usage.Estimated) +} + // aiSummarySem 全局串行生成,避免批量分析后并发打爆免费额度接口。 var aiSummarySem = make(chan struct{}, 1) @@ -501,6 +514,7 @@ func (a *App) generateAISummaries(projectID int64, kinds ...string) { // 无数据的全局简报直接落固定文案,不浪费一次注定编造的模型调用。 if content, ok := a.staticDayBrief(kind, st.Locale); ok { if e := a.store.SaveAISummary(projectID, kind, "system", content); e == nil { + a.store.Log("info", "AI", "AI 调用完成", fmt.Sprintf("scene=summary:%s cache=local provider=system", kind)) a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind}) } continue @@ -518,8 +532,12 @@ func (a *App) generateAISummaries(projectID int64, kinds ...string) { continue } var sb strings.Builder + var usage *ai.Usage var streamErr error for chunk := range stream { + if chunk.Usage != nil { + usage = chunk.Usage + } if chunk.Err != nil { streamErr = chunk.Err break @@ -536,6 +554,7 @@ func (a *App) generateAISummaries(projectID int64, kinds ...string) { a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind, Error: msg}) continue } + a.recordAIUsage(provider.Name(), "summary:"+kind, usage, msgs, sb.String()) if e := a.store.SaveAISummary(projectID, kind, provider.Name(), dedentCommon(stripFence(strings.TrimSpace(sb.String())))); e != nil { a.store.Log("warning", "AI", "AI 模块介绍保存失败", e.Error()) continue diff --git a/ai_tasks.go b/ai_tasks.go index 9007e76..5024413 100644 --- a/ai_tasks.go +++ b/ai_tasks.go @@ -45,8 +45,12 @@ func (a *App) AIGenerateTasks(kind, text string) ([]AITaskDraft, error) { if e := a.checkAIPolicy(); e != nil { return nil, e } + locale := "zh-CN" + if st, se := a.store.Settings(); se == nil && st.Locale != "" { + locale = st.Locale + } msgs := []ai.Message{ - {Role: "system", Content: aiTaskPrompt(kind)}, + {Role: "system", Content: aiTaskPrompt(kind, locale)}, {Role: "user", Content: text}, } ctx, cancel := context.WithTimeout(a.ctx, 2*time.Minute) @@ -68,9 +72,7 @@ func (a *App) AIGenerateTasks(kind, text string) ([]AITaskDraft, error) { } sb.WriteString(chunk.Content) } - if usage != nil { - go a.reportAIUsage(provider.Name(), usage.PromptTokens, usage.CompletionTokens, usage.Estimated) - } + a.recordAIUsage(provider.Name(), "generate:"+kind, usage, msgs, sb.String()) drafts, pe := parseAITaskDrafts(kind, sb.String()) if pe != nil { a.store.Log("warning", "AI", "AI 任务生成解析失败", sb.String()) @@ -80,20 +82,90 @@ func (a *App) AIGenerateTasks(kind, text string) ([]AITaskDraft, error) { return drafts, nil } -func aiTaskPrompt(kind string) string { - now := time.Now() - weekdays := []string{"日", "一", "二", "三", "四", "五", "六"} - head := fmt.Sprintf("今天是 %s(星期%s),当前时间 %s。\n", now.Format("2006-01-02"), weekdays[int(now.Weekday())], now.Format("15:04")) +func aiTaskPrompt(kind, locale string) string { + head := aiCalendarContext(locale) if kind == "ticket" { - return head + `你是研发工单拆解助手。把用户的一句话拆成 1~10 条工单。 + return head + `你是研发工单拆解助手。把用户输入拆成 1~10 条工单。 只输出 JSON 数组,禁止任何解释、markdown 围栏或多余文字。每个元素: {"title":"简短标题(<=40字)","description":"补充细节,可为空","type":"feature|bug|task|improvement","priority":"low|medium|high","startAt":"YYYY-MM-DD","dueAt":"YYYY-MM-DD"} -规则:标题用与用户输入相同的语言;startAt 默认今天;dueAt 不得早于 startAt,用户未提及工期时按任务量合理估算(1~7 天);能从用户话中推断出的日期(如“周五前”“下周”)必须转换为具体日期。` +日期规则(必须遵守): +1. 按用户输入的语言理解相对日期(中文:今天/明天/后天/大后天/本周X/下周X/这周五/下周一/月底/下个月;英文:today/tomorrow/this Friday/next Monday/end of week 等),对照上面日历换成具体 YYYY-MM-DD。 +2. 用户说的不一定是今天:若整段话指向某一天或某一周,所有条目的 startAt/dueAt 都落在那个时间范围内,不要一律填今天。 +3. 一句话里提到多天(如“周三做A,周五做B”“这周每天…”),每条工单各自写对应的 startAt 和 dueAt,不要全部同一天。 +4. 每条都必须写 dueAt。用户给了截止就用;只给了开始日则按工作量估 1~5 天;完全没提日期则 startAt=今天、dueAt 按工作量估。 +5. dueAt 不得早于 startAt。标题语言与用户输入一致。 +6. 用户提到节假日/黄金周/放假/调休(国庆、十一、春节、过年、中秋、端午、元旦、五一、清明、圣诞、感恩节等),必须对照上面节假日表换成具体 YYYY-MM-DD,禁止自己换算农历。「X前」= 节日前一天;「X期间」= 表中放假区间(没有则用当天);「X后」= 假期结束后一天。` } - return head + `你是待办事项拆解助手。把用户的一句话拆成 1~10 条待办。 + return head + `你是待办事项拆解助手。把用户输入拆成 1~10 条待办。 只输出 JSON 数组,禁止任何解释、markdown 围栏或多余文字。每个元素: -{"title":"简短标题(<=40字)","content":"补充细节,可为空","priority":"low|medium|high","dueAt":"YYYY-MM-DDTHH:MM 或空字符串"} -规则:标题用与用户输入相同的语言;只有用户明确或可合理推断截止时间时才填 dueAt(如“明天下午”→ 明天 18:00),否则留空字符串。` +{"title":"简短标题(<=40字)","content":"补充细节,可为空","priority":"low|medium|high","dueAt":"YYYY-MM-DDTHH:MM"} +日期规则(必须遵守): +1. 按用户输入的语言理解相对日期和时间(中文:今天/明天/后天/本周X/下周X/上午/下午/晚上/点钟;英文:today/tomorrow/this Friday/next week/afternoon 等),对照上面日历换成具体 YYYY-MM-DDTHH:MM。 +2. 用户说的不一定是今天:若整段话指向某一天或某一周,所有条目的 dueAt 都落在那个时间,不要一律填今天。 +3. 一句话里提到多天(如“周一买菜,周三交报告”),每条待办各自写对应的 dueAt。 +4. 每条都必须写 dueAt,不要留空。用户给了时刻就用;只给了日期则:上午→10:00、下午→18:00、晚上→21:00、未提时段→18:00。完全没提日期则按紧急度估:高优今天 18:00,中优明天 18:00,低优 3 天后 18:00。 +5. 标题语言与用户输入一致。 +6. 用户提到节假日/黄金周/放假(国庆、十一、春节、过年、中秋、端午、元旦、五一、清明、圣诞、感恩节等),必须对照上面节假日表换成具体日期,禁止自己换算农历。「X前」= 节日前一天 18:00;「X期间」落在放假区间;「X后」从假期结束后一天起。` +} + +// aiCalendarContext 给模型一份「今天 + 本周/下周对照表」,避免相对日期猜错,也不默认全是今天。 +func aiCalendarContext(locale string) string { + now := time.Now() + en := strings.HasPrefix(strings.ToLower(locale), "en") + wdCN := []string{"日", "一", "二", "三", "四", "五", "六"} + wdEN := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} + weekday := func(t time.Time) string { + if en { + return wdEN[int(t.Weekday())] + } + return "星期" + wdCN[int(t.Weekday())] + } + // 本周一:Go Weekday 周日=0 + offset := int(now.Weekday()) + if offset == 0 { + offset = 7 + } + monday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, 1-offset) + var b strings.Builder + if en { + fmt.Fprintf(&b, "Today is %s (%s), current time %s. Timezone is the user's local time.\n", now.Format("2006-01-02"), weekday(now), now.Format("15:04")) + b.WriteString("This week:\n") + } else { + fmt.Fprintf(&b, "今天是 %s(%s),当前时间 %s。时区按用户本机。\n", now.Format("2006-01-02"), weekday(now), now.Format("15:04")) + b.WriteString("本周对照:\n") + } + for i := 0; i < 7; i++ { + d := monday.AddDate(0, 0, i) + mark := "" + if d.Format("2006-01-02") == now.Format("2006-01-02") { + if en { + mark = " ← today" + } else { + mark = " ← 今天" + } + } + fmt.Fprintf(&b, "- %s %s%s\n", weekday(d), d.Format("2006-01-02"), mark) + } + if en { + b.WriteString("Next week:\n") + } else { + b.WriteString("下周对照:\n") + } + for i := 0; i < 7; i++ { + d := monday.AddDate(0, 0, 7+i) + fmt.Fprintf(&b, "- %s %s\n", weekday(d), d.Format("2006-01-02")) + } + monthEnd := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()) + nextMonth := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location()) + if en { + fmt.Fprintf(&b, "End of this month: %s. First day of next month: %s.\n", monthEnd.Format("2006-01-02"), nextMonth.Format("2006-01-02")) + b.WriteString("Convert relative phrases using this calendar. Do not default every item to today unless the user said today or gave no date at all.\n") + } else { + fmt.Fprintf(&b, "本月最后一天:%s。下月 1 日:%s。\n", monthEnd.Format("2006-01-02"), nextMonth.Format("2006-01-02")) + b.WriteString("相对日期必须对照上面日历换算。用户没说今天时,不要把每条都填成今天。\n") + } + b.WriteString(formatHolidayBlock(now, en)) + return b.String() } // parseAITaskDrafts 从模型输出中提取 JSON 数组并规范化字段。 @@ -128,11 +200,21 @@ func parseAITaskDrafts(kind, raw string) ([]AITaskDraft, error) { d.Type = "task" } d.Content = "" - if !validDateStr(d.StartAt) { + // 优先保留模型按用户语义写出的日期;缺了才补,避免把「下周三」整批改成今天。 + d.StartAt = dateOnly(d.StartAt) + d.DueAt = dateOnly(d.DueAt) + if d.StartAt == "" && d.DueAt != "" { + d.StartAt = d.DueAt + } + if d.StartAt == "" { d.StartAt = today } - if !validDateStr(d.DueAt) { - d.DueAt = time.Now().AddDate(0, 0, 3).Format("2006-01-02") + if d.DueAt == "" { + if t, e := time.Parse("2006-01-02", d.StartAt); e == nil { + d.DueAt = t.AddDate(0, 0, 2).Format("2006-01-02") + } else { + d.DueAt = time.Now().AddDate(0, 0, 2).Format("2006-01-02") + } } if d.DueAt < d.StartAt { d.DueAt = d.StartAt @@ -140,6 +222,16 @@ func parseAITaskDrafts(kind, raw string) ([]AITaskDraft, error) { } else { d.Type, d.Description, d.StartAt = "", "", "" d.DueAt = normalizeTodoDue(d.DueAt) + if d.DueAt == "" { + // 待办也必须有截止日期,缺省按优先级估。 + days := 1 + if d.Priority == "high" { + days = 0 + } else if d.Priority == "low" { + days = 3 + } + d.DueAt = time.Now().AddDate(0, 0, days).Format("2006-01-02") + "T18:00" + } } out = append(out, d) if len(out) >= 20 { @@ -153,21 +245,45 @@ func parseAITaskDrafts(kind, raw string) ([]AITaskDraft, error) { } func validDateStr(s string) bool { - _, e := time.Parse("2006-01-02", strings.TrimSpace(s)) - return e == nil + return dateOnly(s) != "" } -// normalizeTodoDue 接受 YYYY-MM-DDTHH:MM 或纯日期(补 18:00),其余返回空。 +// dateOnly 从 YYYY-MM-DD 或带时间的写法里抽出日期;解析失败返回空。 +func dateOnly(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if i := strings.IndexAny(s, "T "); i > 0 { + s = s[:i] + } + if _, e := time.Parse("2006-01-02", s); e == nil { + return s + } + return "" +} + +// normalizeTodoDue 接受常见日期/时间写法,统一成 datetime-local 用的 YYYY-MM-DDTHH:MM。 func normalizeTodoDue(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } - if _, e := time.Parse("2006-01-02T15:04", s); e == nil { - return s + s = strings.ReplaceAll(s, " ", "T") + for _, layout := range []string{ + "2006-01-02T15:04", + "2006-01-02T15:04:05", + time.RFC3339, + } { + if t, e := time.Parse(layout, s); e == nil { + return t.Format("2006-01-02T15:04") + } } if validDateStr(s) { return s + "T18:00" } + if i := strings.Index(s, "T"); i == 10 && validDateStr(s[:10]) { + return s[:10] + "T18:00" + } return "" } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2ce32bb..34f9408 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,7 +2,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' -import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal, Home, ListTodo, TicketCheck, CalendarDays, CalendarCheck2, Sparkles, UserRound, ClipboardList, Wrench, Rocket, ChevronDown, Bell, StickyNote, ListFilter, Palette, Bot, Users, NotebookPen, CloudUpload, Power, Image, Activity, Package, Shield } from 'lucide-vue-next' +import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal, Home, ListTodo, TicketCheck, CalendarDays, CalendarCheck2, Sparkles, UserRound, ClipboardList, Wrench, Rocket, ChevronDown, Bell, StickyNote, ListFilter, Palette, Bot, Users, NotebookPen, CloudUpload, Power, Image, Activity, Package, Shield, Plus, Pin, PinOff } from 'lucide-vue-next' import { useAppStore } from './store' import DatabaseSetup from './components/DatabaseSetup.vue' import BrowserBlocked from './components/BrowserBlocked.vue' @@ -107,14 +107,74 @@ const navGroups = [ { to: '/settings?tab=database', icon: Database, label: 'tabDatabase', match: r => r.path === '/settings' && settingsTab(r) === 'database' } ] } ] + +// ---- 概览快捷入口:把其它分区菜单钉到「概览」二级导航 ---- +const SHORTCUT_KEY = 'cc-nav-shortcuts' +const loadShortcuts = () => { + try { + const raw = JSON.parse(localStorage.getItem(SHORTCUT_KEY) || '[]') + return Array.isArray(raw) ? raw.filter(x => typeof x === 'string') : [] + } catch { return [] } +} +const navShortcuts = ref(loadShortcuts()) +const shortcutPicker = ref(false) +function saveShortcuts() { + localStorage.setItem(SHORTCUT_KEY, JSON.stringify(navShortcuts.value)) +} +const catalogByTo = computed(() => { + const map = new Map() + for (const g of navGroups) { + for (const it of g.items) map.set(it.to, { ...it, groupId: g.id, groupLabel: g.label }) + } + return map +}) +// 可钉到概览的项:非概览内置、且当前用户可见 +const pinnableItems = computed(() => { + const overviewTos = new Set(navGroups[0].items.map(it => it.to)) + const out = [] + for (const g of navGroups) { + if (g.id === 'overview') continue + for (const it of g.items) { + if (overviewTos.has(it.to)) continue + if (it.adminOnly && store.syncStatus.userId !== 1) continue + out.push({ ...it, groupId: g.id, groupLabel: g.label }) + } + } + return out +}) +function addShortcut(to) { + if (navShortcuts.value.includes(to)) return + navShortcuts.value = [...navShortcuts.value, to] + saveShortcuts() + shortcutPicker.value = false +} +function removeShortcut(to) { + navShortcuts.value = navShortcuts.value.filter(x => x !== to) + saveShortcuts() +} +function toggleShortcut(to) { + if (navShortcuts.value.includes(to)) removeShortcut(to) + else addShortcut(to) +} + // adminOnly 项只对云端管理员(id=1)展示 -const visibleItems = g => g.items.filter(it => !it.adminOnly || store.syncStatus.userId === 1) +const visibleItems = g => { + let items = g.items.filter(it => !it.adminOnly || store.syncStatus.userId === 1) + if (g.id === 'overview') { + const extras = navShortcuts.value + .map(to => catalogByTo.value.get(to)) + .filter(it => it && (!it.adminOnly || store.syncStatus.userId === 1)) + .map(it => ({ ...it, shortcut: true })) + items = [...items, ...extras] + } + return items +} const itemActive = it => it.match ? it.match(route) : route.path === it.to.split('?')[0] // 导航徽标:badge 显示数字,dot 只显示小圆点;一级 rail 在组内任一非零时亮点 const badgeVal = it => it.badge ? (store.badges[it.badge] || 0) : 0 const badgeText = it => { const n = badgeVal(it); return n > 99 ? '99+' : String(n) } const dotVal = it => it.dot ? !!store.badges[it.dot] : false -const groupDot = g => g.items.some(it => badgeVal(it) > 0 || dotVal(it)) +const groupDot = g => visibleItems(g).some(it => badgeVal(it) > 0 || dotVal(it)) const childActive = c => route.path === '/settings' && String(route.query.tab || '') === c.tab const groupOfRoute = () => navGroups.find(g => g.items.some(it => itemActive(it)))?.id const activeGroupId = ref(groupOfRoute() || 'overview') @@ -126,6 +186,7 @@ watch(() => route.fullPath, () => { const g = groupOfRoute() if (g) activeGroupId.value = g railFlyout.value = null + if (g !== 'overview') shortcutPicker.value = false for (const grp of navGroups) { for (const it of grp.items) if (it.children && itemActive(it)) expandedParents.value[it.label] = true } @@ -263,16 +324,29 @@ watch(activeTask, task => {
{{ t(activeGroup.label) }}
+ +
+ +
+
{{ activeTaskProject || visibleTask.stage }}{{ t(visibleTask.messageKey || 'task.start', visibleTask.params || {}) }}
diff --git a/frontend/src/components/AITaskGenerator.vue b/frontend/src/components/AITaskGenerator.vue index b034494..c0b0e71 100644 --- a/frontend/src/components/AITaskGenerator.vue +++ b/frontend/src/components/AITaskGenerator.vue @@ -111,6 +111,7 @@ function toggleAll() {
+

{{ t('aiGenDateHint') }}

@@ -129,10 +130,19 @@ function toggleAll() { - +

{{ kind === 'todo' ? d.content : d.description }}

@@ -175,7 +185,6 @@ function toggleAll() { padding: .45rem 0; font-size: .88rem; } -.btn.sm { padding: .38rem .7rem; font-size: .82rem; } .ai-gen-modal { width: min(680px, 94vw); max-height: 84vh; display: flex; flex-direction: column; } .ai-gen-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .6rem; } .ai-gen-head h2 { display: flex; align-items: center; gap: .45rem; font-size: 1.05rem; margin: 0; } @@ -189,8 +198,11 @@ function toggleAll() { .ai-gen-check { padding-top: .35rem; } .ai-gen-fields { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .4rem; } .ai-gen-title { width: 100%; padding: .4rem .55rem; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text); font-weight: 600; } -.ai-gen-row { display: flex; gap: .4rem; flex-wrap: wrap; } +.ai-gen-date-hint { margin: 0 0 .55rem; font-size: .78rem; color: var(--muted); } +.ai-gen-row { display: flex; gap: .4rem; flex-wrap: wrap; align-items: flex-end; } .ai-gen-row select, .ai-gen-row input { padding: .3rem .45rem; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text); font-size: .82rem; } +.ai-gen-date { display: inline-flex; flex-direction: column; gap: .15rem; font-size: .72rem; color: var(--muted); } +.ai-gen-date input { min-width: 9.5rem; } .ai-gen-desc { margin: 0; font-size: .8rem; color: var(--muted); white-space: pre-wrap; } .ai-gen-err { color: #ef4444; font-size: .85rem; margin: .5rem 0 0; } .ai-gen-hint { color: #f59e0b; font-size: .85rem; margin: .5rem 0 0; } diff --git a/frontend/src/components/CloudClaimModal.vue b/frontend/src/components/CloudClaimModal.vue index 129a340..de4c4a4 100644 --- a/frontend/src/components/CloudClaimModal.vue +++ b/frontend/src/components/CloudClaimModal.vue @@ -141,7 +141,7 @@ onUnmounted(() => { offSync?.() }) .claim-rules svg { width: 12px; height: 12px; } .claim-err { margin: .15rem 0 0; color: #ef4444; font-size: .78rem; } .claim-ops { display: flex; flex-direction: column; gap: .35rem; flex: none; } -.btn.sm { padding: .34rem .65rem; font-size: .8rem; } +.claim-ops .btn { white-space: nowrap; } .claim-empty { text-align: center; color: var(--muted); padding: 1.5rem 0; } .claim-foot { display: flex; justify-content: flex-end; margin-top: .85rem; } .spin { animation: claim-spin 1s linear infinite; } diff --git a/frontend/src/components/NetworkInfoModal.vue b/frontend/src/components/NetworkInfoModal.vue new file mode 100644 index 0000000..baeccf7 --- /dev/null +++ b/frontend/src/components/NetworkInfoModal.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/frontend/src/components/NoteModal.vue b/frontend/src/components/NoteModal.vue index 54707e7..2176f08 100644 --- a/frontend/src/components/NoteModal.vue +++ b/frontend/src/components/NoteModal.vue @@ -1,16 +1,22 @@ @@ -58,16 +68,21 @@ onUnmounted(() => { removeEventListener('keydown', onKey); clearTimeout(timer) }