模块介绍 / 今日规划 / 下班日报 / 团队摘要 生成成功后没有上报,也几乎不写「AI」日志。

流结束时的 usage 用了非阻塞发送,通道一满就被丢掉;很多服务商默认还不在流里带 usage。
现在每次真正打到模型的调用都会:

在本地日志(分类 AI,消息 AI 调用完成)写下 scene / prompt / completion / cached
登录后同步上报到后台日汇总
This commit is contained in:
李琦
2026-08-17 14:00:47 +08:00
parent 83e69317d4
commit e71ce451ff
23 changed files with 1363 additions and 93 deletions

View File

@@ -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 => {
<div class="sub-title">{{ t(activeGroup.label) }}</div>
<nav class="sub-nav" :aria-label="t(activeGroup.label)">
<template v-for="it in visibleItems(activeGroup)" :key="it.to">
<RouterLink :to="it.to" :class="{ active: itemActive(it) }">
<RouterLink :to="it.to" :class="{ active: itemActive(it), shortcut: it.shortcut }">
<component :is="it.icon" /><span>{{ t(it.label) }}</span>
<em v-if="badgeVal(it)" class="nav-badge">{{ badgeText(it) }}</em>
<i v-else-if="dotVal(it)" class="nav-dot" aria-hidden="true" />
<button
v-if="it.shortcut"
type="button"
class="nav-unpin"
:title="t('navUnpinShortcut')"
@click.prevent.stop="removeShortcut(it.to)"
><PinOff /></button>
<button v-if="it.children" type="button" class="sub-caret" :class="{ open: expandedParents[it.label] }" :aria-label="t(it.label)" @click="toggleParent(it, $event)"><ChevronDown /></button>
</RouterLink>
<div v-if="it.children && expandedParents[it.label]" class="sub-children">
<RouterLink v-for="c in it.children" :key="c.to" :to="c.to" :class="{ active: childActive(c) }">{{ t(c.label) }}</RouterLink>
</div>
</template>
<button
v-if="activeGroup.id === 'overview'"
type="button"
class="nav-add-shortcut"
@click="shortcutPicker = !shortcutPicker"
><Plus />{{ t('navAddShortcut') }}</button>
</nav>
<div class="sidebar-bottom">
<span class="version"><i />v{{ appVersion }}</span>
@@ -348,6 +422,33 @@ watch(activeTask, task => {
</section>
</div>
</Teleport>
<Teleport to="body">
<div v-if="shortcutPicker" class="overlay" @click.self="shortcutPicker = false">
<section class="modal nav-shortcut-modal" @click.stop>
<header class="modal-head">
<h2><Pin />{{ t('navShortcutPicker') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="shortcutPicker = false"><X /></button>
</header>
<p class="nav-shortcut-hint">{{ t('navShortcutHint') }}</p>
<div class="nav-shortcut-list">
<button
v-for="it in pinnableItems"
:key="it.to"
type="button"
class="nav-shortcut-item"
:class="{ on: navShortcuts.includes(it.to) }"
@click="toggleShortcut(it.to)"
>
<component :is="it.icon" class="nav-shortcut-ico" />
<span class="nav-shortcut-label">{{ t(it.label) }}</span>
<small class="nav-shortcut-group">{{ t(it.groupLabel) }}</small>
<Pin v-if="navShortcuts.includes(it.to)" class="nav-shortcut-mark on" />
<Plus v-else class="nav-shortcut-mark" />
</button>
</div>
</section>
</div>
</Teleport>
<main><RouterView /></main>
<div v-if="visibleTask && !useFullscreenLoading" class="taskbar">
<div><b>{{ activeTaskProject || visibleTask.stage }}</b><span>{{ t(visibleTask.messageKey || 'task.start', visibleTask.params || {}) }}</span></div>

View File

@@ -111,6 +111,7 @@ function toggleAll() {
</select>
</label>
</div>
<p class="ai-gen-date-hint">{{ t('aiGenDateHint') }}</p>
<div class="ai-gen-list">
<article v-for="(d, i) in drafts" :key="i" class="ai-gen-item" :class="{ off: !d._on }">
<label class="ai-gen-check"><input v-model="d._on" type="checkbox" /></label>
@@ -129,10 +130,19 @@ function toggleAll() {
<option value="high">{{ t('priority.high') }}</option>
</select>
<template v-if="kind === 'ticket'">
<input v-model="d.startAt" type="date" :title="t('startDate')" />
<input v-model="d.dueAt" type="date" :title="t('dueDate')" />
<label class="ai-gen-date">
<span>{{ t('startDate') }}</span>
<input v-model="d.startAt" type="date" :title="t('startDate')" />
</label>
<label class="ai-gen-date">
<span>{{ t('dueDate') }}</span>
<input v-model="d.dueAt" type="date" :title="t('dueDate')" />
</label>
</template>
<input v-else v-model="d.dueAt" type="datetime-local" :title="t('dueDate')" />
<label v-else class="ai-gen-date">
<span>{{ t('dueDate') }}</span>
<input v-model="d.dueAt" type="datetime-local" :title="t('dueDate')" />
</label>
</div>
<p v-if="kind === 'todo' ? d.content : d.description" class="ai-gen-desc">{{ kind === 'todo' ? d.content : d.description }}</p>
</div>
@@ -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; }

View File

@@ -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; }

View File

@@ -0,0 +1,195 @@
<script setup>
// 本机网络信息模态框:调用 GetNetworkInfo展示主 IP / 各网卡,支持一键复制。
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Network, X, Copy, RefreshCw, Check, Star } from 'lucide-vue-next'
import { call } from '../api'
import { useAppStore } from '../store'
const emit = defineEmits(['close'])
const { t } = useI18n()
const store = useAppStore()
const loading = ref(true)
const info = ref(null)
const err = ref('')
const copied = ref('')
async function load() {
loading.value = true
err.value = ''
try {
info.value = await call('GetNetworkInfo')
} catch (e) {
err.value = String(e?.message || e)
} finally {
loading.value = false
}
}
async function copyText(text, key) {
const v = String(text || '').trim()
if (!v) return
try {
await navigator.clipboard.writeText(v)
copied.value = key
store.showToast({ type: 'success', key: 'netInfoCopied' })
setTimeout(() => { if (copied.value === key) copied.value = '' }, 1500)
} catch {
store.showToast({ type: 'error', key: 'netInfoCopyFail' })
}
}
function copyAdapter(a) {
const lines = [
a.name + (a.description ? ` (${a.description})` : ''),
a.mac ? `MAC: ${a.mac}` : '',
...(a.ipv4 || []).map(ip => `IPv4: ${ip}`),
...(a.ipv6 || []).map(ip => `IPv6: ${ip}`),
...(a.gateway || []).map(g => `Gateway: ${g}`),
...(a.dns || []).map(d => `DNS: ${d}`)
].filter(Boolean)
copyText(lines.join('\n'), 'a:' + a.name)
}
function copyAll() {
if (!info.value) return
const parts = []
if (info.value.hostname) parts.push('Hostname: ' + info.value.hostname)
if (info.value.primaryIPv4) parts.push('Primary IPv4: ' + info.value.primaryIPv4)
parts.push('')
parts.push(info.value.raw || '')
copyText(parts.join('\n').trim(), 'all')
}
const cleanName = s => String(s || '').replace(/\uFFFD/g, '').trim() || '—'
onMounted(load)
</script>
<template>
<Teleport to="body">
<div class="overlay" @click.self="emit('close')">
<section class="modal net-modal" @click.stop>
<header class="net-head">
<h2><Network />{{ t('netInfoTitle') }}</h2>
<div class="net-head-acts">
<button type="button" class="btn secondary icon" :disabled="loading" :title="t('refresh')" @click="load">
<RefreshCw :class="{ spin: loading }" />
</button>
<button type="button" class="btn primary icon" :disabled="!info" :title="t('netInfoCopyAll')" @click="copyAll">
<Copy />
</button>
<button type="button" class="nm-close" :title="t('close')" @click="emit('close')"><X /></button>
</div>
</header>
<p v-if="loading" class="net-hint">{{ t('loading') }}</p>
<p v-else-if="err" class="net-err">{{ err }}</p>
<template v-else-if="info">
<div class="net-primary">
<div>
<small>{{ t('netInfoHostname') }}</small>
<b>{{ info.hostname || '—' }}</b>
</div>
<div class="net-primary-ip">
<div>
<small>{{ t('netInfoPrimaryIP') }}</small>
<b>{{ info.primaryIPv4 || '—' }}</b>
</div>
<button
v-if="info.primaryIPv4"
type="button"
class="btn secondary sm"
:title="t('netInfoCopyIP')"
@click="copyText(info.primaryIPv4, 'primary')"
>
<Check v-if="copied === 'primary'" /><Copy v-else /><span>{{ t('netInfoCopyIP') }}</span>
</button>
</div>
</div>
<div class="net-list">
<article v-for="a in info.adapters" :key="a.name + (a.mac || '')" class="net-card" :class="{ primary: a.primary, virtual: a.virtual }">
<header>
<div class="net-card-id">
<b>{{ cleanName(a.name) }}</b>
<em v-if="a.primary"><Star />{{ t('netInfoPrimary') }}</em>
<em v-else-if="a.virtual" class="dim">{{ t('netInfoVirtual') }}</em>
<small v-if="cleanName(a.description) !== '—'">{{ cleanName(a.description) }}</small>
</div>
<button type="button" class="btn secondary icon" :title="t('copy')" @click="copyAdapter(a)"><Copy /></button>
</header>
<dl>
<template v-if="a.mac"><dt>MAC</dt><dd><button type="button" @click="copyText(a.mac, 'mac:'+a.name)">{{ a.mac }}</button></dd></template>
<template v-for="(ip, i) in (a.ipv4 || [])" :key="'v4'+i"><dt>IPv4</dt><dd><button type="button" @click="copyText(ip, 'v4:'+a.name+i)">{{ ip }}</button></dd></template>
<template v-for="(ip, i) in (a.ipv6 || [])" :key="'v6'+i"><dt>IPv6</dt><dd><button type="button" @click="copyText(ip, 'v6:'+a.name+i)">{{ ip }}</button></dd></template>
<template v-for="(g, i) in (a.gateway || [])" :key="'gw'+i"><dt>{{ t('netInfoGateway') }}</dt><dd><button type="button" @click="copyText(g, 'gw:'+a.name+i)">{{ g }}</button></dd></template>
<template v-for="(d, i) in (a.dns || [])" :key="'dns'+i"><dt>DNS</dt><dd><button type="button" @click="copyText(d, 'dns:'+a.name+i)">{{ d }}</button></dd></template>
<template v-if="a.dhcp"><dt>DHCP</dt><dd>{{ t('optOn') }}</dd></template>
</dl>
</article>
<p v-if="!info.adapters?.length" class="net-hint">{{ t('netInfoEmpty') }}</p>
</div>
<details class="net-raw">
<summary>{{ t('netInfoRaw') }}</summary>
<pre>{{ info.raw }}</pre>
</details>
</template>
</section>
</div>
</Teleport>
</template>
<style scoped>
.net-modal { width: min(680px, 94vw); max-height: 86vh; display: flex; flex-direction: column; padding: 0; overflow: hidden; }
.net-head {
display: flex; align-items: center; justify-content: space-between; gap: .75rem;
padding: 14px 16px; border-bottom: 1px solid var(--border); flex: none;
}
.net-head h2 { display: flex; align-items: center; gap: .45rem; margin: 0; font-size: 1.05rem; min-width: 0; }
.net-head h2 svg { width: 18px; height: 18px; color: #38bdf8; flex: none; }
.net-head-acts { display: flex; align-items: center; gap: .4rem; flex: none; }
.net-hint, .net-err { margin: .75rem 1rem; font-size: .88rem; color: var(--muted); }
.net-err { color: #ef4444; }
.net-primary {
display: grid; grid-template-columns: 1fr 1.4fr; gap: .75rem; margin: .85rem 1rem 0;
padding: .75rem .85rem; border: 1px solid color-mix(in srgb, #38bdf8 35%, var(--border));
border-radius: 10px; background: color-mix(in srgb, #38bdf8 8%, transparent);
}
.net-primary small { display: block; font-size: .72rem; color: var(--muted); margin-bottom: .2rem; }
.net-primary b { font-size: .95rem; word-break: break-all; }
.net-primary-ip { display: flex; align-items: center; justify-content: space-between; gap: .6rem; min-width: 0; }
.net-primary-ip .btn { flex: none; }
.net-list { flex: 1; overflow: auto; display: flex; flex-direction: column; gap: .55rem; min-height: 100px; padding: .75rem 1rem; }
.net-card { border: 1px solid var(--border); border-radius: 10px; padding: .65rem .75rem; }
.net-card.primary { border-color: color-mix(in srgb, #38bdf8 45%, var(--border)); }
.net-card.virtual { opacity: .72; }
.net-card > header { display: flex; align-items: flex-start; justify-content: space-between; gap: .6rem; margin-bottom: .45rem; }
.net-card-id { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
.net-card-id b { font-size: .9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.net-card-id small { color: var(--muted); font-size: .75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.net-card-id em {
display: inline-flex; align-items: center; gap: .25rem; width: fit-content;
font-style: normal; font-size: .7rem; padding: .1rem .45rem; border-radius: 999px;
background: color-mix(in srgb, #38bdf8 16%, transparent); color: #38bdf8;
}
.net-card-id em.dim { background: var(--surface-3); color: var(--muted); }
.net-card-id em svg { width: 11px; height: 11px; }
.net-card dl { display: grid; grid-template-columns: 72px 1fr; gap: .25rem .5rem; margin: 0; font-size: .82rem; }
.net-card dt { color: var(--muted); }
.net-card dd { margin: 0; min-width: 0; }
.net-card dd button { all: unset; cursor: pointer; color: var(--text); word-break: break-all; }
.net-card dd button:hover { color: #38bdf8; text-decoration: underline; }
.net-raw { margin: 0 1rem 1rem; font-size: .82rem; color: var(--muted); }
.net-raw pre {
margin: .4rem 0 0; max-height: 180px; overflow: auto; padding: .65rem .75rem;
border-radius: 8px; border: 1px solid var(--border); background: var(--surface-2);
color: var(--text); font-size: .75rem; white-space: pre-wrap; word-break: break-word;
}
.spin { animation: net-spin 1s linear infinite; }
@keyframes net-spin { to { transform: rotate(360deg); } }
@media (max-width: 640px) {
.net-primary { grid-template-columns: 1fr; }
}
</style>

View File

@@ -1,16 +1,22 @@
<script setup>
// 笔记详情模态框:编辑/预览切换 + Markdown 渲染600ms 防抖自动保存;关闭时空内容自动清理。
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { StickyNote, Trash2, X } from 'lucide-vue-next'
import { StickyNote, Trash2, X, Pencil, Eye } from 'lucide-vue-next'
import { call } from '../api'
import MarkdownView from './MarkdownView.vue'
// 笔记详情模态框打开即编辑600ms 防抖自动保存;关闭时空内容自动清理。
const props = defineProps({ note: { type: Object, default: null } })
const props = defineProps({
note: { type: Object, default: null },
// 打开时默认模式edit | preview
initialMode: { type: String, default: 'edit' }
})
const emit = defineEmits(['close', 'changed'])
const { t } = useI18n()
const noteId = ref(props.note?.id || 0)
const text = ref(props.note?.content || '')
const savedAt = ref('')
const preview = ref(props.initialMode === 'preview')
const area = ref(null)
let timer = 0
let saving = null
@@ -30,9 +36,13 @@ async function flush() {
}).catch(() => {})
await saving
}
async function setPreview(on) {
if (preview.value === on) return
if (on) await flush()
preview.value = on
}
async function close() {
await flush()
// 内容清空的已有笔记视为不再需要,顺手删除
if (noteId.value && !text.value.trim()) {
try { await call('DeleteNote', noteId.value); emit('changed') } catch {}
}
@@ -50,7 +60,7 @@ function onKey(e) {
}
onMounted(() => {
addEventListener('keydown', onKey)
requestAnimationFrame(() => area.value?.focus())
if (!preview.value) requestAnimationFrame(() => area.value?.focus())
})
onUnmounted(() => { removeEventListener('keydown', onKey); clearTimeout(timer) })
</script>
@@ -58,16 +68,21 @@ onUnmounted(() => { removeEventListener('keydown', onKey); clearTimeout(timer) }
<template>
<Teleport to="body">
<div class="overlay" @click.self="close">
<section class="modal note-modal">
<section class="modal note-modal" @click.stop>
<header class="nm-head">
<h2><StickyNote />{{ noteId ? t('noteEdit') : t('noteNew') }}</h2>
<small v-if="savedAt" class="nm-saved">{{ t('autoSaved') }} {{ savedAt }}</small>
<div class="tabs compact nm-mode">
<button type="button" :class="{ active: !preview }" :title="t('mdEdit')" @click="setPreview(false)"><Pencil /></button>
<button type="button" :class="{ active: preview }" :title="t('mdPreviewTab')" @click="setPreview(true)"><Eye /></button>
</div>
<div class="nm-tools">
<button v-if="noteId" type="button" class="nm-del" :title="t('delete')" @click="removeNote"><Trash2 /></button>
<button type="button" class="nm-close" :title="t('close')" @click="close"><X /></button>
</div>
</header>
<textarea ref="area" v-model="text" class="nm-area" :placeholder="t('notepadPlaceholder')" @input="onInput" />
<textarea v-show="!preview" ref="area" v-model="text" class="nm-area" :placeholder="t('mdPlaceholder')" @input="onInput" />
<MarkdownView v-if="preview" class="nm-preview md-preview-box" :source="text || t('mdEmpty')" />
</section>
</div>
</Teleport>

View File

@@ -37,6 +37,10 @@ const zh = {
logs: '运行日志',
settings: '设置',
navOverview: '概览',
navAddShortcut: '添加快捷入口',
navShortcutPicker: '选择快捷入口',
navShortcutHint: '把常用菜单钉到概览,方便一键到达',
navUnpinShortcut: '从概览移除',
navProjects: '项目',
navWork: '事务',
navSystem: '系统',
@@ -92,6 +96,20 @@ const zh = {
lpMyApps: '我的应用',
lpScanned: '检测到的服务',
lpAddApp: '添加应用',
netInfoBtn: '本机网络',
netInfoTitle: '本机网络信息',
netInfoHostname: '主机名',
netInfoPrimaryIP: '主局域网 IP',
netInfoPrimary: '主网卡',
netInfoVirtual: '虚拟网卡',
netInfoGateway: '网关',
netInfoCopyIP: '复制 IP',
netInfoCopyAll: '复制全部',
netInfoCopied: '已复制到剪贴板',
netInfoCopyFail: '复制失败',
netInfoEmpty: '未检测到可用网卡',
netInfoRaw: '原始 ipconfig 输出',
copy: '复制',
lpEditApp: '编辑应用',
lpRefresh: '刷新',
lpShowSys: '显示系统进程',
@@ -196,8 +214,8 @@ const zh = {
ticketStatus: { open: '待处理', in_progress: '处理中', resolved: '已解决', closed: '已关闭' },
ticketFlow: { start: '开始处理', resolve: '标记解决', close: '关闭', reopen: '重新打开' },
noTickets: '暂无工单',
aiGenPhTodo: '一句话描述AI 帮你拆成多条待办,回车生成...',
aiGenPhTicket: '一句话描述需求AI 帮你拆成多条工单,回车生成...',
aiGenPhTodo: '例如:国庆前交报告,中秋给家里打电话… AI 会按日期/节日拆条',
aiGenPhTicket: '例如:十一前改登录,春节后做导出… AI 会按日期/节日拆条',
aiGenBtn: 'AI 生成',
aiGenBusy: '生成中...',
aiGenPreviewTitle: 'AI 生成预览',
@@ -208,6 +226,7 @@ const zh = {
aiGenNeedProject: '工单必须关联项目,请先选择归属项目',
aiGenSaveN: '保存所选({n}',
aiGenSavedToast: '已保存 {n} 条',
aiGenDateHint: '日期由 AI 根据你的描述和节假日推断(不一定是今天,多天会拆开),可直接改。',
today: '今天',
selectDate: '选择日期',
noSchedule: '当日无排期',
@@ -232,6 +251,7 @@ const zh = {
noteEdit: '编辑笔记',
noteEmpty: '还没有笔记,点右上角新建一条',
noteUntitled: '(空白笔记)',
noteExpand: '展开查看',
noteDeleteConfirm: '删除这条笔记?',
notesPage: '笔记',
notesSubtitle: '共 {n} 条笔记,点击卡片编辑',
@@ -768,9 +788,11 @@ const zh = {
adminStatTeams: '团队数',
adminStatDAU: '今日日活',
adminStatTokens: '今日 Token',
adminStatCached: '今日缓存命中',
adminDAUSeries: '近 14 日日活',
adminTokenSeries: '近 14 日 Token',
adminCalls: '调用次数',
adminCached: '缓存命中',
adminProviderPie: 'AI 提供商用量分布',
adminDataPie: '云端数据构成',
adminViewDetail: '查看详情',
@@ -1097,6 +1119,10 @@ const en = {
logs: 'Activity Log',
settings: 'Settings',
navOverview: 'Overview',
navAddShortcut: 'Add shortcut',
navShortcutPicker: 'Pick shortcuts',
navShortcutHint: 'Pin frequent menus to Overview for one-tap access',
navUnpinShortcut: 'Remove from Overview',
navProjects: 'Projects',
navWork: 'Work',
navSystem: 'System',
@@ -1152,6 +1178,20 @@ const en = {
lpMyApps: 'My apps',
lpScanned: 'Detected services',
lpAddApp: 'Add app',
netInfoBtn: 'Network info',
netInfoTitle: 'Local network info',
netInfoHostname: 'Hostname',
netInfoPrimaryIP: 'Primary LAN IP',
netInfoPrimary: 'Primary',
netInfoVirtual: 'Virtual',
netInfoGateway: 'Gateway',
netInfoCopyIP: 'Copy IP',
netInfoCopyAll: 'Copy all',
netInfoCopied: 'Copied to clipboard',
netInfoCopyFail: 'Copy failed',
netInfoEmpty: 'No adapters found',
netInfoRaw: 'Raw ipconfig output',
copy: 'Copy',
lpEditApp: 'Edit app',
lpRefresh: 'Refresh',
lpShowSys: 'Show system processes',
@@ -1256,8 +1296,8 @@ const en = {
ticketStatus: { open: 'Open', in_progress: 'In progress', resolved: 'Resolved', closed: 'Closed' },
ticketFlow: { start: 'Start', resolve: 'Resolve', close: 'Close', reopen: 'Reopen' },
noTickets: 'No tickets yet',
aiGenPhTodo: 'Describe in one sentence and AI splits it into todos. Enter to generate...',
aiGenPhTicket: 'Describe in one sentence and AI splits it into tickets. Enter to generate...',
aiGenPhTodo: 'e.g. submit the report before National Day, call home on Mid-Autumn… AI splits by date/holiday',
aiGenPhTicket: 'e.g. fix login before Oct 1, export after Spring Festival… AI splits by date/holiday',
aiGenBtn: 'AI generate',
aiGenBusy: 'Generating...',
aiGenPreviewTitle: 'AI generated preview',
@@ -1268,6 +1308,7 @@ const en = {
aiGenNeedProject: 'Tickets must belong to a project. Pick one first.',
aiGenSaveN: 'Save selected ({n})',
aiGenSavedToast: 'Saved {n} item(s)',
aiGenDateHint: 'Dates are inferred from your wording and holidays (not always today; multi-day items are split). You can edit them.',
today: 'Today',
selectDate: 'Select a date',
noSchedule: 'Nothing scheduled',
@@ -1292,6 +1333,7 @@ const en = {
noteEdit: 'Edit note',
noteEmpty: 'No notes yet — create one from the top right',
noteUntitled: '(Blank note)',
noteExpand: 'Open full view',
noteDeleteConfirm: 'Delete this note?',
notesPage: 'Notes',
notesSubtitle: '{n} notes in total, click a card to edit',
@@ -1828,9 +1870,11 @@ const en = {
adminStatTeams: 'Teams',
adminStatDAU: 'DAU today',
adminStatTokens: 'Tokens today',
adminStatCached: 'Cache hits today',
adminDAUSeries: 'DAU (14d)',
adminTokenSeries: 'Tokens (14d)',
adminCalls: 'Calls',
adminCached: 'Cache hits',
adminProviderPie: 'AI provider usage',
adminDataPie: 'Cloud data breakdown',
adminViewDetail: 'View detail',

View File

@@ -653,7 +653,7 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .
@media(min-width:1700px){.wb-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
.wb-col{margin-bottom:0}
.wb-col .section-head h2 small{margin-left:8px;color:var(--muted);font-weight:normal;font-size:12px}
.wb-col .section-head .btn{height:32px;padding:0 11px;font-size:12px}
.wb-col .section-head .btn{height:32px;padding:0 11px;font-size:12px;white-space:nowrap;flex-shrink:0}
.wb-col .section-head .btn svg{width:13px}
.wb-list{display:grid;gap:8px;margin-top:14px}
.wb-item{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:10px;align-items:center;background:var(--surface-2);border:1px solid var(--glass-border);border-radius:11px;padding:10px 12px;overflow:hidden;transition:border-color .18s}
@@ -678,11 +678,12 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .
.wb-note-saved{color:var(--green);font-size:11px}
.wb-note-dirty{color:var(--yellow);font-size:11px}
.wb-note-panel{display:flex;flex-direction:column;min-height:0}
.wb-note-panel .section-head{flex-wrap:wrap;gap:8px}
.wb-note-acts{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-left:auto}
.wb-note-acts .btn{height:30px;padding:0 10px;font-size:12px}
.wb-note-acts .btn svg{width:13px;height:13px}
.wb-note-body{display:grid;grid-template-columns:minmax(120px,38%) 1fr;gap:10px;margin-top:12px;min-height:180px}
.wb-note-head{flex-wrap:nowrap;gap:8px;align-items:center}
.wb-note-head h2{flex:none;min-width:0}
.wb-note-acts{display:flex;align-items:center;gap:6px;flex-wrap:nowrap;margin-left:auto;flex:1;justify-content:flex-end;min-width:0}
.wb-note-acts .btn{height:30px;padding:0 10px;font-size:12px;flex:none;white-space:nowrap}
.wb-note-acts .btn svg{width:13px;height:13px;flex:none}
.wb-note-body{display:grid;grid-template-columns:minmax(120px,38%) 1fr;gap:10px;margin-top:12px;min-height:0}
.wb-note-list{display:flex;flex-direction:column;gap:4px;max-height:220px;overflow:auto;padding:4px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2)}
.wb-note-item{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:100%;text-align:left;padding:8px 9px;border:0;border-radius:7px;background:transparent;color:var(--text);cursor:pointer;font:inherit}
.wb-note-item:hover{background:var(--surface-3)}
@@ -690,13 +691,19 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .
.wb-note-item b{font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}
.wb-note-item time{font-size:10.5px;color:var(--muted)}
.wb-note-list-empty{padding:16px 8px;text-align:center;color:var(--muted);font-size:12px}
.wb-note-body .wb-note{min-height:180px;height:100%;resize:vertical}
/* 编辑/预览切换 + Markdown 预览框 */
.wb-note-tabs{margin-right:2px}
.wb-note-tabs button{display:inline-flex;align-items:center;gap:5px;height:30px;padding:0 10px;font-size:12px}
.wb-note-tabs button svg{width:13px;height:13px}
.wb-note-body .wb-note-preview{min-height:180px;max-height:none;height:100%;overflow:auto}
.wb-note-tabs{margin-right:2px;flex:none}
.wb-note-tabs button{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0}
.wb-note-tabs button svg{width:14px;height:14px}
.wb-note-stage{position:relative;min-height:0;max-height:220px;display:flex;flex-direction:column}
.wb-note-stage .wb-note{min-height:160px;max-height:220px;height:220px;resize:none}
.wb-note-stage .wb-note-preview{min-height:160px;max-height:220px;height:220px;overflow:auto;cursor:pointer}
.wb-note-stage.preview:hover .wb-note-preview{border-color:var(--primary)}
.wb-note-expand{position:absolute;right:10px;bottom:10px;display:inline-flex;align-items:center;gap:5px;height:28px;padding:0 10px;border-radius:7px;border:1px solid var(--border);background:color-mix(in srgb,var(--surface) 88%,transparent);color:var(--text);font:inherit;font-size:11.5px;cursor:pointer;backdrop-filter:blur(6px)}
.wb-note-expand svg{width:12px;height:12px}
.wb-note-expand:hover{border-color:var(--primary);color:var(--primary)}
@media (max-width:980px){
.wb-note-head{flex-wrap:wrap}
.wb-note-acts{flex-wrap:wrap;justify-content:flex-start;width:100%;margin-left:0}
.wb-note-body{grid-template-columns:1fr}
.wb-note-list{max-height:120px;flex-direction:row;flex-wrap:wrap}
.wb-note-item{width:auto;max-width:46%}
@@ -860,17 +867,21 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .
.nc-item b{flex:1;min-width:0;font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.nc-item time{flex:none;font-size:10.5px;color:var(--muted);font-variant-numeric:tabular-nums}
/* 笔记详情模态框 */
.note-modal{width:560px;max-width:94vw;padding:0;overflow:hidden}
.nm-head{display:flex;align-items:center;gap:10px;padding:13px 16px;border-bottom:1px solid var(--border)}
.note-modal{width:min(720px,94vw);max-width:94vw;padding:0;overflow:hidden;display:flex;flex-direction:column;max-height:86vh}
.nm-head{display:flex;align-items:center;gap:10px;padding:13px 16px;border-bottom:1px solid var(--border);flex-wrap:wrap}
.nm-head h2{display:flex;align-items:center;gap:8px;margin:0;font-size:14px}
.nm-head h2 svg{width:15px;height:15px;color:var(--yellow)}
.nm-saved{color:var(--muted);font-size:11px}
.nm-tools{margin-left:auto;display:flex;gap:6px}
.nm-mode{margin-left:auto}
.nm-mode button{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0}
.nm-mode button svg{width:14px;height:14px}
.nm-tools{display:flex;gap:6px}
.nm-tools button{display:grid;place-items:center;width:28px;height:28px;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}
.nm-tools button:hover{background:var(--surface-3);color:var(--text)}
.nm-tools .nm-del:hover{color:var(--red)}
.nm-tools svg{width:15px;height:15px}
.note-modal .nm-area{display:block;width:100%;height:340px;border:0;border-radius:0;background:transparent;color:var(--text);padding:16px;margin:0;resize:none;outline:none;font:inherit;font-size:13px;line-height:1.75}
.note-modal .nm-area{display:block;width:100%;height:min(520px,58vh);border:0;border-radius:0;background:transparent;color:var(--text);padding:16px;margin:0;resize:none;outline:none;font:inherit;font-size:13px;line-height:1.75}
.note-modal .nm-preview{height:min(520px,58vh);max-height:none;border:0;border-radius:0;overflow:auto;padding:16px 18px;background:transparent;box-shadow:none}
.ai-input-row{display:flex;gap:10px;align-items:flex-end;padding-top:14px;border-top:1px solid var(--border)}
.ai-input-row textarea{flex:1;min-height:52px;max-height:180px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--text);padding:12px;resize:vertical;outline:none;font:inherit;font-size:13px;line-height:1.6}
.ai-input-row textarea:focus{border-color:var(--primary)}
@@ -1025,7 +1036,11 @@ input[type=checkbox],input[type=radio]{accent-color:var(--primary)}
.form-error{margin:14px 26px 0;padding:9px 13px;border-radius:10px;background:rgba(240,94,104,.12);border:1px solid rgba(240,94,104,.32);color:#ff9aa2;font-size:12.5px}
/* 按钮:主按钮渐变 + 光泽,悬停轻微上浮 */
.btn{border-radius:11px;font-weight:600}
.btn{border-radius:11px;font-weight:600;white-space:nowrap;flex-shrink:0}
.btn.sm{height:32px;padding:0 12px;font-size:12.5px;gap:6px}
.btn.sm svg{width:14px;height:14px;flex:none}
.btn.icon{width:32px;height:32px;padding:0;gap:0}
.btn.icon svg{width:15px;height:15px}
.btn.primary{background:linear-gradient(140deg,#8f83f9,#6e5ff2 52%,#5b4ce6);border-color:rgba(255,255,255,.16);box-shadow:0 10px 26px rgba(101,87,232,.36),inset 0 1px 0 rgba(255,255,255,.24);text-shadow:0 1px 2px rgba(0,0,0,.16)}
.btn.primary:hover:not(:disabled){filter:brightness(1.08);transform:translateY(-1px);box-shadow:0 14px 30px rgba(101,87,232,.44),inset 0 1px 0 rgba(255,255,255,.28)}
.btn.primary:active:not(:disabled){transform:translateY(0) scale(.985);filter:brightness(.98)}
@@ -1484,6 +1499,7 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.lp-head{display:flex;flex-direction:column;align-items:stretch;justify-content:flex-start;gap:12px}
.lp-head-top{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
.lp-tools{display:flex;align-items:center;gap:10px;flex:none;flex-wrap:nowrap}
.lp-tools .btn{white-space:nowrap;flex-shrink:0}
.lp-filters{display:flex;align-items:center;gap:10px}
.lp-search{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface-2);color:var(--muted);min-width:0}
.lp-search svg{width:13px;height:13px;flex:none}
@@ -1624,6 +1640,29 @@ html[data-theme=light] .lp-log-line{color:#1f2937}
.sidebar .sub-nav a svg{width:16px;height:16px;flex:none}
.sidebar .sub-nav a:hover{background:var(--surface-3);color:var(--text)}
.sidebar .sub-nav a.active{background:color-mix(in srgb,var(--primary) 15%,transparent);color:#a9a2ff}
.sidebar .sub-nav a.shortcut{border:1px dashed color-mix(in srgb,var(--primary) 28%,transparent)}
.nav-unpin{margin-left:4px;display:grid;place-items:center;width:22px;height:22px;border:0;border-radius:6px;background:transparent;color:var(--muted);cursor:pointer;padding:0;flex:none}
.nav-unpin svg{width:12px!important;height:12px!important}
.nav-unpin:hover{background:rgba(240,84,84,.12);color:var(--red)}
.nav-add-shortcut{display:flex;align-items:center;gap:8px;height:34px;margin:6px 0 0;padding:0 10px;border:1px dashed var(--border);border-radius:8px;background:transparent;color:var(--muted);font:inherit;font-size:12px;cursor:pointer;white-space:nowrap;flex-shrink:0}
.nav-add-shortcut svg{width:14px;height:14px;flex:none}
.nav-add-shortcut:hover{border-color:var(--primary);color:var(--primary);background:color-mix(in srgb,var(--primary) 8%,transparent)}
.nav-shortcut-modal{width:min(420px,92vw);max-height:min(560px,80vh);padding:0;overflow:hidden;display:flex;flex-direction:column}
.nav-shortcut-modal .modal-head{flex:none}
.nav-shortcut-modal .modal-head h2{display:flex;align-items:center;gap:8px;margin:0;font-size:15px}
.nav-shortcut-modal .modal-head h2 svg{width:16px;height:16px;color:var(--primary)}
.nav-shortcut-hint{margin:0;padding:0 20px 10px;font-size:12.5px;color:var(--muted);line-height:1.45}
.nav-shortcut-list{overflow:auto;display:flex;flex-direction:column;gap:4px;padding:0 12px 16px;min-height:0}
.nav-shortcut-item{display:flex;align-items:center;gap:10px;min-height:40px;height:40px;padding:0 12px;border:1px solid transparent;border-radius:9px;background:var(--surface-2);color:var(--text);font:inherit;font-size:13px;cursor:pointer;text-align:left;width:100%;flex-shrink:0}
.nav-shortcut-item:hover{border-color:var(--border);background:var(--surface-3)}
.nav-shortcut-item.on{border-color:color-mix(in srgb,var(--primary) 40%,var(--border));background:color-mix(in srgb,var(--primary) 12%,transparent);color:#a9a2ff}
.nav-shortcut-ico{width:16px;height:16px;flex:none;color:var(--muted)}
.nav-shortcut-item.on .nav-shortcut-ico{color:#a9a2ff}
.nav-shortcut-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
.nav-shortcut-group{flex:none;font-size:11px;color:var(--muted);white-space:nowrap}
.nav-shortcut-mark{width:14px;height:14px;flex:none;color:var(--muted);margin-left:4px}
.nav-shortcut-mark.on{color:var(--primary)}
.side-sub{position:relative}
.sub-caret{margin-left:auto;display:grid;place-items:center;width:20px;height:20px;border:0;border-radius:6px;background:transparent;color:inherit;cursor:pointer;padding:0}
.sub-caret svg{width:13px;height:13px;transition:transform .18s}
.sub-caret.open svg{transform:rotate(180deg)}

File diff suppressed because one or more lines are too long

View File

@@ -160,6 +160,7 @@ const tokenChart = computed(() => {
series: [
{ name: 'Prompt', type: 'bar', stack: 'tok', barMaxWidth: 16, itemStyle: { color: '#7b73ff', borderRadius: [0, 0, 0, 0] }, data: s.map(p => p.promptTokens || 0) },
{ name: 'Completion', type: 'bar', stack: 'tok', barMaxWidth: 16, itemStyle: { color: '#4fd1a1', borderRadius: [3, 3, 0, 0] }, data: s.map(p => p.completionTokens || 0) },
{ name: t('adminCached'), type: 'line', smooth: true, showSymbol: false, lineStyle: { width: 2, color: '#23b5d3' }, itemStyle: { color: '#23b5d3' }, data: s.map(p => p.cachedTokens || 0) },
{ name: t('adminCalls'), type: 'line', yAxisIndex: 1, smooth: true, showSymbol: false, lineStyle: { width: 2, color: '#f4c84a' }, itemStyle: { color: '#f4c84a' }, data: s.map(p => p.calls || 0) }
]
}
@@ -307,6 +308,7 @@ onUnmounted(() => { offFilesDropped?.() })
<div class="stat"><span>{{ t('adminStatTeams') }}</span><b>{{ overview.teamCount }}</b></div>
<div class="stat"><span>{{ t('adminStatDAU') }}</span><b>{{ overview.dauToday }}</b></div>
<div class="stat"><span>{{ t('adminStatTokens') }}</span><b>{{ (overview.tokenToday?.promptTokens||0)+(overview.tokenToday?.completionTokens||0) }}</b></div>
<div class="stat"><span>{{ t('adminStatCached') }}</span><b>{{ overview.tokenToday?.cachedTokens||0 }}</b></div>
</div>
<div class="chart-grid">
<div class="chart-card">
@@ -501,7 +503,7 @@ onUnmounted(() => { offFilesDropped?.() })
.admin-tabs{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem}
.admin-tabs button{display:inline-flex;align-items:center;gap:.35rem;padding:.45rem .75rem;border-radius:8px;border:1px solid var(--border);background:transparent;color:inherit;cursor:pointer}
.admin-tabs button.on{background:var(--accent, #3b82f6);color:#fff;border-color:transparent}
.stat-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.75rem;margin-bottom:1.25rem}
.stat-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:.75rem;margin-bottom:1.25rem}
.stat{padding:1rem;border:1px solid var(--border);border-radius:10px;display:flex;flex-direction:column;gap:.35rem}
.stat b{font-size:1.6rem}
.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}

View File

@@ -3,13 +3,14 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Browser } from '@wailsio/runtime'
import { Plus, RefreshCw, Play, Square, Pencil, Trash2, X, Hexagon, Zap, FileCode2, Coffee, Database, Globe, Boxes, Box, Cpu, MemoryStick, HardDrive, Sparkles, Search, ExternalLink, FolderGit2, ScrollText, LoaderCircle, Image, ImageUp, Tags, Settings2, RotateCcw, ChevronRight, Package, Pin } from 'lucide-vue-next'
import { Plus, RefreshCw, Play, Square, Pencil, Trash2, X, Hexagon, Zap, FileCode2, Coffee, Database, Globe, Boxes, Box, Cpu, MemoryStick, HardDrive, Sparkles, Search, ExternalLink, FolderGit2, ScrollText, LoaderCircle, Image, ImageUp, Tags, Settings2, RotateCcw, ChevronRight, Package, Pin, Network } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
import PackCmdsModal from '../components/PackCmdsModal.vue'
import LaunchAppFormModal from '../components/LaunchAppFormModal.vue'
import RefreshIntervalPicker from '../components/RefreshIntervalPicker.vue'
import NetworkInfoModal from '../components/NetworkInfoModal.vue'
import { getPackCmds } from '../packCmds'
// 启动台:扫描本机监听端口的服务 + 管理保存的应用(启动/停止/资源占用)。
@@ -18,6 +19,7 @@ const store = useAppStore()
const route = useRoute()
const router = useRouter()
const aiOpen = ref(false)
const netInfoOpen = ref(false)
const entries = ref([])
const loading = ref(false)
const nameQ = ref('')
@@ -510,6 +512,7 @@ watch(() => route.query.pin, async v => {
<div class="lp-head-top">
<div><h1>{{ t('launchpad') }}</h1><p>{{ t('launchpadSubtitle') }}</p></div>
<div class="lp-tools">
<button class="btn secondary" @click="netInfoOpen = true"><Network />{{ t('netInfoBtn') }}</button>
<button class="btn secondary" @click="aiOpen = true"><Sparkles />{{ t('aiScopeBtn') }}</button>
<RefreshIntervalPicker v-model="refreshSec" />
<button class="btn secondary" :disabled="loading" @click="load"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
@@ -705,6 +708,7 @@ watch(() => route.query.pin, async v => {
</div>
</Teleport>
<LaunchAppFormModal :initial="editing" :initial-error="editErr" @close="editing = null; editErr = ''" @saved="onFormSaved" />
<NetworkInfoModal v-if="netInfoOpen" @close="netInfoOpen = false" />
<AIScopeDrawer v-if="aiOpen" kind="launchpad" :title="t('launchpad')" @close="aiOpen = false" />
</div>
</template>

View File

@@ -2,10 +2,11 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Star, Folder, Code2, GitCommitHorizontal, ListTodo, TicketCheck, StickyNote, ArrowRight, Circle, CircleDot, CircleCheck, Play, Check, CalendarDays, Plus, Bell, Save, Pencil, Eye } from 'lucide-vue-next'
import { Star, Folder, Code2, GitCommitHorizontal, ListTodo, TicketCheck, StickyNote, ArrowRight, Circle, CircleDot, CircleCheck, Play, Check, CalendarDays, Plus, Bell, Save, Pencil, Eye, Maximize2 } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import AIDayPanel from '../components/AIDayPanel.vue'
import MarkdownView from '../components/MarkdownView.vue'
import NoteModal from '../components/NoteModal.vue'
import { call } from '../api'
import { useAppStore } from '../store'
@@ -20,8 +21,10 @@ const noteText = ref('')
const noteSavedAt = ref('')
const noteDirty = ref(false)
const noteSaving = ref(false)
// 编辑/预览切换(预览走 Markdown 渲染),选择记忆在本地
// 面板内编辑/预览;点击内容区打开模态框查看全文
const notePreview = ref(localStorage.getItem('cc-wb-note-preview') === '1')
const noteModal = ref(false)
const noteModalMode = ref('preview')
const messages = ref([])
let noteTimer
@@ -79,13 +82,31 @@ function editNote() {
clearTimeout(noteTimer)
noteTimer = setTimeout(() => { saveNote(false) }, 800)
}
// 切到预览前先落盘,避免防抖窗口内的改动丢失
async function setNotePreview(on) {
if (notePreview.value === on) return
if (on && noteDirty.value) await saveNote(false)
notePreview.value = on
localStorage.setItem('cc-wb-note-preview', on ? '1' : '0')
}
async function openNoteModal(mode = 'preview') {
if (noteDirty.value) await saveNote(false)
noteModalMode.value = mode
noteModal.value = true
}
async function onNoteModalChanged() {
await loadNotes()
if (note.value?.id) {
const fresh = notes.value.find(n => n.id === note.value.id)
if (fresh) {
note.value = fresh
noteText.value = fresh.content || ''
noteDirty.value = false
}
} else if (notes.value.length) {
note.value = notes.value[0]
noteText.value = note.value.content || ''
}
}
async function saveNote(manual = true) {
if (noteSaving.value) return
noteSaving.value = true
@@ -206,17 +227,18 @@ onUnmounted(() => clearTimeout(noteTimer))
</section>
<section class="panel wb-col wb-note-panel">
<div class="section-head">
<div class="section-head wb-note-head">
<h2><StickyNote class="panel-icon" />{{ t('notepad') }}<small>{{ notes.length }}</small></h2>
<div class="wb-note-acts">
<small v-if="noteDirty" class="wb-note-dirty">{{ t('noteUnsaved') }}</small>
<small v-else-if="noteSavedAt" class="wb-note-saved">{{ t('autoSaved') }} {{ noteSavedAt }}</small>
<div class="tabs compact wb-note-tabs">
<button type="button" :class="{ active: !notePreview }" @click="setNotePreview(false)"><Pencil />{{ t('mdEdit') }}</button>
<button type="button" :class="{ active: notePreview }" @click="setNotePreview(true)"><Eye />{{ t('mdPreviewTab') }}</button>
<button type="button" :class="{ active: !notePreview }" :title="t('mdEdit')" @click="setNotePreview(false)"><Pencil /></button>
<button type="button" :class="{ active: notePreview }" :title="t('mdPreviewTab')" @click="setNotePreview(true)"><Eye /></button>
</div>
<button type="button" class="btn secondary" :title="t('noteExpand')" @click="openNoteModal(notePreview ? 'preview' : 'edit')"><Maximize2 /></button>
<button type="button" class="btn secondary" :title="t('noteNew')" @click="newNote"><Plus /></button>
<button type="button" class="btn primary" :disabled="noteSaving || !noteDirty" :title="t('save')" @click="saveNote(true)"><Save />{{ t('save') }}</button>
<button type="button" class="btn primary" :disabled="noteSaving || !noteDirty" :title="t('save')" @click="saveNote(true)"><Save /></button>
<button type="button" class="btn secondary" @click="router.push('/notes')">{{ t('viewAll') }}<ArrowRight /></button>
</div>
</div>
@@ -235,8 +257,11 @@ onUnmounted(() => clearTimeout(noteTimer))
</button>
<div v-if="!notes.length" class="wb-note-list-empty">{{ t('noteEmpty') }}</div>
</aside>
<textarea v-show="!notePreview" v-model="noteText" class="wb-note" :placeholder="t('mdPlaceholder')" @input="editNote" />
<MarkdownView v-if="notePreview" class="wb-note-preview md-preview-box" :source="noteText || t('mdEmpty')" />
<div class="wb-note-stage" :class="{ preview: notePreview }" @click="notePreview && openNoteModal('preview')">
<textarea v-show="!notePreview" v-model="noteText" class="wb-note" :placeholder="t('mdPlaceholder')" @input="editNote" @click.stop />
<MarkdownView v-if="notePreview" class="wb-note-preview md-preview-box" :source="noteText || t('mdEmpty')" />
<button v-if="notePreview" type="button" class="wb-note-expand" @click.stop="openNoteModal('preview')"><Maximize2 />{{ t('noteExpand') }}</button>
</div>
</div>
</section>
@@ -254,4 +279,11 @@ onUnmounted(() => clearTimeout(noteTimer))
</section>
</div>
</div>
<NoteModal
v-if="noteModal"
:note="note"
:initial-mode="noteModalMode"
@close="noteModal = false; onNoteModalChanged()"
@changed="onNoteModalChanged"
/>
</template>