模块介绍 / 今日规划 / 下班日报 / 团队摘要 生成成功后没有上报,也几乎不写「AI」日志。
流结束时的 usage 用了非阻塞发送,通道一满就被丢掉;很多服务商默认还不在流里带 usage。 现在每次真正打到模型的调用都会: 在本地日志(分类 AI,消息 AI 调用完成)写下 scene / prompt / completion / cached 登录后同步上报到后台日汇总
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
195
frontend/src/components/NetworkInfoModal.vue
Normal file
195
frontend/src/components/NetworkInfoModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user