更新若干功能

This commit is contained in:
李琦
2026-08-15 17:18:00 +08:00
parent 4954295961
commit 6a81e479ef
77 changed files with 8165 additions and 2372 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 } 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 } from 'lucide-vue-next'
import { useAppStore } from './store'
import DatabaseSetup from './components/DatabaseSetup.vue'
import BrowserBlocked from './components/BrowserBlocked.vue'
@@ -14,6 +14,7 @@ import AboutModal from './components/AboutModal.vue'
import MessageBell from './components/MessageBell.vue'
import TaskCenter from './components/TaskCenter.vue'
import NoteCenter from './components/NoteCenter.vue'
import LocalPackCenter from './components/LocalPackCenter.vue'
import TeamSwitcher from './components/TeamSwitcher.vue'
import TitleBar from './components/TitleBar.vue'
import { call, isNative, on } from './api'
@@ -25,6 +26,9 @@ const { t, locale } = useI18n()
const native = isNative()
const quickOpen = ref(false)
const aboutOpen = ref(false)
const exitOpen = ref(false)
const updateInfo = ref(null)
const updateBusy = ref(false)
const appVersion = ref('1.0.0')
const displayedTask = ref(null)
let off
@@ -47,9 +51,17 @@ function openSettings() {
router.push('/settings')
}
// 侧边栏底部退出:与原生菜单/托盘「退出」一致,绕过最小化到托盘直接退出应用
async function quitApp() {
if (!confirm(t('quitConfirm'))) return
// 侧栏头像下退出:模态框选择「退出登录」或「退出程序」
async function exitLogout() {
exitOpen.value = false
try {
await call('SyncLogout')
await store.refreshSyncStatus()
store.showToast({ type: 'success', key: 'logoutToast' })
} catch { /* ignore */ }
}
async function exitQuit() {
exitOpen.value = false
try { await call('QuitApp') } catch { /* 预览模式无原生桥 */ }
}
@@ -79,14 +91,18 @@ const navGroups = [
] },
{ id: 'system', icon: Wrench, label: 'navSystem', items: [
{ to: '/launchpad', icon: Rocket, label: 'launchpad' },
{ to: '/ports', icon: Activity, label: 'portMonitor' },
{ to: '/pack-tasks', icon: Package, label: 'packTasksPage' },
{ to: '/logs', icon: ScrollText, label: 'logs' }
] },
{ id: 'settings', icon: Settings, label: 'settings', items: [
{ to: '/admin', icon: Shield, label: 'adminTitle', adminOnly: true, match: r => r.path === '/admin' },
{ to: '/settings?tab=rules', icon: ListFilter, label: 'tabRules', match: r => r.path === '/settings' && settingsTab(r) === 'rules' },
{ to: '/settings?tab=appearance', icon: Palette, label: 'tabAppearance', match: r => r.path === '/settings' && settingsTab(r) === 'appearance' },
{ to: '/settings?tab=ai', icon: Bot, label: 'aiAnalysis', match: r => r.path === '/settings' && settingsTab(r) === 'ai' },
// 全局文件存储配置:权威数据在服务器,仅管理员(云端账号 id=1可见可改
{ to: '/settings?tab=filestorage', icon: CloudUpload, label: 'tabFileStorage', adminOnly: true, match: r => r.path === '/settings' && settingsTab(r) === 'filestorage' },
{ to: '/settings?tab=kindicons', icon: Image, label: 'tabKindIcons', adminOnly: true, match: r => r.path === '/settings' && settingsTab(r) === 'kindicons' },
{ to: '/settings?tab=database', icon: Database, label: 'tabDatabase', match: r => r.path === '/settings' && settingsTab(r) === 'database' }
] }
]
@@ -161,12 +177,28 @@ onMounted(async () => {
store.refreshSyncStatus()
}
}),
on('menu:set', p => p?.key && updateSetting(p.key, p.value))
on('menu:set', p => p?.key && updateSetting(p.key, p.value)),
on('app:update-available', p => {
if (p && !p.upToDate && !p.skipped) updateInfo.value = p
})
]
await store.boot()
locale.value = store.settings.locale || 'zh-CN'
try { appVersion.value = await call('GetAppVersion') } catch { /* keep fallback */ }
})
async function installUpdate() {
updateBusy.value = true
try {
await call('DownloadAndInstallUpdate')
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
updateBusy.value = false
}
}
async function skipUpdate() {
try { await call('SkipAppUpdate', updateInfo.value?.latest || '') } catch {}
updateInfo.value = null
}
onUnmounted(() => {
removeEventListener('keydown', onGlobalKey)
removeEventListener('click', onFlyoutAway)
@@ -211,7 +243,7 @@ watch(activeTask, task => {
<MessageBell />
<TeamSwitcher />
</div>
<button class="rail-user" :class="{ active: route.path === '/profile' }" :title="store.syncStatus.loggedIn ? store.syncStatus.username : t('loginNow')" @click="store.openAccount(router)">
<button class="rail-user" :title="store.syncStatus.loggedIn ? store.syncStatus.username : t('loginNow')" @click="store.openAccount(router)">
<span class="user-avatar">
<img v-if="store.avatarSrc" :src="store.avatarSrc" alt="" />
<b v-else-if="store.syncStatus.username">{{ store.syncStatus.username[0].toUpperCase() }}</b>
@@ -220,6 +252,10 @@ watch(activeTask, task => {
</span>
<em v-if="store.syncStatus.loggedIn && store.syncStatus.pending > 0" class="user-pending rail-pending" :title="t('pendingSync', { n: store.syncStatus.pending })">{{ store.syncStatus.pending }}</em>
</button>
<div class="rail-foot">
<LocalPackCenter />
<button type="button" class="rail-quit" :title="t('exitMenu')" :disabled="!native" @click="exitOpen = true"><Power /></button>
</div>
</div>
<div class="side-sub">
<div class="sub-brand"><b>{{ t('app') }}</b></div>
@@ -239,7 +275,6 @@ watch(activeTask, task => {
</nav>
<div class="sidebar-bottom">
<span class="version"><i />v{{ appVersion }}</span>
<button class="quick-settings-btn quit-app-btn" :title="t('quitApp')" :disabled="!native" @click="quitApp"><Power /></button>
<button class="quick-settings-btn" :title="t('quickSettings')" @click="quickOpen = !quickOpen"><SlidersHorizontal /></button>
<section v-if="quickOpen" class="quick-settings popover-glass">
<header>
@@ -281,6 +316,37 @@ watch(activeTask, task => {
</template>
</div>
</aside>
<Teleport to="body">
<div v-if="updateInfo" class="overlay" @click.self="skipUpdate">
<section class="modal exit-modal" @click.stop>
<header class="modal-head">
<h2>{{ t('appUpdateTitle') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="skipUpdate"><X /></button>
</header>
<p class="exit-hint">{{ t('appUpdateBody', { current: updateInfo.current, latest: updateInfo.latest }) }}</p>
<pre v-if="updateInfo.changelog" style="white-space:pre-wrap;font-size:.85rem;opacity:.8;max-height:160px;overflow:auto">{{ updateInfo.changelog }}</pre>
<div class="exit-actions">
<button type="button" class="btn secondary" :disabled="updateBusy" @click="skipUpdate">{{ t('appUpdateSkip') }}</button>
<button type="button" class="btn primary" :disabled="updateBusy" @click="installUpdate">{{ updateBusy ? t('appUpdateDownloading') : t('appUpdateInstall') }}</button>
</div>
</section>
</div>
</Teleport>
<Teleport to="body">
<div v-if="exitOpen" class="overlay" @click.self="exitOpen = false">
<section class="modal exit-modal" @click.stop>
<header class="modal-head">
<h2><Power />{{ t('exitMenu') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="exitOpen = false"><X /></button>
</header>
<p class="exit-hint">{{ t('exitMenuHint') }}</p>
<div class="exit-actions">
<button v-if="store.syncStatus.loggedIn" type="button" class="btn secondary" @click="exitLogout">{{ t('logoutBtn') }}</button>
<button type="button" class="btn danger" :disabled="!native" @click="exitQuit">{{ t('quitApp') }}</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

@@ -52,3 +52,40 @@ export async function copyText(text) {
document.body.removeChild(ta)
if (!ok) throw new Error('COPY_FAILED')
}
/** 远程 http(s) 图经本地 AssetServer 代理,避免 WebView 直接拉外站破图。 */
export function displayUrl(url) {
const v = String(url || '').trim()
if (!v) return ''
if (/^(data:|blob:)/i.test(v)) return v
if (/^https?:\/\//i.test(v) && isNative()) {
return `/__ccimg?u=${encodeURIComponent(v)}`
}
return v
}
/** 远程图优先走 Go 拉成 dataURL最稳失败再回退 /__ccimg。 */
const _imgSrcCache = new Map()
export async function resolveImageSrc(url) {
const v = String(url || '').trim()
if (!v) return ''
if (/^(data:|blob:)/i.test(v)) return v
if (!/^https?:\/\//i.test(v) || !isNative()) return v
if (_imgSrcCache.has(v)) return _imgSrcCache.get(v)
const p = (async () => {
try {
return await call('FetchRemoteImageAsDataURL', v)
} catch {
return displayUrl(v)
}
})()
_imgSrcCache.set(v, p)
try {
const out = await p
_imgSrcCache.set(v, out)
return out
} catch {
_imgSrcCache.delete(v)
return displayUrl(v)
}
}

View File

@@ -1,14 +1,17 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { X, BarChart2, GitBranch, ListTodo, CalendarDays, Bot, CloudUpload } from 'lucide-vue-next'
import { X, BarChart2, GitBranch, ListTodo, CalendarDays, Bot, CloudUpload, RefreshCw } from 'lucide-vue-next'
import { call } from '../api'
import { useAppStore } from '../store'
import logoUrl from '../assets/logo.png'
// 关于弹窗:由原生菜单“帮助 → 关于”触发menu:action=about
const emit = defineEmits(['close'])
const { t } = useI18n()
const store = useAppStore()
const version = ref('')
const checking = ref(false)
const aboutFeatures = [
{ icon: BarChart2, key: 'aboutFeatCode' },
{ icon: GitBranch, key: 'aboutFeatGit' },
@@ -20,6 +23,30 @@ const aboutFeatures = [
onMounted(async () => {
try { version.value = await call('GetAppVersion') } catch { version.value = '' }
})
function errText(e) {
const code = String(e).split(':')[0].trim()
const key = 'errors.' + code
return t(key) !== key ? t(key) : String(e)
}
async function checkUpdate() {
if (checking.value) return
checking.value = true
try {
const r = await call('CheckAppUpdate', true)
if (r?.upToDate) {
store.showToast({ type: 'success', text: t('aboutUpToDate', { version: r.current || version.value || '—' }) })
} else if (r?.latest) {
// CheckAppUpdate 会 emit app:update-availableApp 层弹出安装对话框
store.showToast({ type: 'info', text: t('aboutUpdateAvailable', { latest: r.latest, current: r.current }) })
}
} catch (e) {
store.showToast({ type: 'error', text: errText(e) })
} finally {
checking.value = false
}
}
</script>
<template>
@@ -42,6 +69,12 @@ onMounted(async () => {
<div class="about-feats">
<div v-for="f in aboutFeatures" :key="f.key" class="about-feat"><component :is="f.icon" /><span>{{ t(f.key) }}</span></div>
</div>
<div class="about-update">
<p class="about-update-hint">{{ t('aboutUpdateHint') }}</p>
<button type="button" class="btn secondary" :disabled="checking" @click="checkUpdate">
<RefreshCw :class="{ spin: checking }" />{{ checking ? t('aboutCheckingUpdate') : t('aboutCheckUpdate') }}
</button>
</div>
<div class="about-story">
<b>{{ t('aboutNameTitle') }}</b>
<p>{{ t('aboutNameStory') }}</p>

View File

@@ -0,0 +1,83 @@
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { X, ZoomIn, ZoomOut, RotateCcw } from 'lucide-vue-next'
const props = defineProps({
src: { type: String, default: '' },
open: { type: Boolean, default: false }
})
const emit = defineEmits(['close'])
const scale = ref(1)
const dragging = ref(false)
const ox = ref(0)
const oy = ref(0)
let startX = 0, startY = 0, baseOx = 0, baseOy = 0
watch(() => props.open, v => {
if (v) { scale.value = 1; ox.value = 0; oy.value = 0 }
})
function close() { emit('close') }
function zoom(d) {
scale.value = Math.min(5, Math.max(0.2, +(scale.value + d).toFixed(2)))
}
function reset() { scale.value = 1; ox.value = 0; oy.value = 0 }
function onWheel(e) {
e.preventDefault()
zoom(e.deltaY < 0 ? 0.15 : -0.15)
}
function onDown(e) {
if (e.button !== 0) return
dragging.value = true
startX = e.clientX; startY = e.clientY
baseOx = ox.value; baseOy = oy.value
}
function onMove(e) {
if (!dragging.value) return
ox.value = baseOx + (e.clientX - startX)
oy.value = baseOy + (e.clientY - startY)
}
function onUp() { dragging.value = false }
function onKey(e) {
if (!props.open) return
if (e.key === 'Escape') close()
if (e.key === '+' || e.key === '=') zoom(0.2)
if (e.key === '-') zoom(-0.2)
if (e.key === '0') reset()
}
onMounted(() => {
window.addEventListener('keydown', onKey)
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKey)
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
})
</script>
<template>
<Teleport to="body">
<div v-if="open && src" class="img-preview" @click.self="close" @wheel.prevent="onWheel">
<div class="img-preview-toolbar">
<button type="button" :title="'Zoom in'" @click="zoom(0.25)"><ZoomIn /></button>
<button type="button" :title="'Zoom out'" @click="zoom(-0.25)"><ZoomOut /></button>
<button type="button" :title="'Reset'" @click="reset"><RotateCcw /></button>
<span class="img-preview-scale">{{ Math.round(scale * 100) }}%</span>
<button type="button" class="close" @click="close"><X /></button>
</div>
<img
:src="src"
alt=""
class="img-preview-img"
:class="{ dragging }"
:style="{ transform: `translate(${ox}px,${oy}px) scale(${scale})` }"
draggable="false"
@mousedown.prevent="onDown"
@click.stop
/>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,99 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Package, LoaderCircle, Check, CircleX, Trash2, X, ArrowRight } from 'lucide-vue-next'
import { call, on } from '../api'
// 本机打包任务快捷入口:完整页面在 /pack-tasks。
const { t } = useI18n()
const router = useRouter()
const open = ref(false)
const wrap = ref(null)
const tasks = ref([])
let off = null
async function load() {
try { tasks.value = await call('ListLocalPackTasks') || [] } catch { tasks.value = [] }
}
const running = computed(() => tasks.value.filter(x => x.status === 'running').length)
const badge = computed(() => running.value || (tasks.value.length ? tasks.value.length : 0))
const showBadge = computed(() => tasks.value.length > 0)
function statusLabel(s) {
if (s === 'running') return t('packTaskRunning')
if (s === 'done') return t('packTaskDone')
return t('packTaskFailed')
}
function fmtTime(s) {
if (!s) return ''
return String(s).replace('T', ' ').slice(5, 19)
}
async function toggle() {
open.value = !open.value
if (open.value) await load()
}
function onClickAway(e) {
if (wrap.value && !wrap.value.contains(e.target)) open.value = false
}
async function clearDone() {
try { tasks.value = await call('ClearFinishedLocalPackTasks') || [] } catch { /* ignore */ }
}
async function dismiss(id) {
try { tasks.value = await call('DismissLocalPackTask', id) || [] } catch { /* ignore */ }
}
function goPage(id) {
open.value = false
router.push(id ? { path: '/pack-tasks', query: { id } } : '/pack-tasks')
}
onMounted(() => {
addEventListener('click', onClickAway)
load()
off = on('pack:task', () => load())
})
onUnmounted(() => {
removeEventListener('click', onClickAway)
off?.()
})
</script>
<template>
<div ref="wrap" class="rail-pack-wrap">
<button type="button" class="rail-pack-btn" :title="t('packTasks')" @click="toggle">
<Package />
<i v-if="showBadge" class="rail-pack-badge" :class="{ run: running }">{{ badge > 99 ? '99+' : badge }}</i>
</button>
<div v-if="open" class="rail-pack-drop" @click.stop>
<header>
<b>{{ t('packTasks') }}</b>
<span class="rail-pack-hint">{{ t('packTasksLocal') }}</span>
<button v-if="tasks.some(x => x.status !== 'running')" type="button" class="rail-pack-clear" :title="t('packTasksClear')" @click="clearDone"><Trash2 /></button>
<button type="button" class="nm-close" @click="open = false"><X /></button>
</header>
<div class="rail-pack-list">
<div v-for="x in tasks" :key="x.id" class="rail-pack-item" :class="x.status" @click="goPage(x.id)">
<span class="rail-pack-st">
<LoaderCircle v-if="x.status === 'running'" class="spin" />
<Check v-else-if="x.status === 'done'" />
<CircleX v-else />
</span>
<div class="rail-pack-main">
<b :title="x.cmd">{{ x.title || x.cmd }}</b>
<small>
<em>{{ statusLabel(x.status) }}</em>
<time v-if="x.startedAt">{{ fmtTime(x.startedAt) }}</time>
<span v-if="x.pid">PID {{ x.pid }}</span>
</small>
<code v-if="x.cmd && x.title && x.cmd !== x.title" :title="x.cmd">{{ x.cmd }}</code>
<p v-if="x.error" class="rail-pack-err" :title="x.error">{{ x.error }}</p>
</div>
<button v-if="x.status !== 'running'" type="button" class="rail-pack-x" :title="t('close')" @click.stop="dismiss(x.id)"><X /></button>
</div>
<div v-if="!tasks.length" class="rail-pack-empty">{{ t('packTasksEmpty') }}</div>
</div>
<button type="button" class="bell-more" @click="goPage()">{{ t('viewAll') }}<ArrowRight /></button>
</div>
</div>
</template>

View File

@@ -4,7 +4,7 @@
import { nextTick, ref, watch } from 'vue'
import { marked } from 'marked'
import { Browser } from '@wailsio/runtime'
import { call, isNative } from '../api'
import { call, isNative, resolveImageSrc } from '../api'
const props = defineProps({ source: { type: String, default: '' } })
const el = ref(null)
@@ -25,7 +25,12 @@ function hydrateImages() {
if (!el.value) return
for (const img of el.value.querySelectorAll('img')) {
const src = img.getAttribute('src') || ''
if (!src || /^(data:|https?:)/i.test(src)) continue
if (!src || /^data:/i.test(src)) continue
// 远程 http(s) 经 Go 拉成 dataURL避免 WebView 直接拉外站破图。
if (/^https?:/i.test(src)) {
resolveImageSrc(src).then(u => { if (u) img.src = u })
continue
}
img.classList.add('md-img-loading')
resolveLocal(src).then(dataURL => {
if (dataURL) { img.src = dataURL; img.classList.remove('md-img-loading') }

View File

@@ -0,0 +1,131 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Package, Plus, Trash2, X, Terminal, Sparkles } from 'lucide-vue-next'
import { getPackCmds, setPackCmds } from '../packCmds'
import { call } from '../api'
const props = defineProps({
open: Boolean,
scope: { type: String, required: true }, // lp | proj
targetId: { type: [Number, String], required: true },
title: { type: String, default: '' },
dir: { type: String, default: '' }
})
const emit = defineEmits(['close', 'saved'])
const { t } = useI18n()
const rows = ref([])
const suggest = ref({ kind: '', label: '', items: [] })
const suggestLoading = ref(false)
watch(() => [props.open, props.scope, props.targetId, props.dir], async () => {
if (!props.open) return
const list = getPackCmds(props.scope, props.targetId)
rows.value = list.length
? list.map(x => ({ name: x.name || '', cmd: x.cmd || '' }))
: [{ name: '', cmd: '' }]
suggest.value = { kind: '', label: '', items: [] }
if (!props.dir) return
suggestLoading.value = true
try {
suggest.value = await call('SuggestPackCommands', props.dir) || { kind: '', label: '', items: [] }
} catch {
suggest.value = { kind: '', label: '', items: [] }
}
suggestLoading.value = false
}, { immediate: true })
const canSave = computed(() => rows.value.some(r => String(r.cmd || '').trim()))
const validCount = computed(() => rows.value.filter(r => String(r.cmd || '').trim()).length)
const suggestItems = computed(() => suggest.value?.items || [])
function addRow() {
rows.value.push({ name: '', cmd: '' })
}
function removeRow(i) {
rows.value.splice(i, 1)
if (!rows.value.length) rows.value.push({ name: '', cmd: '' })
}
function applySuggest(item) {
if (!item?.cmd) return
const empty = rows.value.findIndex(r => !String(r.cmd || '').trim())
const row = { name: item.name || '', cmd: item.cmd }
if (empty >= 0) rows.value[empty] = row
else rows.value.push(row)
}
function applyAllSuggest() {
for (const it of suggestItems.value) applySuggest(it)
}
function save() {
setPackCmds(props.scope, props.targetId, rows.value)
emit('saved')
emit('close')
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="overlay" @click.self="emit('close')">
<section class="modal pack-cmds-modal" @click.stop>
<header class="pack-cmds-head">
<div class="pack-cmds-brand">
<span class="pack-cmds-ico"><Package /></span>
<div>
<h2>{{ title || t('packCmdsTitle') }}</h2>
<p>{{ t('packCmdsHint') }}</p>
</div>
</div>
<button type="button" class="nm-close" :title="t('close')" @click="emit('close')"><X /></button>
</header>
<div class="pack-cmds-body">
<div v-if="dir" class="pack-suggest">
<div class="pack-suggest-top">
<b><Sparkles />{{ t('packSuggestTitle') }}</b>
<em v-if="suggestLoading">{{ t('packSuggestLoading') }}</em>
<em v-else-if="suggest.label" class="pack-suggest-kind">{{ suggest.label }}</em>
<button v-if="suggestItems.length" type="button" class="pack-suggest-all" @click="applyAllSuggest">{{ t('packSuggestAll') }}</button>
</div>
<div v-if="suggestItems.length" class="pack-suggest-chips">
<button v-for="(it, i) in suggestItems" :key="i" type="button" :title="it.cmd" @click="applySuggest(it)">
<span>{{ it.name }}</span>
<code>{{ it.cmd }}</code>
</button>
</div>
<p v-else-if="!suggestLoading" class="pack-suggest-empty">{{ t('packSuggestEmpty') }}</p>
</div>
<div class="pack-cmds-meta">
<b>{{ t('packCmdsList') }}</b>
<em>{{ t('packCmdsCount', { n: validCount }) }}</em>
</div>
<div class="pack-cmds-list">
<article v-for="(r, i) in rows" :key="i" class="pack-cmd-card">
<div class="pack-cmd-card-top">
<span class="pack-cmd-idx">{{ i + 1 }}</span>
<button type="button" class="pack-cmd-del" :title="t('delete')" @click="removeRow(i)"><Trash2 /></button>
</div>
<label class="pack-cmd-labeled">
<span>{{ t('packCmdNameLabel') }}</span>
<input v-model.trim="r.name" :placeholder="t('packCmdNamePh')" />
</label>
<label class="pack-cmd-labeled">
<span>{{ t('packCmdCmdLabel') }}</span>
<span class="pack-cmd-field">
<Terminal />
<input v-model.trim="r.cmd" class="pack-cmd-term" spellcheck="false" :placeholder="t('packCmdPh')" />
</span>
</label>
</article>
</div>
<button type="button" class="pack-cmd-add" @click="addRow"><Plus />{{ t('packCmdAdd') }}</button>
</div>
<footer class="pack-cmds-foot">
<button type="button" class="btn secondary" @click="emit('close')">{{ t('cancel') }}</button>
<button type="button" class="btn primary" :disabled="!canSave" @click="save">{{ t('save') }}</button>
</footer>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
// 远程 http(s) 图经 Go 拉成 dataURL 再显示,避免 WebView 外站破图。
import { ref, watch } from 'vue'
import { resolveImageSrc } from '../api'
const props = defineProps({
src: { type: String, default: '' },
alt: { type: String, default: '' },
})
const resolved = ref('')
watch(() => props.src, async (v) => {
resolved.value = ''
if (!v) return
resolved.value = await resolveImageSrc(v)
}, { immediate: true })
</script>
<template>
<img v-if="resolved" :src="resolved" :alt="alt" />
</template>

View File

@@ -1,4 +1,4 @@
*{scrollbar-width:thin;scrollbar-color:rgba(145,136,255,.55) transparent}::-webkit-scrollbar{width:9px;height:9px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:rgba(145,136,255,.38);border:2px solid transparent;background-clip:padding-box;border-radius:8px}::-webkit-scrollbar-thumb:hover{background:rgba(145,136,255,.7);border:2px solid transparent;background-clip:padding-box}
.wsl-picker{display:flex;gap:8px;margin:12px 24px 0}.wsl-picker select{flex:1;background:var(--glass-soft);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:0 10px}.icon-actions button:disabled{opacity:.5;cursor:not-allowed}
.page{overflow-x:clip}.project-title>div:first-child{min-width:0;flex:1}.project-title p{max-width:100%!important}.icon-actions{flex:none}.project-card{min-width:0}
.page{overflow-x:clip}.project-title>div:first-child{min-width:0;flex:1;overflow:hidden}.project-title p{max-width:100%!important}.icon-actions{flex:none;flex-shrink:0}.project-card{min-width:0}
.git-context{display:flex;align-items:center;gap:18px;margin:-8px 0 18px;padding:11px 15px;border:1px solid var(--glass-border);border-radius:7px;background:var(--glass-soft);color:var(--muted)}.git-context span{display:flex;align-items:center;gap:7px}.git-context svg{width:17px;color:#9188ff}.git-context b{color:var(--text)}.heat-panel{height:255px}.heat-panel .chart{height:185px}.branch.interactive{position:relative;padding-right:52px;cursor:pointer;transition:border-color .2s,background .2s}.branch.interactive:hover,.branch.interactive.selected{background:rgba(123,115,255,.12);outline:1px solid rgba(145,136,255,.35)}.checkout-btn{position:absolute;right:12px;top:20px;width:32px;height:32px;border:0;border-radius:6px;background:rgba(123,115,255,.15);color:#9188ff;display:grid;place-items:center;cursor:pointer}.checkout-btn svg{width:16px}.commit{width:100%;border:0;color:var(--text);text-align:left;cursor:pointer}.commit:hover{outline:1px solid rgba(145,136,255,.3)}.git-trend{position:relative}.git-trend>.segments{position:absolute;right:0;top:-38px;z-index:2}.git-trend .chart{height:280px}.drawer-mask{position:fixed;inset:0;z-index:60;background:rgba(0,0,0,.55);backdrop-filter:blur(5px);display:flex;justify-content:flex-end;animation:overlay-in .2s}.commit-drawer{width:min(620px,90vw);height:100%;overflow:auto;background:var(--glass-strong);border-left:1px solid var(--glass-border);box-shadow:-20px 0 50px rgba(0,0,0,.3);padding:26px;animation:drawer-in .3s cubic-bezier(.16,1,.3,1)}.commit-drawer header{display:flex;justify-content:space-between;gap:20px;border-bottom:1px solid var(--border);padding-bottom:18px}.commit-drawer header small{color:var(--muted)}.commit-drawer h2{margin:7px 0 0;font-size:20px}.commit-drawer header button,.commit-meta button{border:0;background:var(--glass-soft);color:var(--text);width:34px;height:34px;border-radius:6px;display:grid;place-items:center;cursor:pointer}.commit-drawer svg{width:17px}.commit-meta{display:grid;grid-template-columns:1fr auto auto auto;gap:10px;align-items:center;margin:20px 0;padding:15px;background:var(--glass-soft);border-radius:7px}.commit-meta code{min-width:0;overflow:hidden;text-overflow:ellipsis}.commit-meta span,.commit-meta time{grid-column:1/3;color:var(--muted)}.change-file{display:grid;grid-template-columns:22px 1fr auto auto auto;gap:10px;align-items:center;padding:11px;border-bottom:1px solid var(--border)}.change-file span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.change-file small{color:var(--muted)}@keyframes drawer-in{from{transform:translateX(100%)}to{transform:none}}@media(prefers-reduced-motion:reduce){.commit-drawer{animation:none}}

View File

@@ -15,12 +15,15 @@ const Logs = () => import('./views/Logs.vue')
const Settings = () => import('./views/Settings.vue')
const Profile = () => import('./views/Profile.vue')
const Launchpad = () => import('./views/Launchpad.vue')
const PortMonitor = () => import('./views/PortMonitor.vue')
const PackTasks = () => import('./views/PackTasks.vue')
const Messages = () => import('./views/Messages.vue')
const Today = () => import('./views/Today.vue')
const Notes = () => import('./views/Notes.vue')
const TeamHome = () => import('./views/TeamHome.vue')
const TeamTasks = () => import('./views/TeamTasks.vue')
const TeamReports = () => import('./views/TeamReports.vue')
const Admin = () => import('./views/Admin.vue')
import './style.css'
import './motion.css'
import './database.css'
@@ -38,13 +41,54 @@ const zh = {
navWork: '事务',
navSystem: '系统',
launchpad: '启动台',
launchpadSubtitle: '本机服务端口与应用启停管理',
launchpadSubtitle: '管理本机已保存应用启停与分类',
portMonitor: '端口监控',
portMonitorSubtitle: '本机资源总览与监听端口服务',
pmOverview: '系统总览',
pmHideCharts: '隐藏图表',
pmShowCharts: '显示图表',
pmBandwidth: '带宽',
pmNetDown: '下行',
pmNetUp: '上行',
pmGpuNA: '未检测到 GPU 数据',
packRun: '执行打包',
packCmdsTitle: '配置打包命令',
packCmdsTitleNamed: '配置打包命令 · {name}',
packCmdsHint: '可配置多条命令;下次右键「执行打包」从二级菜单选择。命令在项目/应用目录下后台执行。',
packCmdsList: '命令列表',
packCmdsCount: '{n} 条有效',
packCmdNameLabel: '显示名称',
packCmdCmdLabel: '执行命令',
packCmdNamePh: '如:生产安装包',
packCmdPh: '如wails3 task package',
packCmdAdd: '添加命令',
packCmdsConfig: '配置命令…',
packCmdStarted: '已开始执行:{name}',
packSuggestTitle: '根据项目推荐',
packSuggestLoading: '识别中…',
packSuggestEmpty: '未识别到常见打包脚本,可手动填写',
packSuggestAll: '全部加入',
packTasks: '本地任务',
packTasksPage: '打包任务',
packTasksPageSub: '本机打包/构建任务与控制台输出(保存在本地库,重启可查看,不同步云端)',
packTasksLocal: '本地持久',
packTasksEmpty: '暂无打包任务',
packTasksClear: '清除已结束',
packTasksPick: '从左侧选择一条任务查看控制台输出',
packLogCopy: '复制日志',
packLogCopied: '日志已复制',
packLogEmpty: '暂无输出',
packTaskRunning: '执行中',
packTaskDone: '已完成',
packTaskFailed: '失败',
lpMyApps: '我的应用',
lpScanned: '检测到的服务',
lpAddApp: '添加应用',
lpEditApp: '编辑应用',
lpRefresh: '刷新',
lpShowSys: '显示系统进程',
lpSearchName: '搜索名称',
lpSearchPort: '端口',
lpName: '应用名称',
lpKind: '种类',
lpPort: '端口',
@@ -55,14 +99,52 @@ const zh = {
lpSuggest: '推荐',
lpStart: '启动',
lpStop: '停止',
lpRestart: '重启',
lpRestartConfirm: '确认重启 {name}',
lpPin: '存为应用',
lpAddFromProject: '从我的项目添加',
lpSearchProject: '搜索项目名称或路径',
lpNoProjects: '还没有项目,请先在「项目」页添加',
lpOpenPort: '打开 http://127.0.0.1:{p}',
lpToLaunchpad: '添加到启动台',
lpStarting: '启动中…',
lpFailed: '启动失败',
lpLogs: '运行日志',
lpLogsLocalHint: '仅保存在本机,不同步到云端',
lpLogsEmpty: '暂无日志,启动后将在此显示输出',
lpClearLogs: '清空日志',
lpFetchIcon: '抓取图标',
lpFetchIconOk: '已获取项目图标',
lpPickIcon: '选择本地图片',
lpClearIcon: '清除图标',
lpCategory: '分类',
lpCategoryPh: '如:前端 / 后端 / 工具',
lpPrimaryCat: '一级分类',
lpSecondaryCat: '二级分类',
lpManagePrimary: '管理一级分类',
lpAddPrimary: '新增一级分类',
lpPrimaryPh: '分类名称',
lpNoPrimary: '还没有一级分类,先新增一个',
lpCatDelConfirm: '删除一级分类「{name}」?应用将变为未归类',
lpSetPrimary: '设置一级分类',
lpSetSecondary: '设置二级分类',
lpCatNone: '无',
lpCatAll: '全部',
viewDetail: '查看详情',
avatarHistory: '历史头像(已登录则同步到账号)',
avatarServerHistory: '服务器上传记录',
avatarReselect: '回选此头像',
tabKindIcons: '种类图标',
kindIconsTitle: '启动台种类默认图标',
kindIconsHint: '仅保存在本机、不同步。未抓取到项目/应用 logo 时,按识别到的种类展示此处配置的默认图。',
kindIconsAdminOnly: '种类默认图标暂不可在此修改',
lpRunning: '运行中',
lpStopped: '未运行',
lpMem: '内存',
lpNeedCmd: '首次启动请先选择或填写启动命令',
lpStopConfirm: '确认停止 {name}',
lpDelConfirm: '删除应用 {name}?(不会停止正在运行的进程)',
lpEmptyApps: '还没有保存的应用:从下方检测列表「存为应用」或手动添加',
lpEmptyApps: '还没有保存的应用:从「端口监控」页「存为应用」或手动添加',
lpEmptyScan: '未检测到监听端口的服务',
workbench: '工作台',
workbenchSubtitle: '收藏项目、今日待办与速记',
@@ -123,6 +205,8 @@ const zh = {
notepad: '记事本',
notepadPlaceholder: '随手记点什么,自动保存...',
autoSaved: '已自动保存',
noteSaved: '笔记已保存',
noteUnsaved: '未保存',
noteCenter: '笔记',
noteNew: '新建笔记',
noteEdit: '编辑笔记',
@@ -534,7 +618,7 @@ const zh = {
dailyFuture: '未来的心语还没写好',
dailyPrevDay: '前一天',
dailyNextDay: '后一天',
festImgTitle: '节日格样式(管理员)',
festImgTitle: '节日格样式',
festImgSet: '设置图片',
festImgReplace: '更换',
festImgRemove: '移除',
@@ -612,34 +696,92 @@ const zh = {
imgModePath: '本地文件(仅本机显示)',
imgModeServer: '上传到服务器(跨设备可见)',
contentImageHint: '待办 / 工单正文里粘贴或插入的图片按此方式保存:内嵌 Base64 会随内容同步到云端;本地文件体积更小,但换设备后无法显示;上传到服务器后以链接引用,任何设备都能访问。图片统一压缩到 1100px 以内。',
avatarServer: '上传到服务器(管理员配置的文件服务',
avatarServer: '上传到服务器(跨设备可见',
fsTitle: '文件存储(全局)',
fsHint: '管理员专属:配置随同步下发全员生效。服务器模式下内容图与头像上传到文件服务,跨设备可访问',
fsHint: '配置将下发全员生效。服务器模式下内容图与头像上传到文件服务,跨设备可访问',
fsMode: '存储方式',
fsModeLocal: '本地(默认)',
fsModeServer: '服务器nl-pms-api',
fsBaseUrl: '服务器地址',
fsApiKey: '上传密钥',
fsApiKeyPh: '与 nl-pms-api 配置文件里的 api_key 一致',
fsApiKey: '上传密钥(已废弃)',
fsApiKeyPh: '已改用登录 JWT无需填写',
fsTestBtn: '测试连接',
fsTesting: '测试中…',
fsSaveBtn: '保存配置',
fsSavedToast: '文件存储配置已保存,立即全员生效',
fsTestOkToast: '文件服务器连接成功',
tabFileStorage: '文件存储',
fsPageHint: '配置保存在服务器数据库,保存后立即全员生效:每个客户端上传图片时都会实时读取该配置决定存储位置,无需等待同步。',
fsAdminOnlyTitle: '仅管理员可配置',
fsAdminOnlyDesc: '只有管理员账号ID=1可以修改全局文件存储方式请联系管理员。',
storageFollowHint: '头像与正文图片的存储方式由管理员统一配置,无需手动选择。当前:{mode}。',
fsPageHint: '配置保存在服务器;上传鉴权使用账号登录 JWT登录一次即可。保存后立即全员生效。敏感操作需 Google 动态码验证。',
adminTitle: '运营后台',
adminSubtitle: '日活、用量、用户/团队管理与发版',
adminTabOverview: '概览',
adminTabUsers: '用户',
adminTabTeams: '团队',
adminTabReleases: '发版',
adminTabSecurity: '安全',
adminStatUsers: '用户数',
adminStatTeams: '团队数',
adminStatDAU: '今日日活',
adminStatTokens: '今日 Token',
adminDAUSeries: '近 14 日日活',
adminTokenSeries: '近 14 日 Token',
adminColAI: 'AI',
adminColDisabled: '账号',
adminColMembers: '成员',
adminColLatest: '最新',
adminBanAI: '禁止 AI',
adminUnbanAI: '允许 AI',
adminDisable: '禁用',
adminEnable: '启用',
adminReleaseVersion: '版本号',
adminReleaseChannel: '渠道',
adminReleaseChangelog: '更新说明',
adminReleaseFile: '安装包',
adminReleaseDropTitle: '拖拽安装包到此处',
adminReleaseDropHint: '支持 .exe也可点击浏览选择',
adminReleaseNeedExe: '请拖入或选择 .exe 安装包',
adminReleaseUpload: '上传',
adminReleasePublish: '设为最新',
adminReleaseUploaded: '安装包已上传',
adminReleasePublished: '已发布为最新版',
adminTotpHint: '绑定 Google Authenticator 后改文件存储、发版、禁用户等敏感操作需输入动态码2 小时内有效;换 IP 需重新验证)。',
adminTotpEnabled: '已绑定动态码',
adminTotpBegin: '生成绑定二维码',
adminTotpConfirm: '确认绑定',
adminTotpBound: '动态码已绑定',
adminTotpCodePh: '6 位动态码',
adminStepupNow: '立即验证动态码',
adminStepupActive: '敏感操作已解锁',
adminStepupTitle: '输入动态码',
adminStepupHint: '打开 Google Authenticator输入 6 位验证码',
adminStepupConfirm: '验证',
adminIpChangedRisk: '检测到 IP 变化,存在账号被盗风险,请重新输入动态码。',
appUpdateTitle: '发现新版本',
appUpdateBody: '当前 {current} → 最新 {latest}',
appUpdateSkip: '稍后',
appUpdateInstall: '下载并安装',
appUpdateDownloading: '下载中…',
checkAppUpdate: '检查软件更新',
aboutCheckUpdate: '检查更新',
aboutCheckingUpdate: '正在检查…',
aboutUpToDate: '已是最新版本({version}',
aboutUpdateAvailable: '发现新版本 {latest}(当前 {current}',
aboutUpdateHint: '登录后会定期自动检查更新;未登录或暂无推送时,可在此手动检查。',
fsAdminOnlyTitle: '暂不可配置',
fsAdminOnlyDesc: '全局文件存储方式由系统维护,如需调整请联系客服。',
storageFollowHint: '头像与正文图片的存储方式由系统统一配置,无需手动选择。当前:{mode}。',
avatarClear: '清除头像',
quitApp: '退出应用',
quitConfirm: '确定退出应用?',
exitMenu: '退出',
exitMenuHint: '请选择要执行的操作',
assetsTab: '素材库',
assetsHint: '管理上传到文件服务器的图片本人管自己的团队管理员管团队的管理员id=1管全部',
assetsNeedServer: '未启用服务器存储。管理员在「设置 → 文件存储」切到服务器模式后,上传的图片会在这里展示。',
assetsHint: '管理上传到文件服务器的图片;团队素材需具备团队管理权限',
assetsHintAdmin: '管理服务器上的图片:本人 / 团队 / 全部',
assetsNeedServer: '当前未启用服务器存储,图片保存在本地。启用后上传记录会出现在这里。',
assetsScopeMine: '我的上传',
assetsScopeTeam: '团队素材',
assetsScopeAll: '全部(管理员)',
assetsScopeAll: '全部',
assetsCount: '共 {n} 张',
assetsRefresh: '刷新',
assetsEmpty: '还没有图片。待办 / 工单正文粘贴的图片或上传的头像会出现在这里。',
@@ -793,10 +935,30 @@ const zh = {
SYNC_DECRYPT_FAILED: '云端 API Key 解密失败,请重新登录后再同步',
AVATAR_FILE_TOO_LARGE: '图片超过 10MB请换一张更小的图片',
AVATAR_DECODE_FAILED: '无法识别的图片格式(支持 PNG / JPG / GIF / WebP',
FILE_STORAGE_ADMIN_ONLY: '只有管理员id=1可以配置文件存储',
AVATAR_VALUE_REQUIRED: '头像内容不能为空',
AVATAR_VALUE_TOO_LARGE: '头像数据过大,无法写入历史',
FILE_STORAGE_ADMIN_ONLY: '无权修改文件存储配置',
ADMIN_STEPUP_REQUIRED: '请先输入 Google 动态码验证',
ADMIN_IP_CHANGED: '检测到 IP 变化,请重新输入动态码',
TOTP_INVALID: '动态码错误',
TOTP_NOT_ENABLED: '请先绑定 Google Authenticator',
TOTP_ALREADY_ENABLED: '动态码已绑定',
USER_AI_BANNED: '你的账号已被禁止使用 AI',
TEAM_AI_BANNED: '所在团队已被禁止使用 AI',
ACCOUNT_DISABLED: '账号已被禁用',
VERSION_INVALID: '版本号格式应为 x.y.z',
VERSION_EXISTS: '该版本已存在',
FILE_TOO_LARGE: '文件过大',
SHA256_MISMATCH: '安装包校验失败',
NO_RELEASE: '暂无可用更新',
KIND_ICON_ADMIN_ONLY: '无权维护种类默认图标',
KIND_UNKNOWN: '未知的启动台种类',
CATEGORY_EXISTS: '该一级分类已存在',
ICON_NOT_FOUND: '未找到图标',
LAUNCH_APP_NOT_FOUND: '启动台应用不存在',
FILE_STORAGE_BAD_URL: '服务器地址无效,需以 http(s):// 开头',
FILE_STORAGE_UNREACHABLE: '无法连接文件服务器,请检查地址与服务状态',
FILE_API_UNCONFIGURED: '服务器存储未启用,请联系管理员配置文件存储',
FILE_API_UNCONFIGURED: '服务器存储未启用,请稍后再试或联系客服',
IMAGE_UPLOAD_FAILED: '图片上传失败,请检查文件服务器后重试',
FILE_PERMISSION_DENIED: '没有权限操作这个文件',
FILE_API_REQUEST_FAILED: '文件服务器请求失败,请稍后重试',
@@ -885,13 +1047,54 @@ const en = {
navWork: 'Work',
navSystem: 'System',
launchpad: 'Launchpad',
launchpadSubtitle: 'Local service ports & app start/stop',
launchpadSubtitle: 'Start/stop saved local apps',
portMonitor: 'Port monitor',
portMonitorSubtitle: 'Host overview & listening services',
pmOverview: 'System overview',
pmHideCharts: 'Hide charts',
pmShowCharts: 'Show charts',
pmBandwidth: 'Bandwidth',
pmNetDown: 'Download',
pmNetUp: 'Upload',
pmGpuNA: 'No GPU metrics',
packRun: 'Run package',
packCmdsTitle: 'Configure package commands',
packCmdsTitleNamed: 'Package commands · {name}',
packCmdsHint: 'Add multiple commands; later pick one from the context submenu. Runs in the app/project directory.',
packCmdsList: 'Commands',
packCmdsCount: '{n} valid',
packCmdNameLabel: 'Display name',
packCmdCmdLabel: 'Command',
packCmdNamePh: 'e.g. Release installer',
packCmdPh: 'e.g. wails3 task package',
packCmdAdd: 'Add command',
packCmdsConfig: 'Configure…',
packCmdStarted: 'Started: {name}',
packSuggestTitle: 'Suggested for this project',
packSuggestLoading: 'Detecting…',
packSuggestEmpty: 'No common build scripts found — add manually',
packSuggestAll: 'Add all',
packTasks: 'Local tasks',
packTasksPage: 'Pack tasks',
packTasksPageSub: 'Local build/package jobs & console output (saved locally, survives restart, not synced)',
packTasksLocal: 'Local persist',
packTasksEmpty: 'No package tasks yet',
packTasksClear: 'Clear finished',
packTasksPick: 'Select a task on the left to view console output',
packLogCopy: 'Copy log',
packLogCopied: 'Log copied',
packLogEmpty: 'No output yet',
packTaskRunning: 'Running',
packTaskDone: 'Done',
packTaskFailed: 'Failed',
lpMyApps: 'My apps',
lpScanned: 'Detected services',
lpAddApp: 'Add app',
lpEditApp: 'Edit app',
lpRefresh: 'Refresh',
lpShowSys: 'Show system processes',
lpSearchName: 'Search name',
lpSearchPort: 'Port',
lpName: 'Name',
lpKind: 'Kind',
lpPort: 'Port',
@@ -902,14 +1105,52 @@ const en = {
lpSuggest: 'Suggested',
lpStart: 'Start',
lpStop: 'Stop',
lpRestart: 'Restart',
lpRestartConfirm: 'Restart {name}?',
lpPin: 'Save as app',
lpAddFromProject: 'Add from my projects',
lpSearchProject: 'Search project name or path',
lpNoProjects: 'No projects yet — add one on the Projects page first',
lpOpenPort: 'Open http://127.0.0.1:{p}',
lpToLaunchpad: 'Add to Launchpad',
lpStarting: 'Starting…',
lpFailed: 'Failed',
lpLogs: 'Run logs',
lpLogsLocalHint: 'Stored on this device only — not synced',
lpLogsEmpty: 'No logs yet — output appears after start',
lpClearLogs: 'Clear logs',
lpFetchIcon: 'Fetch icon',
lpFetchIconOk: 'Project icon fetched',
lpPickIcon: 'Choose local image',
lpClearIcon: 'Clear icon',
lpCategory: 'Category',
lpCategoryPh: 'e.g. Frontend / Backend / Tools',
lpPrimaryCat: 'Primary category',
lpSecondaryCat: 'Secondary category',
lpManagePrimary: 'Manage primary categories',
lpAddPrimary: 'Add primary category',
lpPrimaryPh: 'Category name',
lpNoPrimary: 'No primary categories yet',
lpCatDelConfirm: 'Delete primary category "{name}"? Apps will become uncategorized',
lpSetPrimary: 'Set primary category',
lpSetSecondary: 'Set secondary category',
lpCatNone: 'None',
lpCatAll: 'All',
viewDetail: 'View details',
avatarHistory: 'Avatar history (synced when signed in)',
avatarServerHistory: 'Server uploads',
avatarReselect: 'Use this avatar',
tabKindIcons: 'Kind icons',
kindIconsTitle: 'Launchpad kind default icons',
kindIconsHint: 'Stored locally only (not synced). Used when a project/app has no logo and a kind is detected.',
kindIconsAdminOnly: 'Kind icons cannot be edited here',
lpRunning: 'Running',
lpStopped: 'Stopped',
lpMem: 'Memory',
lpNeedCmd: 'Pick or enter a start command first',
lpStopConfirm: 'Stop {name}?',
lpDelConfirm: 'Delete app {name}? (running process is kept)',
lpEmptyApps: 'No saved apps yet — pin one from the detected list or add manually',
lpEmptyApps: 'No saved apps yet — pin from Port monitor or add manually',
lpEmptyScan: 'No listening services detected',
workbench: 'Workbench',
workbenchSubtitle: 'Favorites, today\'s tasks and quick notes',
@@ -970,6 +1211,8 @@ const en = {
notepad: 'Notepad',
notepadPlaceholder: 'Jot something down, autosaved...',
autoSaved: 'Autosaved',
noteSaved: 'Note saved',
noteUnsaved: 'Unsaved',
noteCenter: 'Notes',
noteNew: 'New note',
noteEdit: 'Edit note',
@@ -1381,7 +1624,7 @@ const en = {
dailyFuture: 'Notes for the future are not written yet',
dailyPrevDay: 'Previous day',
dailyNextDay: 'Next day',
festImgTitle: 'Festival cell style (admin)',
festImgTitle: 'Festival cell style',
festImgSet: 'Set image',
festImgReplace: 'Replace',
festImgRemove: 'Remove',
@@ -1459,34 +1702,92 @@ const en = {
imgModePath: 'Local file (this device only)',
imgModeServer: 'Upload to server (visible across devices)',
contentImageHint: 'Images pasted or inserted into todo / ticket content are stored this way: inline Base64 syncs to the cloud with the text; local files keep the database small but won\'t show on other devices; server uploads are referenced by URL and load anywhere. Images are compressed to 1100px max.',
avatarServer: 'Upload to server (admin-configured file service)',
avatarServer: 'Upload to server (visible across devices)',
fsTitle: 'File storage (global)',
fsHint: 'Admin only: the config syncs to every account. In server mode content images and avatars upload to the file service and stay accessible across devices',
fsHint: 'Changes apply to all accounts. In server mode images upload to the file service.',
fsMode: 'Storage mode',
fsModeLocal: 'Local (default)',
fsModeServer: 'Server (nl-pms-api)',
fsBaseUrl: 'Server URL',
fsApiKey: 'Upload key',
fsApiKeyPh: 'Same as api_key in the nl-pms-api config',
fsApiKey: 'Upload key (deprecated)',
fsApiKeyPh: 'Uses login JWT; leave blank',
fsTestBtn: 'Test connection',
fsTesting: 'Testing…',
fsSaveBtn: 'Save config',
fsSavedToast: 'File storage config saved; effective for everyone immediately',
fsTestOkToast: 'File server reachable',
tabFileStorage: 'File storage',
fsPageHint: 'The config lives in the server database and takes effect immediately: every client reads it in real time when uploading images, no sync wait.',
fsAdminOnlyTitle: 'Admin only',
fsAdminOnlyDesc: 'Only the admin account (ID=1) can change the global file storage mode.',
storageFollowHint: 'Avatar and content image storage follows the admin-managed global config. Current: {mode}.',
fsPageHint: 'Stored on the server; uploads use the login JWT (sign in once). Changes apply to all clients immediately. Sensitive actions require a Google Authenticator code.',
adminTitle: 'Admin',
adminSubtitle: 'DAU, usage, users/teams and releases',
adminTabOverview: 'Overview',
adminTabUsers: 'Users',
adminTabTeams: 'Teams',
adminTabReleases: 'Releases',
adminTabSecurity: 'Security',
adminStatUsers: 'Users',
adminStatTeams: 'Teams',
adminStatDAU: 'DAU today',
adminStatTokens: 'Tokens today',
adminDAUSeries: 'DAU (14d)',
adminTokenSeries: 'Tokens (14d)',
adminColAI: 'AI',
adminColDisabled: 'Account',
adminColMembers: 'Members',
adminColLatest: 'Latest',
adminBanAI: 'Ban AI',
adminUnbanAI: 'Allow AI',
adminDisable: 'Disable',
adminEnable: 'Enable',
adminReleaseVersion: 'Version',
adminReleaseChannel: 'Channel',
adminReleaseChangelog: 'Changelog',
adminReleaseFile: 'Installer',
adminReleaseDropTitle: 'Drop installer here',
adminReleaseDropHint: 'Accepts .exe; or click Browse',
adminReleaseNeedExe: 'Please drop or pick an .exe installer',
adminReleaseUpload: 'Upload',
adminReleasePublish: 'Publish',
adminReleaseUploaded: 'Installer uploaded',
adminReleasePublished: 'Published as latest',
adminTotpHint: 'After binding Google Authenticator, sensitive actions (file storage, releases, bans) require a code (valid 2h; IP change forces re-auth).',
adminTotpEnabled: 'TOTP enabled',
adminTotpBegin: 'Generate QR',
adminTotpConfirm: 'Confirm bind',
adminTotpBound: 'TOTP bound',
adminTotpCodePh: '6-digit code',
adminStepupNow: 'Verify now',
adminStepupActive: 'Sensitive ops unlocked',
adminStepupTitle: 'Enter TOTP code',
adminStepupHint: 'Open Google Authenticator and enter the 6-digit code',
adminStepupConfirm: 'Verify',
adminIpChangedRisk: 'IP changed — possible account theft. Re-enter your authenticator code.',
appUpdateTitle: 'Update available',
appUpdateBody: '{current} → {latest}',
appUpdateSkip: 'Later',
appUpdateInstall: 'Download & install',
appUpdateDownloading: 'Downloading…',
checkAppUpdate: 'Check for updates',
aboutCheckUpdate: 'Check for updates',
aboutCheckingUpdate: 'Checking…',
aboutUpToDate: 'You are up to date ({version})',
aboutUpdateAvailable: 'Update available: {latest} (current {current})',
aboutUpdateHint: 'When signed in, the app checks for updates periodically. You can also check here manually.',
fsAdminOnlyTitle: 'Not available',
fsAdminOnlyDesc: 'Global file storage is managed by the system.',
storageFollowHint: 'Avatar and content image storage is managed by the system. Current: {mode}.',
avatarClear: 'Clear avatar',
quitApp: 'Quit app',
quitConfirm: 'Quit the app?',
exitMenu: 'Exit',
exitMenuHint: 'Choose an action',
assetsTab: 'Media library',
assetsHint: 'Manage images uploaded to the file server: yours, your teams\' (as owner/admin), or everything (admin id=1)',
assetsNeedServer: 'Server storage is off. Once the admin switches file storage to server mode on the Sync tab, uploads show up here.',
assetsHint: 'Manage your images on the file server; team media requires team admin rights',
assetsHintAdmin: 'Manage server images: mine / team / all',
assetsNeedServer: 'Server storage is off. Images stay local until it is enabled.',
assetsScopeMine: 'My uploads',
assetsScopeTeam: 'Team media',
assetsScopeAll: 'Everything (admin)',
assetsScopeAll: 'All',
assetsCount: '{n} images',
assetsRefresh: 'Refresh',
assetsEmpty: 'No images yet. Pictures pasted into todos / tickets or uploaded avatars will appear here.',
@@ -1640,10 +1941,30 @@ const en = {
SYNC_DECRYPT_FAILED: 'Could not decrypt cloud API keys; sign in again and retry',
AVATAR_FILE_TOO_LARGE: 'Image exceeds 10MB, please pick a smaller one',
AVATAR_DECODE_FAILED: 'Unrecognized image format (PNG / JPG / GIF / WebP supported)',
FILE_STORAGE_ADMIN_ONLY: 'Only the admin (id=1) can configure file storage',
AVATAR_VALUE_REQUIRED: 'Avatar value is required',
AVATAR_VALUE_TOO_LARGE: 'Avatar payload too large for history',
FILE_STORAGE_ADMIN_ONLY: 'You do not have permission to change file storage',
ADMIN_STEPUP_REQUIRED: 'Enter your Google Authenticator code first',
ADMIN_IP_CHANGED: 'IP changed — re-enter authenticator code',
TOTP_INVALID: 'Invalid authenticator code',
TOTP_NOT_ENABLED: 'Bind Google Authenticator first',
TOTP_ALREADY_ENABLED: 'TOTP already enabled',
USER_AI_BANNED: 'Your account is banned from AI',
TEAM_AI_BANNED: 'Your team is banned from AI',
ACCOUNT_DISABLED: 'Account disabled',
VERSION_INVALID: 'Version must be x.y.z',
VERSION_EXISTS: 'Version already exists',
FILE_TOO_LARGE: 'File too large',
SHA256_MISMATCH: 'Installer checksum mismatch',
NO_RELEASE: 'No release available',
KIND_ICON_ADMIN_ONLY: 'You do not have permission to manage kind icons',
KIND_UNKNOWN: 'Unknown launchpad kind',
CATEGORY_EXISTS: 'Primary category already exists',
ICON_NOT_FOUND: 'Icon not found',
LAUNCH_APP_NOT_FOUND: 'Launchpad app not found',
FILE_STORAGE_BAD_URL: 'Invalid server URL; it must start with http(s)://',
FILE_STORAGE_UNREACHABLE: 'Cannot reach the file server; check the URL and service',
FILE_API_UNCONFIGURED: 'Server storage is not enabled; ask the admin to configure file storage',
FILE_API_UNCONFIGURED: 'Server storage is not enabled; try again later or contact support',
IMAGE_UPLOAD_FAILED: 'Image upload failed; check the file server and retry',
FILE_PERMISSION_DENIED: 'You do not have permission to manage this file',
FILE_API_REQUEST_FAILED: 'File server request failed; try again later',
@@ -1743,12 +2064,15 @@ const router = createRouter({
{ path: '/settings', component: Settings },
{ path: '/profile', component: Profile },
{ path: '/launchpad', component: Launchpad },
{ path: '/ports', component: PortMonitor },
{ path: '/pack-tasks', component: PackTasks },
{ path: '/messages', component: Messages },
{ path: '/today', component: Today },
{ path: '/notes', component: Notes },
{ path: '/team', component: TeamHome },
{ path: '/team/tasks', component: TeamTasks },
{ path: '/team/reports', component: TeamReports }
{ path: '/team/reports', component: TeamReports },
{ path: '/admin', component: Admin }
]
})

35
frontend/src/packCmds.js Normal file
View File

@@ -0,0 +1,35 @@
// 打包命令本地存储:启动台应用 / 项目各自多条 name+cmd不进云同步。
const PACK_KEY = 'cc-pack-cmds'
/** @typedef {{ name: string, cmd: string }} PackCmd */
function loadAll() {
try { return JSON.parse(localStorage.getItem(PACK_KEY) || '{}') || {} } catch { return {} }
}
function saveAll(map) {
localStorage.setItem(PACK_KEY, JSON.stringify(map))
}
/** @param {'lp'|'proj'} scope @param {number|string} id */
export function packKey(scope, id) {
return `${scope}:${id}`
}
/** @returns {PackCmd[]} */
export function getPackCmds(scope, id) {
const list = loadAll()[packKey(scope, id)]
return Array.isArray(list) ? list.filter(x => x && String(x.cmd || '').trim()) : []
}
/** @param {PackCmd[]} cmds */
export function setPackCmds(scope, id, cmds) {
const map = loadAll()
const cleaned = (cmds || [])
.map(x => ({ name: String(x.name || '').trim(), cmd: String(x.cmd || '').trim() }))
.filter(x => x.cmd)
const k = packKey(scope, id)
if (!cleaned.length) delete map[k]
else map[k] = cleaned
saveAll(map)
}

View File

@@ -16,6 +16,51 @@ html{--topbar-h:0px}
/* 气泡贴着工具区弹出:图标右侧 + 底部对齐,跟随 rail 宽度变化 */
.rail-tools .bell-dropdown{position:absolute;left:calc(100% + 16px);right:auto;top:auto;bottom:0}
.side-rail .rail-user{margin-top:6px}
.rail-foot{display:flex;align-items:center;justify-content:center;gap:6px;margin-top:6px}
.rail-quit{display:grid;place-items:center;width:28px;height:28px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--muted);cursor:pointer;transition:color .15s,border-color .15s,background .15s;padding:0;flex:none}
.rail-quit svg{width:13px;height:13px}
.rail-quit:hover:not(:disabled){color:var(--red);border-color:rgba(240,94,104,.5);background:color-mix(in srgb,var(--red) 10%,var(--surface-2))}
.rail-quit:disabled{opacity:.45;cursor:default}
.rail-pack-wrap{position:relative;flex:none}
.rail-pack-btn{position:relative;display:grid;place-items:center;width:28px;height:28px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--muted);cursor:pointer;padding:0;transition:color .15s,border-color .15s,background .15s}
.rail-pack-btn svg{width:13px;height:13px}
.rail-pack-btn:hover{color:#a9a2ff;border-color:rgba(123,115,255,.45);background:color-mix(in srgb,var(--primary) 12%,var(--surface-2))}
.rail-pack-badge{position:absolute;top:-5px;right:-5px;min-width:14px;height:14px;padding:0 3px;border-radius:7px;background:var(--primary);color:#fff;font-size:9px;font-style:normal;display:grid;place-items:center;line-height:1;font-weight:700}
.rail-pack-badge.run{background:var(--yellow);color:#1a1400}
.rail-pack-drop{position:absolute;left:calc(100% + 10px);bottom:0;width:320px;max-height:min(420px,70vh);display:flex;flex-direction:column;z-index:40;padding:0;overflow:hidden;background:var(--surface)!important;border:1px solid var(--border);border-radius:12px;box-shadow:0 16px 44px rgba(0,0,0,.55);backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
.rail-pack-drop header{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border)}
.rail-pack-drop header b{font-size:13px}
.rail-pack-hint{font-size:10.5px;color:var(--muted);margin-right:auto}
.rail-pack-clear{width:26px;height:26px;border:0;border-radius:6px;background:transparent;color:var(--muted);display:grid;place-items:center;cursor:pointer}
.rail-pack-clear:hover{background:var(--surface-3);color:var(--text)}
.rail-pack-clear svg,.rail-pack-drop .nm-close svg{width:13px;height:13px}
.rail-pack-list{overflow:auto;padding:8px;display:flex;flex-direction:column;gap:6px}
.rail-pack-item{display:flex;gap:8px;align-items:flex-start;padding:8px;border-radius:9px;border:1px solid var(--border);background:var(--surface-2)}
.rail-pack-item.running{border-color:rgba(231,189,53,.4)}
.rail-pack-item.done{border-color:rgba(67,201,150,.35)}
.rail-pack-item.failed{border-color:rgba(240,94,104,.4)}
.rail-pack-st{width:22px;height:22px;border-radius:6px;display:grid;place-items:center;flex:none;background:var(--surface-3);color:var(--muted)}
.rail-pack-item.running .rail-pack-st{color:var(--yellow)}
.rail-pack-item.done .rail-pack-st{color:var(--green)}
.rail-pack-item.failed .rail-pack-st{color:var(--red)}
.rail-pack-st svg{width:13px;height:13px}
.rail-pack-main{min-width:0;flex:1;display:flex;flex-direction:column;gap:2px}
.rail-pack-main b{font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rail-pack-main small{display:flex;flex-wrap:wrap;gap:6px;font-size:10.5px;color:var(--muted)}
.rail-pack-main code{font-size:10.5px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,Consolas,monospace}
.rail-pack-err{margin:2px 0 0;font-size:11px;color:var(--red);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rail-pack-x{width:22px;height:22px;border:0;border-radius:6px;background:transparent;color:var(--muted);display:grid;place-items:center;cursor:pointer;flex:none}
.rail-pack-x:hover{background:var(--surface-3)}
.rail-pack-x svg{width:12px;height:12px}
.rail-pack-empty{padding:22px 10px;text-align:center;color:var(--muted);font-size:12px}
.exit-modal .modal-head .nm-close{display:grid;place-items:center;width:32px;height:32px;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}
.exit-modal .modal-head .nm-close:hover{background:var(--surface-3);color:var(--text)}
.exit-modal .modal-head .nm-close svg{width:16px;height:16px}
.exit-hint{margin:0;padding:0 20px 14px;color:var(--muted);font-size:13px}
.exit-actions{display:flex;flex-direction:column;gap:10px;padding:0 20px 22px}
.exit-actions .btn{width:100%;justify-content:center}
.exit-actions .btn.danger{background:color-mix(in srgb,var(--red) 18%,var(--surface-2));border-color:rgba(240,94,104,.45);color:var(--red)}
.exit-actions .btn.danger:hover:not(:disabled){background:color-mix(in srgb,var(--red) 28%,var(--surface-2))}
.autostart-row{display:flex;align-items:center;gap:14px}
.autostart-row small{color:var(--muted)}
.interval-input{max-width:200px}
@@ -40,10 +85,10 @@ html{--topbar-h:0px}
.user-chip:hover{border-color:var(--primary)}
.user-chip.active{border-color:var(--primary);background:color-mix(in srgb,var(--primary) 14%,var(--surface-2))}
.user-chip.active .user-name{color:var(--text)}
.user-avatar{position:relative;width:32px;height:32px;border-radius:50%;background:var(--surface-3);display:grid;place-items:center;flex:none}
.user-avatar img{width:32px;height:32px;border-radius:50%;object-fit:cover;display:block}
.user-avatar b{font-size:14px;color:#a9a2ff}
.user-avatar svg{width:16px;color:var(--muted)}
.user-avatar{position:relative;width:42px;height:42px;border-radius:50%;background:var(--surface-3);display:grid;place-items:center;flex:none}
.user-avatar img{width:42px;height:42px;border-radius:50%;object-fit:cover;display:block}
.user-avatar b{font-size:16px;color:#a9a2ff}
.user-avatar svg{width:20px;color:var(--muted)}
.user-dot{position:absolute;right:-2px;bottom:-2px;width:9px;height:9px;border-radius:50%;border:2px solid var(--side);background:#6b7482}
.user-dot.on{background:var(--green)}
.user-dot.err{background:var(--red)}
@@ -53,8 +98,8 @@ html{--topbar-h:0px}
.user-chip:hover .user-name{color:var(--text)}
@media(max-width:1150px){.user-chip{flex:0 0 auto;border:0;background:transparent;padding:4px;margin-bottom:2px}.user-name{display:none}}
.avatar-row{display:flex;gap:18px;align-items:flex-start;margin-top:18px}
.avatar-preview{width:72px;height:72px;border-radius:50%;background:var(--surface-2);border:1px solid var(--border);display:grid;place-items:center;flex:none;overflow:hidden}
.avatar-preview img{width:100%;height:100%;object-fit:cover;display:block}
.avatar-preview{position:relative;width:72px;height:72px;border-radius:50%;background:var(--surface-2);border:1px solid var(--border);display:grid;place-items:center;flex:none;overflow:hidden}
.avatar-preview img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.avatar-preview svg{width:28px;color:var(--muted)}
.avatar-controls{flex:1;min-width:0}
.avatar-controls .sync-actions{margin-top:14px}
@@ -184,6 +229,23 @@ html[data-theme=light] .lg-art{background:#141a2e}
.icon-actions button{width:30px;height:30px;display:grid;place-items:center;border-radius:7px;transition:background .2s,color .2s}
.icon-actions button:hover{background:var(--surface-3);color:var(--text)}
.group-chip{display:inline-flex;align-items:center;width:max-content;max-width:180px;height:22px;margin:0 0 7px;padding:0 8px;border-radius:999px;background:rgba(115,103,245,.14);color:#a9a2ff;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.project-title{display:flex;justify-content:flex-start;align-items:flex-start;gap:12px}
.project-title-text{min-width:0;flex:1}
.project-title h3{margin:0 0 5px;font-size:16px;line-height:1.35;white-space:normal;overflow:visible;text-overflow:unset;word-break:break-word}
.project-title p{margin:0;color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.project-icon{flex:none;width:40px;height:40px;border-radius:10px;overflow:hidden;background:var(--surface-2);display:grid;place-items:center}
.project-icon img{width:100%;height:100%;object-fit:cover}
.project-card-corner{display:flex;align-items:center;gap:2px;flex:none;margin:-2px -4px 0 0}
.project-card-corner button{width:28px;height:28px;display:grid;place-items:center;border:0;border-radius:7px;background:transparent;color:var(--muted);cursor:pointer}
.project-card-corner button:hover{background:var(--surface-3);color:var(--text)}
.project-card-corner button.danger:hover{color:var(--red);background:color-mix(in srgb,var(--red) 12%,transparent)}
.project-card-corner button:disabled{opacity:.45;cursor:default}
.project-card-corner svg{width:14px;height:14px}
.project-card-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:10px;border-top:1px solid var(--border);margin-top:4px}
.project-card-actions{margin-left:auto;display:flex;align-items:center;gap:4px}
.project-card-actions button{width:30px;height:30px;display:grid;place-items:center;border:0;border-radius:7px;background:transparent;color:var(--muted);cursor:pointer}
.project-card-actions button:hover{background:var(--surface-3);color:var(--text)}
.icon-actions{flex:none;flex-shrink:0;display:flex;align-items:center;gap:6px}
.compact-modal{width:420px}
.modal{display:flex;flex-direction:column;overflow:auto}
.modal header{align-items:center;gap:18px;padding:24px 26px 20px}
@@ -192,8 +254,8 @@ html[data-theme=light] .lg-art{background:#141a2e}
.modal header button:hover{background:var(--surface-2);color:var(--text)}
.modal>label{margin:0;padding:18px 26px 0;font-weight:800;color:var(--text)}
.modal>label:first-of-type{padding-top:24px}
.modal input,.modal textarea,.modal>label select{height:44px;margin-top:10px;border-color:rgba(145,136,255,.26);background:rgba(32,40,56,.92);box-shadow:inset 0 1px 0 rgba(255,255,255,.035);transition:border-color .2s,box-shadow .2s,background .2s}
.modal input:focus,.modal textarea:focus,.modal>label select:focus{border-color:rgba(145,136,255,.72);box-shadow:0 0 0 3px rgba(115,103,245,.18),inset 0 1px 0 rgba(255,255,255,.05)}
.modal input:not(.pack-cmd-term),.modal textarea,.modal>label select{height:44px;margin-top:10px;border-color:rgba(145,136,255,.26);background:rgba(32,40,56,.92);box-shadow:inset 0 1px 0 rgba(255,255,255,.035);transition:border-color .2s,box-shadow .2s,background .2s}
.modal input:not(.pack-cmd-term):focus,.modal textarea:focus,.modal>label select:focus{border-color:rgba(145,136,255,.72);box-shadow:0 0 0 3px rgba(115,103,245,.18),inset 0 1px 0 rgba(255,255,255,.05)}
.modal textarea{min-height:92px;height:92px}
.modal footer{gap:12px;padding:22px 26px;align-items:center}
.modal footer .btn{min-width:88px;justify-content:center}
@@ -611,9 +673,29 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .
.wb-item small svg{width:11px}
.wb-ticket-status{align-self:center}
.wb-empty{min-height:80px;padding:14px 0}
.wb-note{width:100%;min-height:150px;margin-top:14px;border:1px solid var(--border);border-radius:7px;background:rgba(0,0,0,.14);color:var(--text);padding:12px;resize:vertical;outline:none;font:inherit;font-size:13px;line-height:1.6}
.wb-note{width:100%;min-height:150px;margin:0;border:1px solid var(--border);border-radius:7px;background:rgba(0,0,0,.14);color:var(--text);padding:12px;resize:vertical;outline:none;font:inherit;font-size:13px;line-height:1.6}
.wb-note:focus{border-color:var(--primary)}
.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-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)}
.wb-note-item.on{background:color-mix(in srgb,var(--primary) 14%,transparent);color:#a9a2ff}
.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}
@media (max-width:980px){
.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%}
}
.wb-msg-head{margin-top:18px}
.wb-msg-list{display:grid;gap:6px;margin-top:12px}
.wb-msg{display:flex;justify-content:space-between;gap:10px;background:var(--surface-2);border-radius:7px;padding:9px 12px;font-size:12px}
@@ -883,7 +965,7 @@ html[data-theme=light] .calendar-cell.has-art::before{opacity:.82}
html{color-scheme:dark}
html[data-theme=light]{color-scheme:light}
.modal input:not([type=checkbox]):not([type=radio]),.modal textarea,.modal select,
.modal input:not([type=checkbox]):not([type=radio]):not(.pack-cmd-term),.modal textarea,.modal select,
.quick-form input,.quick-form select,
.rule-add input,.rule-add select,
.form-panel input:not([type=range]):not([type=checkbox]),.form-panel select{
@@ -893,13 +975,13 @@ html[data-theme=light]{color-scheme:light}
box-shadow:inset 0 1.5px 3px rgba(0,0,0,.16),inset 0 -1px 0 rgba(255,255,255,.03);
transition:border-color .18s,box-shadow .18s,background-color .18s;
}
.modal input:not([type=checkbox]):not([type=radio]):hover,.modal textarea:hover,.modal select:hover,
.modal input:not([type=checkbox]):not([type=radio]):not(.pack-cmd-term):hover,.modal textarea:hover,.modal select:hover,
.quick-form input:hover,.quick-form select:hover,
.rule-add input:hover,.rule-add select:hover,
.form-panel input:not([type=range]):not([type=checkbox]):hover,.form-panel select:hover{
border-color:color-mix(in srgb,var(--primary) 42%,var(--border));
}
.modal input:not([type=checkbox]):not([type=radio]):focus,.modal textarea:focus,.modal select:focus,
.modal input:not([type=checkbox]):not([type=radio]):not(.pack-cmd-term):focus,.modal textarea:focus,.modal select:focus,
.quick-form input:focus,.quick-form select:focus,
.rule-add input:focus,.rule-add select:focus,
.form-panel input:not([type=range]):not([type=checkbox]):focus,.form-panel select:focus{
@@ -1086,7 +1168,8 @@ input[type=checkbox],input[type=radio]{accent-color:var(--primary)}
@keyframes phSpin{to{transform:rotate(1turn)}}
@media(prefers-reduced-motion:reduce){.ph-ring{animation:none}}
.ph-photo{position:absolute;inset:4px;border-radius:50%;overflow:hidden;display:grid;place-items:center;background:linear-gradient(135deg,#232c44,#1a2233);border:3px solid var(--surface)}
.ph-photo img{width:100%;height:100%;object-fit:cover}
/* 绝对铺满:避免 grid 下大图按固有尺寸撑开,只露出左上角一角 */
.ph-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.ph-photo>b{font-size:36px;font-weight:900;color:#e6e9ff}
.ph-photo>svg{width:38px;height:38px;color:var(--muted)}
.profile-dot{position:absolute;right:5px;bottom:6px;width:16px;height:16px;border-radius:50%;border:3px solid var(--surface);background:#6b7482;z-index:2}
@@ -1284,8 +1367,7 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.quick-kind button svg{width:16px}
.quick-kind button.active{border-color:var(--primary);background:color-mix(in srgb,var(--primary) 14%,var(--surface-2));color:var(--primary)}
/* 侧边栏退出按钮:悬停转为警示色,与快捷设置按钮同尺寸 */
.quit-app-btn:hover{color:var(--red);border-color:rgba(240,94,104,.5)}
/* 侧边栏退出按钮:悬停转为警示色rail-quit 见上) */
/* 设置页文件存储操作行 */
.fs-page-actions{display:flex;gap:12px;margin-top:22px}
/* 素材库(服务器图片管理) */
@@ -1295,6 +1377,7 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.assets-count{font-size:12.5px;color:var(--muted);margin-left:auto}
.assets-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
.asset-card{position:relative;margin:0;border:1px solid var(--border);border-radius:12px;overflow:hidden;background:var(--surface-2)}
.asset-card .asset-thumb{display:block;width:100%;padding:0;border:0;background:transparent;cursor:zoom-in}
.asset-card img{display:block;width:100%;height:110px;object-fit:cover;background:var(--surface-3)}
.asset-card figcaption{padding:8px 10px;display:flex;flex-direction:column;gap:2px}
.asset-card figcaption b{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -1377,8 +1460,16 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.src-switch button:disabled{opacity:.6;cursor:default}
/* ============ 启动台 ============ */
.lp-tools{display:flex;align-items:center;gap:10px}
.lp-sys-toggle{display:inline-flex;align-items:center;gap:7px;color:var(--muted);font-size:12px;cursor:pointer;user-select:none}
.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-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}
.lp-search input{border:0;outline:0;background:transparent;color:var(--text);font:inherit;font-size:12px;width:200px;min-width:0}
.lp-search-port input{width:88px}
.lp-search:focus-within{border-color:var(--primary);color:#a9a2ff}
.lp-sys-toggle{display:inline-flex;align-items:center;gap:7px;color:var(--muted);font-size:12px;cursor:pointer;user-select:none;white-space:nowrap}
.lp-sys-toggle input{accent-color:var(--primary)}
.lp-sys-toggle em{font-style:normal;min-width:18px;height:18px;padding:0 5px;border-radius:9px;background:var(--surface-3);display:grid;place-items:center;font-size:10.5px}
.lp-section{margin-top:18px}
@@ -1387,10 +1478,52 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.lp-title em{font-style:normal;font-weight:600;color:var(--muted);font-size:12px}
.lp-empty{padding:26px;border:1px dashed var(--border);border-radius:12px;color:var(--muted);font-size:12.5px;text-align:center}
.lp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:14px}
.lp-card{display:flex;flex-direction:column;gap:9px;padding:14px 15px}
.lp-card.running{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--green) 26%,transparent)}
.lp-card{display:flex;flex-direction:column;gap:9px;padding:14px 15px;transition:opacity .25s,box-shadow .25s,border-color .25s}
.lp-card.dimmed{opacity:.62;filter:saturate(.75)}
.lp-card.running{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--green) 30%,transparent),0 0 0 0 rgba(67,201,150,.35);animation:lpGlow 2.6s ease-in-out infinite}
.lp-card.starting{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--yellow) 40%,transparent);animation:lpGlowWarm 1.6s ease-in-out infinite}
.lp-card.failed{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--red) 55%,transparent);border-color:color-mix(in srgb,var(--red) 45%,var(--border));animation:none}
.lp-card.failed .lp-name b{color:var(--red)}
@keyframes lpGlow{
0%,100%{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--green) 22%,transparent),0 0 8px rgba(67,201,150,.12)}
50%{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--green) 55%,transparent),0 0 18px rgba(67,201,150,.42)}
}
@keyframes lpGlowWarm{
0%,100%{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--yellow) 30%,transparent),0 0 6px rgba(231,189,53,.1)}
50%{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--yellow) 60%,transparent),0 0 14px rgba(231,189,53,.35)}
}
@keyframes lpPulse{0%,100%{opacity:.55;transform:scale(1)}50%{opacity:1;transform:scale(1.15)}}
.lp-status{display:inline-flex;align-items:center;gap:5px;margin-left:auto;flex:none;height:22px;padding:0 8px;border-radius:999px;font-size:11px;font-weight:700;border:1px solid var(--border);color:var(--muted);background:var(--surface-2)}
.lp-status .lp-dot{position:static;width:7px;height:7px;border-width:0;margin:0}
.lp-status.running .lp-dot,.lp-dot.on{animation:lpPulse 1.8s ease-in-out infinite}
.lp-status svg{width:12px;height:12px}
.lp-status.starting{color:var(--yellow);border-color:rgba(231,189,53,.45);background:rgba(231,189,53,.1)}
.lp-status.running{color:var(--green);border-color:rgba(67,201,150,.4);background:rgba(67,201,150,.1)}
.lp-status.failed{color:var(--red);border-color:rgba(240,94,104,.5);background:rgba(240,94,104,.12)}
.lp-status.stopped{color:var(--muted)}
.lp-cats{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.lp-cat{height:26px;padding:0 11px;border-radius:999px;border:1px solid var(--border);background:var(--surface-2);color:var(--muted);font:inherit;font-size:12px;cursor:pointer}
.lp-cat.on,.lp-cat:hover{color:var(--text);border-color:color-mix(in srgb,var(--primary) 45%,var(--border));background:color-mix(in srgb,var(--primary) 12%,var(--surface-2))}
.lp-cat.on{color:#cfc9ff}
@media(prefers-reduced-motion:reduce){.lp-card.running,.lp-card.starting,.lp-status.running .lp-dot,.lp-dot.on{animation:none}}
.lp-err-line{margin:0;font-size:11.5px;color:var(--red);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.lp-log-preview{margin:0;font-size:11px;color:var(--muted);font-family:ui-monospace,Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lp-log-modal{width:min(720px,94vw);display:flex;flex-direction:column;max-height:min(78vh,640px)}
.lp-log-toolbar{display:flex;align-items:center;gap:8px;padding:8px 16px;border-bottom:1px solid var(--border)}
.lp-log-toolbar small{margin-right:auto;color:var(--muted);font-size:11.5px}
.lp-log-body{flex:1;min-height:240px;overflow:auto;padding:10px 14px;background:#0b0f16;font-family:ui-monospace,Consolas,monospace;font-size:12px;line-height:1.45}
.lp-log-line{margin:0 0 2px;white-space:pre-wrap;word-break:break-all;color:#c9d1d9}
.lp-log-line.error{color:#ff8b8b}
.lp-log-line.warning{color:#e7bd35}
html[data-theme=light] .lp-log-body{background:#f4f6fa}
html[data-theme=light] .lp-log-line{color:#1f2937}
.lp-card header{display:flex;align-items:center;gap:10px}
.lp-icon{flex:none;width:34px;height:34px;border-radius:9px;display:grid;place-items:center}
.lp-icon{flex:none;width:34px;height:34px;border-radius:9px;display:grid;place-items:center;overflow:hidden}
.lp-icon svg{width:18px;height:18px}
.lp-icon img{width:100%;height:100%;object-fit:cover;display:block}
.lp-icon.lg{width:48px;height:48px;border-radius:12px}
.lp-icon.lg svg{width:24px;height:24px}
.lp-icon-edit{display:flex;align-items:center;gap:12px;margin-bottom:4px}
.lp-icon svg{width:17px;height:17px}
.lp-name{flex:1;min-width:0;display:grid;gap:1px}
.lp-name b{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -1400,6 +1533,14 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.lp-ports{display:flex;align-items:center;gap:5px;flex-wrap:wrap;min-height:20px}
.lp-port{padding:1px 8px;border-radius:999px;background:var(--surface-3);border:1px solid var(--glass-border);font-size:11px;font-variant-numeric:tabular-nums}
.lp-port.more{color:var(--muted)}
.lp-port-link{display:inline-flex;align-items:center;gap:3px;cursor:pointer;color:var(--text);font:inherit}
.lp-port-link svg{width:10px;height:10px;opacity:.55}
.lp-port-link:hover{border-color:color-mix(in srgb,var(--primary) 45%,var(--border));color:var(--primary);background:color-mix(in srgb,var(--primary) 10%,var(--surface-3))}
.lp-port-link:hover svg{opacity:1}
.lp-proj-row{display:flex;flex-direction:column;align-items:flex-start;gap:3px;width:100%;padding:10px 12px;margin:0 0 6px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2);color:var(--text);cursor:pointer;text-align:left}
.lp-proj-row:hover{border-color:color-mix(in srgb,var(--primary) 40%,var(--border));background:color-mix(in srgb,var(--primary) 8%,var(--surface-2))}
.lp-proj-row b{font-size:13px}
.lp-proj-row small{font-size:11px;color:var(--muted);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lp-pid{margin-left:auto;color:var(--muted);font-size:10.5px}
.lp-res{display:flex;gap:12px;color:var(--muted);font-size:11.5px}
.lp-res span{display:inline-flex;align-items:center;gap:4px;font-variant-numeric:tabular-nums}
@@ -1408,6 +1549,7 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.lp-card footer{display:flex;align-items:center;gap:6px;padding-top:9px;border-top:1px solid var(--border)}
.lp-gap{flex:1}
.lp-act{display:inline-flex;align-items:center;gap:5px;height:27px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);font:inherit;font-size:11.5px;cursor:pointer;transition:border-color .15s,color .15s}
.lp-act.icon-only{width:27px;padding:0;justify-content:center;gap:0}
.lp-act svg{width:12px;height:12px}
.lp-act:hover{border-color:var(--primary);color:#a9a2ff}
.lp-act.go{border-color:color-mix(in srgb,var(--green) 45%,transparent);color:var(--green)}
@@ -1449,8 +1591,8 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.rail-item span{font-size:10.5px;font-weight:600;letter-spacing:.3px}
.rail-item:hover{background:var(--surface-3);color:var(--text)}
.rail-item.active{background:color-mix(in srgb,var(--primary) 17%,transparent);color:#a9a2ff;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--primary) 32%,transparent)}
.rail-user{position:relative;margin-top:auto;border:0;background:transparent;cursor:pointer;padding:4px;border-radius:11px}
.rail-user:hover,.rail-user.active{background:var(--surface-3)}
.rail-user{position:relative;margin-top:auto;border:0;background:transparent;cursor:pointer;padding:4px;border-radius:14px}
.rail-user:hover{background:var(--surface-3)}
.rail-pending{position:absolute;top:-3px;right:-5px;min-width:17px;height:17px;font-size:10px;border:1px solid var(--side)}
.side-sub{flex:1;min-width:0;display:flex;flex-direction:column;padding:14px 10px 16px}
.sub-brand{flex:none;height:44px;display:flex;align-items:center;padding:0 7px;font-size:14.5px}
@@ -1503,6 +1645,10 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
.about-story{width:100%;margin-top:14px;padding:12px 18px;border-radius:13px;border:1px solid var(--glass-border);background:linear-gradient(135deg,rgba(62,201,167,.09),rgba(79,157,245,.07));text-align:left}
.about-story b{display:block;font-size:12.8px;margin-bottom:5px;color:var(--text)}
.about-story p{margin:0;font-size:12.3px;line-height:1.9;color:var(--muted)}
.about-update{width:100%;margin-top:14px;display:flex;flex-direction:column;align-items:center;gap:10px}
.about-update-hint{margin:0;font-size:12px;line-height:1.7;color:var(--muted);text-align:center}
.about-update .btn{display:inline-flex;align-items:center;gap:7px}
.about-update .btn svg{width:14px;height:14px}
.about-foot{margin:14px 0 0;font-size:11px;color:color-mix(in srgb,var(--muted) 75%,transparent)}
/* ============ 下拉“查看全部” + 消息中心 / 今日任务 / 笔记页面 ============ */
@@ -1671,8 +1817,8 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
/* 成员卡片网格 */
.team-members{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;margin-bottom:18px}
.team-member{display:flex;align-items:flex-start;gap:12px;padding:15px 16px;margin:0}
.tm-avatar{width:44px;height:44px;flex:none;border-radius:50%;overflow:hidden;display:grid;place-items:center;background:linear-gradient(135deg,rgba(115,103,245,.35),rgba(224,68,127,.3));border:1px solid var(--glass-border)}
.tm-avatar img{width:100%;height:100%;object-fit:cover}
.tm-avatar{position:relative;width:44px;height:44px;flex:none;border-radius:50%;overflow:hidden;display:grid;place-items:center;background:linear-gradient(135deg,rgba(115,103,245,.35),rgba(224,68,127,.3));border:1px solid var(--glass-border)}
.tm-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.tm-avatar b{font-size:17px;color:#fff}
.tm-main{flex:1;min-width:0;display:grid;gap:4px}
.tm-main>b{font-size:13.5px;display:flex;align-items:baseline;gap:6px;overflow:hidden;white-space:nowrap}
@@ -1874,3 +2020,179 @@ html,body,#app{height:100%;overflow:hidden}
.tb-name,.tb-ver{display:none}
.tb-menus .tb-menu-btn{padding:0 7px;font-size:11.8px}
}
/* 图片灯箱预览 */
.img-preview{position:fixed;inset:0;z-index:120;display:grid;place-items:center;background:rgba(0,0,0,.78);backdrop-filter:blur(6px)}
.img-preview-toolbar{position:absolute;top:16px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:999px;background:rgba(20,24,32,.88);border:1px solid rgba(255,255,255,.12);z-index:1}
.img-preview-toolbar button{width:34px;height:34px;display:grid;place-items:center;border:0;border-radius:50%;background:transparent;color:#fff;cursor:pointer}
.img-preview-toolbar button:hover{background:rgba(255,255,255,.12)}
.img-preview-toolbar svg{width:16px;height:16px}
.img-preview-scale{color:rgba(255,255,255,.75);font-size:12px;min-width:42px;text-align:center}
.img-preview-img{max-width:92vw;max-height:86vh;object-fit:contain;cursor:grab;user-select:none;transition:transform .05s linear}
.img-preview-img.dragging{cursor:grabbing}
/* 头像历史 */
.avatar-hist{margin-top:8px}
.avatar-hist>b{display:block;font-size:12.5px;color:var(--muted);margin-bottom:8px}
.avatar-hist-grid{display:flex;flex-wrap:wrap;gap:8px}
.avatar-hist-item{width:48px;height:48px;border-radius:50%;overflow:hidden;border:2px solid transparent;padding:0;background:var(--surface-2);cursor:pointer;display:grid;place-items:center;color:var(--muted)}
.avatar-hist-item.on{border-color:var(--primary)}
.avatar-hist-item img{width:100%;height:100%;object-fit:cover}
.avatar-hist-item svg{width:20px;height:20px}
/* 通用右键菜单:一级固定;二级为 Windows 式侧向级联浮层 */
.ctx-menu{position:fixed;z-index:90;min-width:188px;display:flex;flex-direction:column;padding:6px;border:1px solid var(--border);border-radius:10px;background:var(--surface);box-shadow:0 16px 44px rgba(0,0,0,.42)}
.ctx-menu button{display:flex;align-items:center;gap:9px;width:100%;border:0;background:transparent;color:var(--text);padding:8px 10px;border-radius:7px;cursor:pointer;font-size:13px;text-align:left}
.ctx-menu button:hover,.ctx-menu button.open{background:var(--surface-3)}
.ctx-menu button.danger{color:var(--red)}
.ctx-menu button svg{width:15px;height:15px;color:var(--muted);flex:none}
.ctx-menu .ctx-chevron{margin-left:auto;width:14px;height:14px;opacity:.55}
.ctx-menu .ctx-parent{font-weight:600}
.ctx-flyout{position:fixed;z-index:91;min-width:148px;max-height:min(280px,70vh);overflow:auto;display:flex;flex-direction:column;gap:2px;padding:6px;border:1px solid var(--border);border-radius:10px;background:var(--surface);box-shadow:0 16px 44px rgba(0,0,0,.42)}
.ctx-flyout button{display:flex;align-items:center;width:100%;border:0;background:transparent;color:var(--text);padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12.5px;text-align:left;white-space:nowrap}
.ctx-flyout button:hover{background:var(--surface-3)}
.ctx-flyout button.on{background:color-mix(in srgb,var(--primary) 14%,transparent);color:#a9a2ff}
/* 端口监控 · 系统总览 */
.pm-overview{padding:16px 18px;margin-top:4px}
.pm-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-top:4px}
.pm-stat{display:flex;flex-direction:column;gap:4px;padding:12px 14px;border-radius:12px;border:1px solid var(--border);background:var(--surface-2);min-width:0}
.pm-stat-label{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;color:var(--muted)}
.pm-stat-label svg{width:13px;height:13px}
.pm-stat b{font-size:18px;font-weight:700;letter-spacing:-.02em;line-height:1.2}
.pm-stat b.muted{font-size:13px;font-weight:600;color:var(--muted)}
.pm-stat small{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pm-charts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:14px}
.pm-chart-card{border:1px solid var(--border);border-radius:12px;background:var(--surface-2);padding:8px 10px 6px;min-width:0}
.pm-chart-card header{font-size:11.5px;color:var(--muted);padding:2px 4px 4px}
.pm-chart{height:160px;width:100%}
.pm-ports-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:4px}
.pm-ports-head .lp-title{margin:0}
.pack-cmds-modal{width:min(560px,92vw);padding:0;overflow:hidden}
.pack-cmds-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:18px 20px 14px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,color-mix(in srgb,var(--primary) 12%,transparent),transparent)}
.pack-cmds-brand{display:flex;gap:12px;align-items:flex-start;min-width:0}
.pack-cmds-ico{width:40px;height:40px;border-radius:12px;display:grid;place-items:center;flex:none;background:color-mix(in srgb,var(--primary) 18%,var(--surface-2));color:#a9a2ff;border:1px solid color-mix(in srgb,var(--primary) 30%,var(--border))}
.pack-cmds-ico svg{width:18px;height:18px}
.pack-cmds-brand h2{margin:0;font-size:16px;line-height:1.3}
.pack-cmds-brand p{margin:4px 0 0;font-size:12px;color:var(--muted);line-height:1.45}
.pack-cmds-body{padding:14px 20px 8px;display:flex;flex-direction:column;gap:10px}
.pack-cmds-meta{display:flex;align-items:center;justify-content:space-between;gap:8px}
.pack-cmds-meta b{font-size:12.5px}
.pack-cmds-meta em{font-style:normal;font-size:11.5px;color:var(--muted)}
.pack-cmds-list{display:flex;flex-direction:column;gap:10px;max-height:min(360px,48vh);overflow:auto;padding-right:2px}
.pack-cmd-card{border:1px solid var(--border);border-radius:12px;background:var(--surface-2);padding:12px;display:flex;flex-direction:column;gap:10px}
.pack-cmd-card-top{display:flex;align-items:center;justify-content:space-between}
.pack-cmd-idx{width:22px;height:22px;border-radius:7px;display:grid;place-items:center;font-size:11px;font-weight:700;background:color-mix(in srgb,var(--primary) 16%,transparent);color:#a9a2ff;flex:none}
.pack-cmd-labeled{display:flex;flex-direction:column;gap:6px;margin:0}
.pack-cmd-labeled>span{font-size:11.5px;color:var(--muted);font-weight:600}
.pack-cmd-labeled>input{height:34px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface-3);color:var(--text);font:inherit;font-size:13px}
.pack-cmd-labeled>input:focus{border-color:var(--primary);outline:0}
.pack-cmd-del{width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--muted);display:grid;place-items:center;cursor:pointer;flex:none}
.pack-cmd-del:hover{color:var(--red);background:color-mix(in srgb,var(--red) 12%,transparent)}
.pack-cmd-del svg{width:14px;height:14px}
.pack-cmd-field{display:flex;align-items:center;gap:8px;min-height:40px;padding:8px 12px;border-radius:8px;border:1px solid rgba(255,255,255,.06);background:#0b0f16;color:#8b9bb4;box-shadow:inset 0 1px 0 rgba(255,255,255,.03)}
.pack-cmd-field:focus-within{border-color:rgba(83,214,162,.35);color:#53d6a2;box-shadow:inset 0 0 0 1px rgba(83,214,162,.12)}
.pack-cmd-field svg{width:14px;height:14px;flex:none;opacity:.75}
.pack-cmds-modal .pack-cmd-field input.pack-cmd-term,
.pack-cmds-modal .pack-cmd-field input.pack-cmd-term:focus,
.pack-cmds-modal .pack-cmd-field input.pack-cmd-term:hover{
flex:1;min-width:0;height:auto!important;min-height:0!important;margin:0!important;padding:0!important;
border:0!important;outline:0!important;border-radius:0!important;
background:transparent!important;box-shadow:none!important;color:#c9d1d9!important;
font:inherit;font-size:12.5px;font-family:ui-monospace,Consolas,"Cascadia Mono",monospace;letter-spacing:0
}
.pack-cmds-modal .pack-cmd-field input.pack-cmd-term::placeholder{color:#5a6578}
html[data-theme=light] .pack-cmd-field{background:#1a2332;border-color:rgba(0,0,0,.2)}
html[data-theme=light] .pack-cmds-modal .pack-cmd-field input.pack-cmd-term,
html[data-theme=light] .pack-cmds-modal .pack-cmd-field input.pack-cmd-term:focus{color:#e8eef7!important}
html[data-theme=light] .pack-cmds-modal .pack-cmd-field input.pack-cmd-term::placeholder{color:#7a8799}
.pack-suggest{padding:10px 12px;border-radius:12px;border:1px solid color-mix(in srgb,var(--primary) 28%,var(--border));background:color-mix(in srgb,var(--primary) 8%,var(--surface-2));display:flex;flex-direction:column;gap:8px}
.pack-suggest-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.pack-suggest-top b{display:inline-flex;align-items:center;gap:6px;font-size:12.5px}
.pack-suggest-top b svg{width:14px;height:14px;color:#a9a2ff}
.pack-suggest-top em{font-style:normal;font-size:11px;color:var(--muted)}
.pack-suggest-kind{padding:2px 8px;border-radius:999px;background:color-mix(in srgb,var(--primary) 18%,transparent);color:#a9a2ff!important}
.pack-suggest-all{margin-left:auto;border:0;background:transparent;color:#a9a2ff;font:inherit;font-size:11.5px;cursor:pointer;padding:2px 4px}
.pack-suggest-all:hover{text-decoration:underline}
.pack-suggest-chips{display:flex;flex-direction:column;gap:6px;max-height:160px;overflow:auto}
.pack-suggest-chips button{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:100%;text-align:left;padding:8px 10px;border-radius:9px;border:1px solid var(--border);background:var(--surface);color:var(--text);cursor:pointer;font:inherit}
.pack-suggest-chips button:hover{border-color:rgba(123,115,255,.45);background:color-mix(in srgb,var(--primary) 10%,var(--surface))}
.pack-suggest-chips span{font-size:12.5px;font-weight:600}
.pack-suggest-chips code{font-size:11px;color:var(--muted);font-family:ui-monospace,Consolas,monospace;overflow:hidden;text-overflow:ellipsis;max-width:100%;white-space:nowrap}
.pack-suggest-empty{margin:0;font-size:12px;color:var(--muted)}
.pack-cmd-add{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:36px;border-radius:9px;border:1px dashed var(--border);background:transparent;color:var(--muted);cursor:pointer;font:inherit;font-size:12.5px}
.pack-cmd-add:hover{border-color:rgba(123,115,255,.45);color:#a9a2ff;background:color-mix(in srgb,var(--primary) 8%,transparent)}
.pack-cmd-add svg{width:14px;height:14px}
.pack-cmds-foot{display:flex;justify-content:flex-end;gap:8px;padding:12px 20px 18px;border-top:1px solid var(--border)}
@media (max-width:980px){
.pm-stats{grid-template-columns:repeat(2,minmax(0,1fr))}
.pm-charts{grid-template-columns:1fr}
}
/* 启动台两级分类 */
.lp-cats-label{font-size:11.5px;color:var(--muted);margin-right:4px;align-self:center}
.lp-cats-sub{margin-top:6px}
.lp-cat-manage{width:30px;padding:0;display:grid;place-items:center}
.lp-cat-manage svg{width:14px;height:14px}
.lp-icon-btns{display:flex;flex-wrap:wrap;gap:8px}
.lp-cat-row{flex-direction:row;align-items:center;justify-content:space-between}
/* 种类默认图标管理 */
.kind-icons-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;margin-top:14px}
.kind-icon-card{display:flex;flex-direction:column;gap:8px;padding:12px;border:1px solid var(--border);border-radius:12px;background:var(--surface-2)}
.kind-icon-preview{width:48px;height:48px;border-radius:12px;overflow:hidden;background:var(--surface-3);display:grid;place-items:center;color:var(--muted)}
.kind-icon-preview img{width:100%;height:100%;object-fit:cover}
.kind-icon-preview svg{width:22px;height:22px}
.kind-icon-ops{display:flex;flex-wrap:wrap;gap:6px}
/* 打包任务页 */
.pack-tasks-layout{display:grid;grid-template-columns:minmax(240px,320px) 1fr;gap:14px;align-items:stretch;min-height:min(68vh,720px)}
.pack-tasks-side{display:flex;flex-direction:column;gap:8px;min-height:0;max-height:min(68vh,720px);overflow:auto}
.pack-task-row{display:flex;align-items:flex-start;gap:10px;width:100%;text-align:left;padding:10px 12px;border-radius:12px;border:1px solid var(--border);background:var(--surface);color:var(--text);cursor:pointer;font:inherit}
.pack-task-row:hover{border-color:rgba(123,115,255,.4)}
.pack-task-row.on{border-color:rgba(123,115,255,.55);background:color-mix(in srgb,var(--primary) 10%,var(--surface))}
.pack-task-row.running{border-color:rgba(231,189,53,.35)}
.pack-task-row.failed{border-color:rgba(240,94,104,.35)}
.pack-task-st{width:24px;height:24px;border-radius:7px;display:grid;place-items:center;flex:none;background:var(--surface-3);color:var(--muted)}
.pack-task-row.running .pack-task-st{color:var(--yellow)}
.pack-task-row.done .pack-task-st{color:var(--green)}
.pack-task-row.failed .pack-task-st{color:var(--red)}
.pack-task-st svg{width:14px;height:14px}
.pack-task-main{min-width:0;flex:1;display:flex;flex-direction:column;gap:2px}
.pack-task-main b{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pack-task-main small{font-size:11px;color:var(--muted)}
.pack-task-main code{font-size:10.5px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,Consolas,monospace}
.pack-task-rm{width:26px;height:26px;border:0;border-radius:7px;background:transparent;color:var(--muted);display:grid;place-items:center;cursor:pointer;flex:none}
.pack-task-rm:hover{color:var(--red);background:color-mix(in srgb,var(--red) 12%,transparent)}
.pack-task-rm svg{width:13px;height:13px}
.pack-tasks-console{display:flex;flex-direction:column;min-height:0;max-height:min(68vh,720px);padding:0;overflow:hidden}
.pack-console-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:14px 16px 10px;border-bottom:1px solid var(--border)}
.pack-console-head h2{margin:0;display:inline-flex;align-items:center;gap:8px;font-size:15px}
.pack-console-head h2 svg{width:16px;height:16px;color:#a9a2ff}
.pack-console-head p{margin:4px 0 0;display:flex;flex-wrap:wrap;gap:10px;font-size:12px;color:var(--muted)}
.pack-console-head em{font-style:normal;font-weight:700}
.pack-console-head em.running{color:var(--yellow)}
.pack-console-head em.done{color:var(--green)}
.pack-console-head em.failed{color:var(--red)}
.pack-console-acts{display:flex;gap:8px;flex:none}
.pack-console-meta{display:flex;flex-direction:column;gap:4px;padding:8px 16px;border-bottom:1px solid var(--border);font-size:12px;color:var(--muted)}
.pack-console-meta span{display:inline-flex;align-items:center;gap:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pack-console-meta svg{width:13px;height:13px;flex:none}
.pack-console-meta code{font-family:ui-monospace,Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pack-console-body{flex:1;min-height:280px;overflow:auto;padding:12px 14px;background:#0b0f16;font-family:ui-monospace,Consolas,monospace;font-size:12px;line-height:1.5}
.pack-console-line{margin:0 0 2px;white-space:pre-wrap;word-break:break-all;color:#c9d1d9}
.pack-console-line.meta{color:#8b9bb4}
.pack-console-line.ok{color:#53d6a2}
.pack-console-line.err{color:#ff8b8b}
.pack-console-empty,.pack-console-placeholder{padding:40px 16px;text-align:center;color:var(--muted);font-size:13px}
.pack-console-placeholder{display:grid;place-items:center;gap:10px;flex:1}
.pack-console-placeholder svg{width:28px;height:28px;opacity:.5}
.pack-console-err{margin:0;padding:8px 14px;border-top:1px solid rgba(240,94,104,.35);color:var(--red);font-size:12px;background:color-mix(in srgb,var(--red) 8%,transparent)}
.rail-pack-item{cursor:pointer}
html[data-theme=light] .pack-console-body{background:#f4f6fa}
html[data-theme=light] .pack-console-line{color:#1f2937}
@media (max-width:980px){
.pack-tasks-layout{grid-template-columns:1fr}
.pack-tasks-side{max-height:220px}
}

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { call, on, onTasksChanged } from './api'
import { call, on, onTasksChanged, resolveImageSrc } from './api'
export const useAppStore = defineStore('app', {
state: () => ({
@@ -63,7 +63,8 @@ export const useAppStore = defineStore('app', {
localStorage.setItem('cc-settings', JSON.stringify(this.settings))
this.resolveAvatar()
},
// resolveAvatar 把设置中的头像解析为可显示的 <img> 源path 模式需经后端读文件转 dataURL
// resolveAvatar 把设置中的头像解析为可显示的 <img> 源path 模式需经后端读文件转 dataURL
// url 模式经 Go 拉取成 dataURLWebView 直接拉外站 HTTPS 常会破图)。
async resolveAvatar() {
const { avatarMode, avatarValue } = this.settings
if (!avatarMode || !avatarValue) { this.avatarSrc = ''; return }
@@ -71,6 +72,10 @@ export const useAppStore = defineStore('app', {
try { this.avatarSrc = await call('ReadImageAsDataURL', avatarValue) } catch { this.avatarSrc = '' }
return
}
if (avatarMode === 'url' || /^https?:\/\//i.test(avatarValue)) {
try { this.avatarSrc = await resolveImageSrc(avatarValue) } catch { this.avatarSrc = '' }
return
}
this.avatarSrc = avatarValue
},
async refreshSyncStatus() {

View File

@@ -0,0 +1,399 @@
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Shield, Users, Building2, Upload, BarChart3, RefreshCw, CheckCircle2, Ban, KeyRound, FolderOpen } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
const { t } = useI18n()
const store = useAppStore()
const tab = ref('overview')
const busy = ref('')
const err = ref('')
const stepupOpen = ref(false)
const stepupCode = ref('')
const stepupRisk = ref(false)
const pendingAction = ref(null)
const totp = reactive({ enabled: false, pending: false, otpauth: '', secret: '', stepupActive: false, stepupExpiresAt: '' })
const overview = ref(null)
const users = ref([])
const teams = ref([])
const releases = ref([])
const releaseForm = reactive({ version: '', channel: 'stable', changelog: '', filePath: '' })
const errText = e => {
const code = String(e).split(':')[0].trim()
const k = 'errors.' + code
return t(k) !== k ? t(k) : String(e)
}
async function withStepUp(fn) {
try {
const has = await call('AdminHasStepUp')
if (!has) {
pendingAction.value = fn
stepupRisk.value = false
stepupCode.value = ''
stepupOpen.value = true
return
}
await fn()
} catch (e) {
const code = String(e).split(':')[0].trim()
if (code === 'ADMIN_STEPUP_REQUIRED' || code === 'ADMIN_IP_CHANGED') {
pendingAction.value = fn
stepupRisk.value = code === 'ADMIN_IP_CHANGED'
stepupCode.value = ''
stepupOpen.value = true
return
}
throw e
}
}
async function confirmStepUp() {
busy.value = 'stepup'
err.value = ''
try {
await call('AdminStepUp', stepupCode.value.trim())
stepupOpen.value = false
await loadTotp()
const fn = pendingAction.value
pendingAction.value = null
if (fn) await fn()
} catch (e) {
err.value = errText(e)
} finally {
busy.value = ''
}
}
async function loadTotp() {
const s = await call('AdminTOTPStatus')
Object.assign(totp, {
enabled: !!s.enabled,
pending: !!s.pending,
otpauth: s.otpauth || '',
secret: s.secret || '',
stepupActive: !!s.stepupActive,
stepupExpiresAt: s.stepupExpiresAt || ''
})
}
async function beginTotp() {
busy.value = 'totp'
try {
const s = await call('AdminTOTPSetupBegin')
Object.assign(totp, { pending: true, otpauth: s.otpauth || '', secret: s.secret || '', enabled: false })
} catch (e) { err.value = errText(e) }
finally { busy.value = '' }
}
async function confirmTotp() {
busy.value = 'totp'
try {
await call('AdminTOTPSetupConfirm', stepupCode.value.trim())
stepupCode.value = ''
await loadTotp()
store.showToast({ type: 'success', text: t('adminTotpBound') })
} catch (e) { err.value = errText(e) }
finally { busy.value = '' }
}
async function loadOverview() {
overview.value = await call('AdminOverview', 14)
}
async function loadUsers() { users.value = await call('AdminListUsers') }
async function loadTeams() { teams.value = await call('AdminListTeams') }
async function loadReleases() { releases.value = await call('AdminListReleases') }
async function refresh() {
busy.value = 'load'
err.value = ''
try {
await loadTotp()
if (tab.value === 'overview') await loadOverview()
if (tab.value === 'users') await loadUsers()
if (tab.value === 'teams') await loadTeams()
if (tab.value === 'releases') await loadReleases()
} catch (e) { err.value = errText(e) }
finally { busy.value = '' }
}
watch(tab, () => refresh())
async function patchUser(u, field, val) {
await withStepUp(async () => {
await call('AdminPatchUser', u.id, field, val)
await loadUsers()
})
}
async function patchTeam(tm, val) {
await withStepUp(async () => {
await call('AdminPatchTeam', tm.id, val)
await loadTeams()
})
}
async function pickRelease() {
const p = await call('AdminSelectReleaseFile')
if (p) releaseForm.filePath = p
}
function applyReleaseFile(path) {
const p = String(path || '').trim()
if (!p) return
if (!/\.exe$/i.test(p)) {
store.showToast({ type: 'error', text: t('adminReleaseNeedExe') })
return
}
releaseForm.filePath = p
}
async function uploadRelease() {
await withStepUp(async () => {
busy.value = 'upload'
try {
await call('AdminUploadRelease', releaseForm.version, releaseForm.channel, releaseForm.changelog, releaseForm.filePath)
releaseForm.filePath = ''
await loadReleases()
store.showToast({ type: 'success', text: t('adminReleaseUploaded') })
} finally { busy.value = '' }
})
}
async function publishRelease(id) {
await withStepUp(async () => {
await call('AdminPublishRelease', id)
await loadReleases()
store.showToast({ type: 'success', text: t('adminReleasePublished') })
})
}
const qrUrl = computed(() => {
if (!totp.otpauth) return ''
return 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodeURIComponent(totp.otpauth)
})
let offFilesDropped = null
onMounted(() => {
refresh()
offFilesDropped = on('files-dropped', payload => {
if (tab.value !== 'releases') return
const target = payload?.target || ''
if (target && target !== 'admin-release-drop') return
const files = Array.isArray(payload?.files) ? payload.files : []
const exe = files.find(f => /\.exe$/i.test(String(f || '')))
if (exe) applyReleaseFile(exe)
else if (files.length) store.showToast({ type: 'error', text: t('adminReleaseNeedExe') })
})
})
onUnmounted(() => { offFilesDropped?.() })
</script>
<template>
<div class="page admin-page">
<header class="page-head">
<div>
<h1>{{ t('adminTitle') }}</h1>
<p class="muted">{{ t('adminSubtitle') }}</p>
</div>
<button class="btn" :disabled="!!busy" @click="refresh"><RefreshCw :size="16"/>{{ t('refresh') }}</button>
</header>
<p v-if="err" class="err-banner">{{ err }}</p>
<nav class="admin-tabs">
<button :class="{on:tab==='overview'}" @click="tab='overview'"><BarChart3 :size="15"/>{{ t('adminTabOverview') }}</button>
<button :class="{on:tab==='users'}" @click="tab='users'"><Users :size="15"/>{{ t('adminTabUsers') }}</button>
<button :class="{on:tab==='teams'}" @click="tab='teams'"><Building2 :size="15"/>{{ t('adminTabTeams') }}</button>
<button :class="{on:tab==='releases'}" @click="tab='releases'"><Upload :size="15"/>{{ t('adminTabReleases') }}</button>
<button :class="{on:tab==='security'}" @click="tab='security'"><Shield :size="15"/>{{ t('adminTabSecurity') }}</button>
</nav>
<section v-if="tab==='overview' && overview" class="admin-panel">
<div class="stat-grid">
<div class="stat"><span>{{ t('adminStatUsers') }}</span><b>{{ overview.userCount }}</b></div>
<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>
<div class="series">
<h3>{{ t('adminDAUSeries') }}</h3>
<div class="bars">
<div v-for="p in overview.dauSeries||[]" :key="'d'+p.date" class="bar" :title="p.date+': '+p.count">
<i :style="{height: Math.max(4, (p.count/Math.max(1,...(overview.dauSeries||[]).map(x=>x.count)))*80)+'px'}"></i>
<em>{{ p.date.slice(5) }}</em>
</div>
</div>
</div>
<div class="series">
<h3>{{ t('adminTokenSeries') }}</h3>
<div class="bars">
<div v-for="p in overview.tokenSeries||[]" :key="'t'+p.date" class="bar" :title="p.date">
<i :style="{height: Math.max(4, (((p.promptTokens||0)+(p.completionTokens||0))/Math.max(1,...(overview.tokenSeries||[]).map(x=>(x.promptTokens||0)+(x.completionTokens||0))))*80)+'px'}"></i>
<em>{{ p.date.slice(5) }}</em>
</div>
</div>
</div>
</section>
<section v-else-if="tab==='users'" class="admin-panel">
<table class="admin-table">
<thead><tr><th>ID</th><th>{{ t('fieldUser') }}</th><th>{{ t('profileNickname') }}</th><th>IP</th><th>{{ t('adminColAI') }}</th><th>{{ t('adminColDisabled') }}</th></tr></thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td>{{ u.id }}</td>
<td>{{ u.username }}</td>
<td>{{ u.nickname||'—' }}</td>
<td class="mono">{{ u.lastLoginIp||'—' }}</td>
<td>
<button class="btn sm" :class="{danger:u.aiBanned}" @click="patchUser(u,'aiBanned',u.aiBanned?0:1)">
<Ban :size="14"/>{{ u.aiBanned ? t('adminUnbanAI') : t('adminBanAI') }}
</button>
</td>
<td>
<button class="btn sm" :disabled="u.id===1" :class="{danger:u.disabled}" @click="patchUser(u,'disabled',u.disabled?0:1)">
{{ u.disabled ? t('adminEnable') : t('adminDisable') }}
</button>
</td>
</tr>
</tbody>
</table>
</section>
<section v-else-if="tab==='teams'" class="admin-panel">
<table class="admin-table">
<thead><tr><th>ID</th><th>{{ t('teamName') }}</th><th>{{ t('teamRole_owner') }}</th><th>{{ t('adminColMembers') }}</th><th>{{ t('adminColAI') }}</th></tr></thead>
<tbody>
<tr v-for="tm in teams" :key="tm.id">
<td>{{ tm.id }}</td>
<td>{{ tm.name }}</td>
<td>{{ tm.ownerName||tm.ownerId }}</td>
<td>{{ tm.members }}</td>
<td>
<button class="btn sm" :class="{danger:tm.aiBanned}" @click="patchTeam(tm, tm.aiBanned?0:1)">
{{ tm.aiBanned ? t('adminUnbanAI') : t('adminBanAI') }}
</button>
</td>
</tr>
</tbody>
</table>
</section>
<section v-else-if="tab==='releases'" class="admin-panel">
<div class="form-grid">
<label>{{ t('adminReleaseVersion') }}<input v-model="releaseForm.version" placeholder="2.0.1"/></label>
<label>{{ t('adminReleaseChannel') }}<input v-model="releaseForm.channel"/></label>
<label class="span2">{{ t('adminReleaseChangelog') }}<textarea v-model="releaseForm.changelog" rows="3"/></label>
<label class="span2">{{ t('adminReleaseFile') }}
<div
id="admin-release-drop"
class="release-drop"
data-file-drop-target
@click="pickRelease"
>
<Upload :size="22"/>
<div class="release-drop-body">
<b>{{ releaseForm.filePath ? releaseForm.filePath.replace(/^.*[\\/]/, '') : t('adminReleaseDropTitle') }}</b>
<small>{{ releaseForm.filePath || t('adminReleaseDropHint') }}</small>
</div>
<button type="button" class="btn" @click.stop="pickRelease"><FolderOpen :size="15"/>{{ t('browse') }}</button>
</div>
</label>
<button class="btn primary" :disabled="!!busy||!releaseForm.version||!releaseForm.filePath" @click="uploadRelease">
<Upload :size="15"/>{{ t('adminReleaseUpload') }}
</button>
</div>
<table class="admin-table" style="margin-top:1rem">
<thead><tr><th>{{ t('adminReleaseVersion') }}</th><th>{{ t('adminReleaseChannel') }}</th><th>SHA256</th><th>{{ t('adminColLatest') }}</th><th></th></tr></thead>
<tbody>
<tr v-for="r in releases" :key="r.id">
<td>{{ r.version }}</td>
<td>{{ r.channel }}</td>
<td class="mono trunc">{{ (r.sha256||'').slice(0,12) }}</td>
<td>{{ r.isLatest ? '✓' : '' }}</td>
<td><button v-if="!r.isLatest" class="btn sm" @click="publishRelease(r.id)">{{ t('adminReleasePublish') }}</button></td>
</tr>
</tbody>
</table>
</section>
<section v-else-if="tab==='security'" class="admin-panel">
<div class="sec-card">
<h3><KeyRound :size="16"/> Google Authenticator</h3>
<p class="muted">{{ t('adminTotpHint') }}</p>
<p v-if="totp.enabled" class="ok"><CheckCircle2 :size="14"/> {{ t('adminTotpEnabled') }}
<span v-if="totp.stepupActive"> · {{ t('adminStepupActive') }}</span>
</p>
<template v-else>
<button class="btn primary" :disabled="!!busy" @click="beginTotp">{{ t('adminTotpBegin') }}</button>
<div v-if="totp.pending" class="totp-setup">
<img v-if="qrUrl" :src="qrUrl" alt="QR" width="180" height="180"/>
<p class="mono">{{ totp.secret }}</p>
<input v-model="stepupCode" maxlength="8" :placeholder="t('adminTotpCodePh')"/>
<button class="btn primary" @click="confirmTotp">{{ t('adminTotpConfirm') }}</button>
</div>
</template>
<button v-if="totp.enabled" class="btn" style="margin-top:.75rem" @click="stepupOpen=true;stepupRisk=false;pendingAction=null">{{ t('adminStepupNow') }}</button>
</div>
</section>
<div v-if="stepupOpen" class="modal-mask" @click.self="stepupOpen=false">
<div class="modal">
<h3>{{ t('adminStepupTitle') }}</h3>
<p v-if="stepupRisk" class="risk">{{ t('adminIpChangedRisk') }}</p>
<p class="muted">{{ t('adminStepupHint') }}</p>
<input v-model="stepupCode" maxlength="8" autofocus :placeholder="t('adminTotpCodePh')" @keyup.enter="confirmStepUp"/>
<div class="row end">
<button class="btn" @click="stepupOpen=false">{{ t('cancel') }}</button>
<button class="btn primary" :disabled="!!busy||stepupCode.length<6" @click="confirmStepUp">{{ t('adminStepupConfirm') }}</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.admin-page{padding:1.25rem 1.5rem 2rem;max-width:1100px}
.page-head{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;margin-bottom:1rem}
.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{padding:1rem;border:1px solid var(--border);border-radius:10px;display:flex;flex-direction:column;gap:.35rem}
.stat b{font-size:1.6rem}
.series{margin-bottom:1.25rem}
.bars{display:flex;align-items:flex-end;gap:4px;height:100px;overflow-x:auto}
.bar{display:flex;flex-direction:column;align-items:center;justify-content:flex-end;min-width:28px;flex:1}
.bar i{display:block;width:100%;max-width:18px;background:var(--accent,#3b82f6);border-radius:4px 4px 0 0}
.bar em{font-size:9px;opacity:.6;margin-top:4px}
.admin-table{width:100%;border-collapse:collapse;font-size:.9rem}
.admin-table th,.admin-table td{padding:.55rem .5rem;border-bottom:1px solid var(--border);text-align:left}
.mono{font-family:ui-monospace,monospace;font-size:.8rem}
.trunc{max-width:120px;overflow:hidden;text-overflow:ellipsis}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}
.form-grid label{display:flex;flex-direction:column;gap:.3rem;font-size:.85rem}
.form-grid .span2{grid-column:1/-1}
.form-grid input,.form-grid textarea,.modal input{padding:.5rem .65rem;border-radius:8px;border:1px solid var(--border);background:transparent;color:inherit}
.release-drop{display:flex;align-items:center;gap:.85rem;padding:.9rem 1rem;border:1.5px dashed color-mix(in srgb,var(--border) 80%,var(--accent,#3b82f6));border-radius:12px;background:color-mix(in srgb,var(--surface-3,transparent) 55%,transparent);cursor:pointer;transition:border-color .18s,background .18s,box-shadow .18s}
.release-drop:hover,.release-drop.file-drop-target-active{border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent,#3b82f6) 18%,transparent)}
.release-drop > svg{flex:none;opacity:.75;color:var(--accent,#3b82f6)}
.release-drop-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:.2rem}
.release-drop-body b{font-size:.92rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.release-drop-body small{opacity:.65;font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.row{display:flex;gap:.5rem;align-items:center}
.row.end{justify-content:flex-end;margin-top:1rem}
.btn.sm{padding:.25rem .5rem;font-size:.8rem}
.btn.danger{border-color:#ef4444;color:#ef4444}
.err-banner{color:#ef4444;margin-bottom:.75rem}
.ok{color:#16a34a;display:flex;align-items:center;gap:.35rem}
.sec-card{border:1px solid var(--border);border-radius:12px;padding:1.25rem;max-width:480px}
.totp-setup{margin-top:1rem;display:flex;flex-direction:column;gap:.6rem;align-items:flex-start}
.modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:80}
.modal{background:var(--panel, #1a1f2e);border:1px solid var(--border);border-radius:12px;padding:1.25rem;width:min(400px,92vw)}
.risk{color:#f59e0b;font-weight:600}
@media(max-width:800px){.stat-grid{grid-template-columns:1fr 1fr}.form-grid{grid-template-columns:1fr}}
</style>

View File

@@ -1,12 +1,14 @@
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Folder, Code2, GitCommitHorizontal, Plus, RefreshCw, Search, Trash2, Pencil, FolderOpen, PieChart, X, CheckCircle2, CircleX, Ban, Star, CloudDownload, ChevronUp, ChevronDown } from 'lucide-vue-next'
import { Folder, Code2, GitCommitHorizontal, Plus, RefreshCw, Search, Trash2, Pencil, FolderOpen, PieChart, X, CheckCircle2, CircleX, Ban, Star, CloudDownload, ChevronUp, ChevronDown, Rocket, Image, Eye, Package, ChevronRight } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import ChartView from '../components/ChartView.vue'
import PackCmdsModal from '../components/PackCmdsModal.vue'
import { useAppStore } from '../store'
import { call, on } from '../api'
import { getPackCmds } from '../packCmds'
const store = useAppStore()
const router = useRouter()
@@ -27,6 +29,15 @@ const groupForm = reactive({ name: '' })
const srcMode = ref('local') // 新建项目来源local 本地目录 / git 克隆
const gitForm = reactive({ url: '', parentDir: '' })
const palette = ['#53d6a2', '#5da8ff', '#f7cb4d', '#a78bfa', '#ef6f8f']
const kindIcons = ref({})
const projectKinds = ref({}) // id → kind
const ctx = reactive({ open: false, x: 0, y: 0, project: null, sub: '' })
const ctxEl = ref(null)
const flyEl = ref(null)
const flyStyle = ref({ left: '0px', top: '0px' })
const packModal = ref({ open: false, id: 0, title: '', dir: '' })
const packTick = ref(0)
let subLeaveTimer = 0
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
@@ -67,6 +78,128 @@ const errorText = raw => {
return code ? t(`errors.${code}`) : String(raw)
}
function projectIcon(p) {
if (p?.icon) return p.icon
const kind = projectKinds.value[p.id]
return (kind && kindIcons.value[kind]) || ''
}
async function loadKindFallback() {
try { kindIcons.value = await call('ListKindIcons') || {} } catch { kindIcons.value = {} }
const map = { ...projectKinds.value }
await Promise.all(store.projects.map(async p => {
if (p.icon || map[p.id]) return
try { map[p.id] = await call('DetectProjectLaunchKind', p.id) } catch { /* ignore */ }
}))
projectKinds.value = map
}
function openCtx(e, p) {
clearTimeout(subLeaveTimer)
ctx.open = true
ctx.x = e.clientX
ctx.y = e.clientY
ctx.project = p
ctx.sub = ''
nextTick(() => {
const el = ctxEl.value
if (!el) return
const pad = 10
const reserve = 168
const maxX = Math.max(pad, window.innerWidth - el.offsetWidth - reserve - pad)
ctx.x = Math.max(pad, Math.min(e.clientX, maxX))
ctx.y = Math.max(pad, Math.min(e.clientY, window.innerHeight - el.offsetHeight - pad))
})
}
function closeCtx() {
clearTimeout(subLeaveTimer)
ctx.open = false
ctx.project = null
ctx.sub = ''
}
function placeFlyout(anchorEl) {
nextTick(() => {
const fly = flyEl.value
if (!anchorEl || !fly) return
const rect = anchorEl.getBoundingClientRect()
const fw = fly.offsetWidth || 160
const fh = fly.offsetHeight || 40
const pad = 8
let left = rect.right + 4
if (left + fw > window.innerWidth - pad) left = rect.left - fw - 4
left = Math.max(pad, Math.min(left, window.innerWidth - fw - pad))
let top = rect.top
if (top + fh > window.innerHeight - pad) top = Math.max(pad, window.innerHeight - fh - pad)
if (top < pad) top = pad
flyStyle.value = { left: `${left}px`, top: `${top}px` }
})
}
function openSub(kind, e) {
clearTimeout(subLeaveTimer)
ctx.sub = kind
placeFlyout(e.currentTarget)
}
function clearSubSoon() {
clearTimeout(subLeaveTimer)
subLeaveTimer = setTimeout(() => { ctx.sub = '' }, 180)
}
function keepSub() { clearTimeout(subLeaveTimer) }
function ctxPackCmds(p) {
void packTick.value
return p?.id ? getPackCmds('proj', p.id) : []
}
function openPackConfig(p) {
closeCtx()
if (!p?.id) return
packModal.value = { open: true, id: p.id, title: t('packCmdsTitleNamed', { name: p.name }), dir: p.path || '' }
}
async function runPackCmd(p, cmd) {
closeCtx()
if (!p?.id || !cmd?.cmd) return
try {
const task = await call('RunDirCommand', p.path || '', cmd.cmd, cmd.name || '')
store.showToast({ type: 'success', text: t('packCmdStarted', { name: cmd.name || cmd.cmd }) })
router.push({ path: '/pack-tasks', query: { id: task?.id || '' } })
} catch (e) {
store.showToast({ type: 'error', text: String(e?.message || e) })
}
}
async function fetchProjectIcon(p) {
closeCtx()
try {
const u = await call('FetchProjectIcon', p.id)
p.icon = u
await store.refresh()
store.showToast({ type: 'success', key: 'lpFetchIconOk' })
} catch (e) {
store.showToast({ type: 'error', text: String(e?.message || e) })
}
}
function goDetail(p) {
closeCtx()
router.push('/project/' + p.id)
}
function toLaunchpad(p) {
closeCtx()
router.push({ path: '/launchpad', query: { projectId: p.id } })
}
function toggleFav(p) {
closeCtx()
store.toggleFavorite(p.id)
}
function editProject(p) {
closeCtx()
open(p)
}
function deleteProject(p) {
closeCtx()
remove(p)
}
function refreshFromCtx(p) {
closeCtx()
refreshProject(p)
}
function open(p) {
editing.value = p?.id || 0
Object.assign(form, p ? { name: p.name, path: p.path, description: p.description, groupId: p.groupId || defaultGroupId.value } : { name: '', path: '', description: '', groupId: store.selectedProjectGroupId || defaultGroupId.value })
@@ -253,9 +386,16 @@ async function bindNow() {
onMounted(() => {
consumePendingAction()
loadPending()
loadKindFallback()
offSync = on('sync:done', loadPending)
document.addEventListener('click', closeCtx)
})
onUnmounted(() => offSync?.())
onUnmounted(() => {
clearTimeout(subLeaveTimer)
offSync?.()
document.removeEventListener('click', closeCtx)
})
watch(() => store.projects.length, () => loadKindFallback())
</script>
<template>
@@ -353,15 +493,18 @@ onUnmounted(() => offSync?.())
</div>
</div>
<div class="project-grid">
<article v-for="p in filtered" :key="p.id" class="project-card shine-card" :class="{ favorited: store.favorites.includes(p.id) }" @click="router.push('/project/' + p.id)">
<article v-for="p in filtered" :key="p.id" class="project-card shine-card" :class="{ favorited: store.favorites.includes(p.id) }" @click="router.push('/project/' + p.id)" @contextmenu.prevent="openCtx($event, p)">
<div class="project-title">
<div><h3>{{ p.name }}</h3><span class="group-chip">{{ p.groupId === 1 ? t('myProjectGroup') : (p.groupName || t('projectGroup')) }}</span><p :title="p.path">{{ p.path }}</p></div>
<div class="icon-actions">
<button class="wb-star" :class="{ active: store.favorites.includes(p.id) }" :title="store.favorites.includes(p.id) ? t('unfavorite') : t('favorite')" @click.stop="store.toggleFavorite(p.id)"><Star /></button>
<button :title="t('refreshProject')" :disabled="projectRunning(p.id)" @click.stop="refreshProject(p)"><RefreshCw :class="{ spin: projectRunning(p.id) }" /></button>
<button :title="t('edit')" @click.stop="open(p)"><Pencil /></button>
<button :title="t('delete')" @click.stop="remove(p)"><Trash2 /></button>
<span v-if="projectIcon(p)" class="project-icon"><img :src="projectIcon(p)" alt="" /></span>
<div class="project-title-text">
<h3 :title="p.name">{{ p.name }}</h3>
<span class="group-chip">{{ p.groupId === 1 ? t('myProjectGroup') : (p.groupName || t('projectGroup')) }}</span>
<p :title="p.path">{{ p.path }}</p>
</div>
<span class="project-card-corner" @click.stop>
<button :title="t('refreshProject')" :disabled="projectRunning(p.id)" @click="refreshProject(p)"><RefreshCw :class="{ spin: projectRunning(p.id) }" /></button>
<button class="danger" :title="t('delete')" @click="remove(p)"><Trash2 /></button>
</span>
</div>
<div class="metric-row project-metrics">
<div class="metric-total"><b>{{ fmt(p.stats.totalLines) }}</b><span>{{ t('totalLineCount') }}</span></div>
@@ -373,11 +516,61 @@ onUnmounted(() => offSync?.())
<span v-for="(x, i) in p.languages.slice(0, 4)" :key="x.name"><i :style="{ background: palette[i] }" />{{ x.name }} {{ Math.round(x.code / Math.max(1, total(p)) * 100) }}%</span>
<span v-if="!p.languages?.length">{{ t('unanalyzed') }}</span>
</div>
<footer><span> {{ p.stats.commitCount }} {{ t('commitsUnit') }}</span><b class="positive">+{{ fmt(p.stats.addedLines) }}</b><b class="negative">-{{ fmt(p.stats.deletedLines) }}</b></footer>
<footer class="project-card-foot">
<span> {{ p.stats.commitCount }} {{ t('commitsUnit') }}</span>
<b class="positive">+{{ fmt(p.stats.addedLines) }}</b>
<b class="negative">-{{ fmt(p.stats.deletedLines) }}</b>
<span class="project-card-actions" @click.stop>
<button class="wb-star" :class="{ active: store.favorites.includes(p.id) }" :title="store.favorites.includes(p.id) ? t('unfavorite') : t('favorite')" @click="store.toggleFavorite(p.id)"><Star /></button>
<button :title="t('lpToLaunchpad')" @click="router.push({ path: '/launchpad', query: { projectId: p.id } })"><Rocket /></button>
<button :title="t('edit')" @click="open(p)"><Pencil /></button>
</span>
</footer>
</article>
<button class="add-card shine-card" @click="open()"><span><Plus /></span>{{ t('addProject') }}</button>
</div>
</div>
<Teleport to="body">
<div v-if="ctx.open && ctx.project" ref="ctxEl" class="ctx-menu" :style="{ left: ctx.x + 'px', top: ctx.y + 'px' }" @click.stop @mouseleave="clearSubSoon">
<button type="button" @mouseenter="ctx.sub = ''" @click="goDetail(ctx.project)"><Eye />{{ t('viewDetail') }}</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="toggleFav(ctx.project)"><Star />{{ store.favorites.includes(ctx.project.id) ? t('unfavorite') : t('favorite') }}</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="toLaunchpad(ctx.project)"><Rocket />{{ t('lpToLaunchpad') }}</button>
<button
type="button"
class="ctx-parent"
:class="{ open: ctx.sub === 'pack' }"
@mouseenter="ctxPackCmds(ctx.project).length ? openSub('pack', $event) : (ctx.sub = '')"
@click="ctxPackCmds(ctx.project).length ? openSub('pack', $event) : openPackConfig(ctx.project)"
>
<Package />{{ t('packRun') }}<ChevronRight v-if="ctxPackCmds(ctx.project).length" class="ctx-chevron" />
</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="fetchProjectIcon(ctx.project)"><Image />{{ t('lpFetchIcon') }}</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="refreshFromCtx(ctx.project)"><RefreshCw />{{ t('refreshProject') }}</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="editProject(ctx.project)"><Pencil />{{ t('edit') }}</button>
<button type="button" class="danger" @mouseenter="ctx.sub = ''" @click="deleteProject(ctx.project)"><Trash2 />{{ t('delete') }}</button>
</div>
<div
v-if="ctx.open && ctx.project && ctx.sub === 'pack'"
ref="flyEl"
class="ctx-flyout"
:style="flyStyle"
@click.stop
@mouseenter="keepSub"
@mouseleave="clearSubSoon"
>
<button v-for="(c, i) in ctxPackCmds(ctx.project)" :key="i" type="button" :title="c.cmd" @click="runPackCmd(ctx.project, c)">{{ c.name || c.cmd }}</button>
<button type="button" class="on" @click="openPackConfig(ctx.project)"><Package />{{ t('packCmdsConfig') }}</button>
</div>
<PackCmdsModal
:open="packModal.open"
scope="proj"
:target-id="packModal.id"
:title="packModal.title"
:dir="packModal.dir"
@close="packModal.open = false"
@saved="packTick++"
/>
</Teleport>
<Teleport to="body">
<div v-if="modal" class="overlay" @click.self="!saving && (modal = false)">
<form class="modal" @submit.prevent="save">

View File

@@ -1,24 +1,54 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Rocket, Plus, RefreshCw, Play, Square, Pencil, Trash2, Pin, FolderOpen, X, Hexagon, Zap, FileCode2, Coffee, Database, Globe, Boxes, Box, Cpu, MemoryStick, HardDrive, Sparkles } from 'lucide-vue-next'
import { Browser } from '@wailsio/runtime'
import { Rocket, Plus, RefreshCw, Play, Square, Pencil, Trash2, FolderOpen, 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 { call, on } from '../api'
import { useAppStore } from '../store'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
import PackCmdsModal from '../components/PackCmdsModal.vue'
import { getPackCmds } from '../packCmds'
// 启动台:扫描本机监听端口的服务 + 管理保存的应用(启动/停止/资源占用)。
const { t } = useI18n()
const store = useAppStore()
const route = useRoute()
const router = useRouter()
const aiOpen = ref(false)
const entries = ref([])
const loading = ref(false)
const showSys = ref(localStorage.getItem('cc-lp-sys') === '1')
const editing = ref(null) // 编辑/新建表单数据
const nameQ = ref('')
const portQ = ref('')
const primaryQ = ref(Number(localStorage.getItem('cc-lp-primary') || 0) || 0) // 一级筛选0=全部
const catQ = ref(localStorage.getItem('cc-lp-cat') || '') // 二级筛选;空=全部
const editing = ref(null)
const editErr = ref('')
const suggest = ref({ start: [], stop: [] })
const busy = ref({}) // id/pid -> true 启停按钮防抖
const busy = ref({})
const projectPick = ref(false)
const projects = ref([])
const projectQ = ref('')
const logOpen = ref(null)
const logLines = ref([])
const logLoading = ref(false)
const primaryCats = ref([])
const kindIcons = ref({})
const catMgr = ref(false)
const catForm = ref({ id: 0, name: '' })
const ctx = ref({ open: false, x: 0, y: 0, app: null, sub: '' })
const ctxEl = ref(null)
const flyEl = ref(null)
const flyStyle = ref({ left: '0px', top: '0px' })
const packModal = ref({ open: false, id: 0, title: '', dir: '' })
const packTick = ref(0) // 配置保存后刷新右键子菜单
let subLeaveTimer = 0
let timer = 0
let offChanged = null
let offLog = null
const KINDS = ['node', 'go', 'python', 'java', 'php', 'dotnet', 'mysql', 'redis', 'nginx', 'web', 'other']
const KINDS = ['node', 'go', 'python', 'java', 'php', 'dotnet', 'exe', 'mysql', 'redis', 'nginx', 'web', 'other']
const CAT_PRESETS = ['前端', '后端', '数据库', '工具', '桌面', '其他']
const KIND_UI = {
node: { icon: Hexagon, color: '#8cc84b' },
go: { icon: Zap, color: '#00add8' },
@@ -26,6 +56,7 @@ const KIND_UI = {
java: { icon: Coffee, color: '#f89820' },
php: { icon: FileCode2, color: '#a78bfa' },
dotnet: { icon: Boxes, color: '#8b5cf6' },
exe: { icon: Box, color: '#60a5fa' },
mysql: { icon: Database, color: '#4f9df5' },
redis: { icon: Database, color: '#f16a5b' },
nginx: { icon: Globe, color: '#26c795' },
@@ -33,102 +64,520 @@ const KIND_UI = {
other: { icon: Box, color: 'var(--muted)' }
}
const kindUI = k => KIND_UI[k] || KIND_UI.other
function displayIcon(x) {
if (x?.icon) return x.icon
return kindIcons.value[x?.kind] || ''
}
function primaryName(id) {
return primaryCats.value.find(c => c.id === id)?.name || ''
}
// Windows 关键系统进程:默认隐藏,避免误停
const SYS_NAMES = ['system', 'svchost.exe', 'lsass.exe', 'wininit.exe', 'services.exe', 'csrss.exe', 'winlogon.exe', 'spoolsv.exe', 'searchindexer.exe', 'memcompression', 'registry']
const isSys = x => !x.id && (SYS_NAMES.includes((x.name || '').toLowerCase()) || /^PID \d+$/.test(x.name || ''))
function matchEntry(x) {
const nq = nameQ.value.trim().toLowerCase()
if (nq) {
const hay = [x.name, x.exe, x.cmdline, x.dir].filter(Boolean).join('\n').toLowerCase()
if (!hay.includes(nq)) return false
}
const pq = portQ.value.trim()
if (pq) {
const ports = x.ports?.length ? x.ports : (x.port ? [x.port] : [])
if (!ports.some(p => String(p).includes(pq))) return false
}
return true
}
const myApps = computed(() => entries.value.filter(x => x.id > 0))
const scanned = computed(() => entries.value.filter(x => !x.id && (showSys.value || !isSys(x))))
const hiddenCount = computed(() => entries.value.filter(x => !x.id && isSys(x)).length)
const myApps = computed(() => entries.value.filter(x => {
if (!(x.id > 0 && matchEntry(x))) return false
if (primaryQ.value && Number(x.categoryId || 0) !== primaryQ.value) return false
if (catQ.value && (x.category || '') !== catQ.value) return false
return true
}))
const secondaryCats = computed(() => {
const set = new Set(CAT_PRESETS)
for (const x of entries.value) {
if (x.id > 0 && x.category) {
if (!primaryQ.value || Number(x.categoryId || 0) === primaryQ.value) set.add(x.category)
}
}
return [...set]
})
const filteredProjects = computed(() => {
const q = projectQ.value.trim().toLowerCase()
if (!q) return projects.value
return projects.value.filter(p => (p.name + ' ' + p.path).toLowerCase().includes(q))
})
function setPrimary(id) {
primaryQ.value = primaryQ.value === id ? 0 : id
localStorage.setItem('cc-lp-primary', String(primaryQ.value))
if (primaryQ.value) {
catQ.value = ''
localStorage.setItem('cc-lp-cat', '')
}
}
function setCat(c) {
catQ.value = catQ.value === c ? '' : c
localStorage.setItem('cc-lp-cat', catQ.value)
}
async function loadCats() {
try { primaryCats.value = await call('ListLaunchCategories') || [] } catch { primaryCats.value = [] }
}
async function loadKindIcons() {
try { kindIcons.value = await call('ListKindIcons') || {} } catch { kindIcons.value = {} }
}
async function load() {
loading.value = true
try { entries.value = await call('ListLaunchEntries') } catch { /* 后端未就绪 */ }
loading.value = false
}
function toggleSys() {
showSys.value = !showSys.value
localStorage.setItem('cc-lp-sys', showSys.value ? '1' : '0')
function applyProfile(p, base = {}) {
editing.value = {
id: base.id || 0,
name: p.name || base.name || '',
kind: p.kind || 'other',
port: p.port || 0,
dir: p.dir || '',
startCmd: p.startCmd || '',
stopCmd: p.stopCmd || '',
icon: p.icon || '',
categoryId: base.categoryId || p.categoryId || primaryQ.value || 0,
category: base.category || p.category || ''
}
suggest.value = { start: p.start || [], stop: p.stop || [] }
peekFormIcon()
}
// ---- 添加 / 编辑 ----
async function openForm(x) {
editErr.value = ''
ctx.value.open = false
editing.value = x
? { id: x.id || 0, name: x.name || '', kind: x.kind || 'other', port: x.port || x.ports?.[0] || 0, dir: x.dir || '', startCmd: x.startCmd || '', stopCmd: x.stopCmd || '' }
: { id: 0, name: '', kind: 'other', port: 0, dir: '', startCmd: '', stopCmd: '' }
await loadSuggest()
? { id: x.id || 0, name: x.name || '', kind: x.kind || 'other', port: x.port || x.ports?.[0] || 0, dir: x.dir || '', startCmd: x.startCmd || '', stopCmd: x.stopCmd || '', icon: x.icon || '', categoryId: x.categoryId || 0, category: x.category || '' }
: { id: 0, name: '', kind: 'other', port: 0, dir: '', startCmd: '', stopCmd: '', icon: '', categoryId: primaryQ.value || 0, category: catQ.value || '' }
if (editing.value.dir && !editing.value.startCmd) {
await detectDir(false)
} else {
await loadSuggest()
}
}
async function loadSuggest() {
try { suggest.value = await call('LaunchCmdSuggest', editing.value.kind) } catch { suggest.value = { start: [], stop: [] } }
}
async function detectDir(overwrite = true) {
if (!editing.value?.dir) return
try {
const p = await call('DetectLaunchProfile', editing.value.dir)
if (!p) return
if (overwrite || !editing.value.name) editing.value.name = p.name || editing.value.name
if (overwrite || !editing.value.kind || editing.value.kind === 'other') editing.value.kind = p.kind || editing.value.kind
if (overwrite || !editing.value.port) editing.value.port = p.port || editing.value.port
if (overwrite || !editing.value.startCmd) editing.value.startCmd = p.startCmd || editing.value.startCmd
suggest.value = { start: p.start || [], stop: p.stop || [] }
if (!suggest.value.start?.length) await loadSuggest()
} catch { await loadSuggest() }
}
async function pickDir() {
try {
const d = await call('SelectDirectory')
if (d) editing.value.dir = d
if (d) {
editing.value.dir = d
await detectDir(true)
}
} catch { /* 用户取消 */ }
}
async function onKindChange() {
await loadSuggest()
}
async function saveForm() {
editErr.value = ''
try {
await call('SaveLaunchApp', { ...editing.value, port: Number(editing.value.port) || 0 })
await call('SaveLaunchApp', {
...editing.value,
port: Number(editing.value.port) || 0,
categoryId: Number(editing.value.categoryId) || 0
})
editing.value = null
await load()
} catch (e) {
editErr.value = String(e?.message || e)
}
}
async function fetchIcon(x) {
ctx.value.open = false
try {
await call('FetchLaunchIcon', x.id)
await load()
} catch {
try {
const u = await call('PeekLaunchIcon', x.port || 0, x.dir || '')
if (u && editing.value) editing.value.icon = u
} catch { /* 未找到 */ }
}
}
async function peekFormIcon() {
if (!editing.value) return
try {
editing.value.icon = await call('PeekLaunchIcon', Number(editing.value.port) || 0, editing.value.dir || '')
} catch { /* ignore */ }
}
async function pickLocalIcon() {
if (!editing.value) return
try {
const u = await call('PickLaunchIconImage')
if (u) editing.value.icon = u
} catch (e) { store.showToast({ type: 'error', text: String(e) }) }
}
function clearFormIcon() {
if (editing.value) editing.value.icon = ''
}
async function pickCardIcon(x) {
ctx.value.open = false
try {
const u = await call('PickLaunchIconImage')
if (!u) return
await call('SetLaunchAppIcon', x.id, u)
await load()
} catch (e) { store.showToast({ type: 'error', text: String(e) }) }
}
async function clearCardIcon(x) {
ctx.value.open = false
await call('SetLaunchAppIcon', x.id, '')
await load()
}
async function removeApp(x) {
ctx.value.open = false
if (!confirm(t('lpDelConfirm', { name: x.name }))) return
await call('DeleteLaunchApp', x.id)
await load()
}
// ---- 启动 / 停止 ----
async function patchAppCats(x, patch) {
ctx.value.open = false
const full = {
id: x.id,
name: x.name,
kind: x.kind,
port: x.port || 0,
dir: x.dir || '',
startCmd: x.startCmd || '',
stopCmd: x.stopCmd || '',
icon: x.icon || '',
categoryId: x.categoryId || 0,
category: x.category || '',
...patch
}
await call('SaveLaunchApp', full)
await load()
}
function openCtx(e, x) {
clearTimeout(subLeaveTimer)
ctx.value = { open: true, x: e.clientX, y: e.clientY, app: x, sub: '' }
nextTick(() => {
const el = ctxEl.value
if (!el) return
const pad = 10
// 预留右侧二级菜单宽度,避免贴边后二级无处可放
const reserve = 168
const maxX = Math.max(pad, window.innerWidth - el.offsetWidth - reserve - pad)
ctx.value.x = Math.max(pad, Math.min(e.clientX, maxX))
ctx.value.y = Math.max(pad, Math.min(e.clientY, window.innerHeight - el.offsetHeight - pad))
})
}
function closeCtx() {
clearTimeout(subLeaveTimer)
ctx.value.open = false
ctx.value.sub = ''
}
function placeFlyout(anchorEl) {
nextTick(() => {
const fly = flyEl.value
if (!anchorEl || !fly) return
const rect = anchorEl.getBoundingClientRect()
const fw = fly.offsetWidth || 160
const fh = fly.offsetHeight || 40
const pad = 8
// 默认开在一级右侧,不覆盖一级;右侧不够则开到左侧
let left = rect.right + 4
if (left + fw > window.innerWidth - pad) {
left = rect.left - fw - 4
}
left = Math.max(pad, Math.min(left, window.innerWidth - fw - pad))
let top = rect.top
if (top + fh > window.innerHeight - pad) {
top = Math.max(pad, window.innerHeight - fh - pad)
}
if (top < pad) top = pad
flyStyle.value = { left: `${left}px`, top: `${top}px` }
})
}
function openSub(kind, e) {
clearTimeout(subLeaveTimer)
ctx.value.sub = kind
placeFlyout(e.currentTarget)
}
function clearSubSoon() {
clearTimeout(subLeaveTimer)
subLeaveTimer = setTimeout(() => { ctx.value.sub = '' }, 180)
}
function keepSub() { clearTimeout(subLeaveTimer) }
function ctxPackCmds(app) {
void packTick.value
return app?.id ? getPackCmds('lp', app.id) : []
}
function openPackConfig(app) {
ctx.value.open = false
if (!app?.id) return
packModal.value = { open: true, id: app.id, title: t('packCmdsTitleNamed', { name: app.name }), dir: app.dir || '' }
}
async function runPackCmd(app, cmd) {
ctx.value.open = false
if (!app?.id || !cmd?.cmd) return
try {
const task = await call('RunDirCommand', app.dir || '', cmd.cmd, cmd.name || '')
store.showToast({ type: 'success', text: t('packCmdStarted', { name: cmd.name || cmd.cmd }) })
router.push({ path: '/pack-tasks', query: { id: task?.id || '' } })
} catch (e) {
store.showToast({ type: 'error', text: String(e?.message || e) })
}
}
function openCatMgr() {
catMgr.value = true
catForm.value = { id: 0, name: '' }
}
async function savePrimaryCat() {
const name = catForm.value.name.trim()
if (!name) return
try {
await call('SaveLaunchCategory', { id: catForm.value.id || 0, name, sort: 0 })
catForm.value = { id: 0, name: '' }
await loadCats()
} catch (e) { store.showToast({ type: 'error', text: String(e) }) }
}
function editPrimaryCat(c) {
catForm.value = { id: c.id, name: c.name }
}
async function deletePrimaryCat(c) {
if (!confirm(t('lpCatDelConfirm', { name: c.name }))) return
await call('DeleteLaunchCategory', c.id)
if (primaryQ.value === c.id) setPrimary(0)
await loadCats()
await load()
}
async function openProjectPick() {
projectQ.value = ''
projectPick.value = true
try { projects.value = await call('ListProjects') } catch { projects.value = [] }
}
async function pickProject(p) {
projectPick.value = false
editErr.value = ''
try {
const draft = await call('DraftLaunchFromProject', p.id)
applyProfile(draft)
} catch (e) {
await openForm({ name: p.name, dir: p.path, kind: 'other', port: 0 })
editErr.value = String(e?.message || e)
}
}
async function addFromProjectId(id) {
const n = Number(id)
if (!n) return
try {
const draft = await call('DraftLaunchFromProject', n)
applyProfile(draft)
} catch { /* ignore */ }
}
function consumePinQuery() {
const q = route.query
if (q.pin !== '1' && !q.name && !q.port) return false
if (q.pin !== '1') return false
openForm({
name: String(q.name || ''),
kind: String(q.kind || 'other'),
port: Number(q.port) || 0,
dir: String(q.dir || ''),
startCmd: '',
stopCmd: '',
icon: '',
categoryId: primaryQ.value || 0,
category: catQ.value || ''
})
return true
}
function openPort(p) {
const n = Number(p)
if (!n || n <= 0) return
try { Browser.OpenURL(`http://127.0.0.1:${n}`) } catch { /* ignore */ }
}
function statusOf(x) {
if (x.status === 'starting' || busy.value[x.id] === 'start') return 'starting'
if (x.status === 'failed') return 'failed'
if (x.running || x.status === 'running') return 'running'
return 'stopped'
}
function statusText(x) {
const s = statusOf(x)
if (s === 'starting') return t('lpStarting')
if (s === 'failed') return t('lpFailed')
if (s === 'running') return t('lpRunning')
return t('lpStopped')
}
async function openLogs(x) {
logOpen.value = x.id
logLoading.value = true
try {
logLines.value = await call('ListLaunchLogs', x.id, 300)
} catch { logLines.value = [] }
logLoading.value = false
await nextTickScroll()
}
async function nextTickScroll() {
await new Promise(r => setTimeout(r, 30))
const el = document.querySelector('.lp-log-body')
if (el) el.scrollTop = el.scrollHeight
}
async function clearLogs() {
if (!logOpen.value) return
await call('ClearLaunchLogs', logOpen.value)
logLines.value = []
}
async function start(x) {
if (!x.startCmd) {
await openForm(x)
editErr.value = t('lpNeedCmd')
return
}
busy.value[x.id] = true
try { await call('StartLaunchApp', x.id) } catch (e) {
if (String(e?.message || e).includes('LAUNCH_CMD_REQUIRED')) { await openForm(x); editErr.value = t('lpNeedCmd') }
busy.value[x.id] = 'start'
const hit = entries.value.find(e => e.id === x.id)
if (hit) { hit.status = 'starting'; hit.lastError = '' }
try {
await call('StartLaunchApp', x.id)
await openLogs(x)
} catch (e) {
const msg = String(e?.message || e)
if (msg.includes('LAUNCH_CMD_REQUIRED')) { await openForm(x); editErr.value = t('lpNeedCmd') }
if (hit) { hit.status = 'failed'; hit.lastError = msg }
}
busy.value[x.id] = false
setTimeout(load, 800) // 给进程一点起监听的时间
setTimeout(load, 600)
}
async function stop(x) {
if (!confirm(t('lpStopConfirm', { name: x.name }))) return
const key = x.id || x.pid
busy.value[key] = true
busy.value[key] = 'stop'
try { await call('StopLaunchApp', x.id || 0, x.pid || 0) } catch { /* 已记录日志 */ }
busy.value[key] = false
setTimeout(load, 500)
}
async function restart(x) {
ctx.value.open = false
if (!x?.id) return
if (!x.startCmd) {
await openForm(x)
editErr.value = t('lpNeedCmd')
return
}
if (!confirm(t('lpRestartConfirm', { name: x.name }))) return
busy.value[x.id] = 'start'
const hit = entries.value.find(e => e.id === x.id)
if (hit) { hit.status = 'starting'; hit.lastError = '' }
try {
await call('RestartLaunchApp', x.id)
await openLogs(x)
} catch (e) {
const msg = String(e?.message || e)
if (msg.includes('LAUNCH_CMD_REQUIRED')) { await openForm(x); editErr.value = t('lpNeedCmd') }
if (hit) { hit.status = 'failed'; hit.lastError = msg }
}
busy.value[x.id] = false
setTimeout(load, 600)
}
const fmtCPU = v => (v >= 10 ? v.toFixed(0) : v.toFixed(1)) + '%'
const fmtMem = v => v >= 1024 ? (v / 1024).toFixed(1) + ' GB' : v.toFixed(0) + ' MB'
const fmtIO = v => v >= 1024 ? (v / 1024).toFixed(1) + ' MB/s' : v.toFixed(0) + ' KB/s'
const entryPorts = x => (x.ports?.length ? x.ports : (x.port ? [x.port] : []))
const logAppName = computed(() => entries.value.find(e => e.id === logOpen.value)?.name || '')
onMounted(() => {
function onDocClick() { if (ctx.value.open) closeCtx() }
onMounted(async () => {
loadCats()
loadKindIcons()
load()
timer = setInterval(load, 5000)
offChanged = on('launchpad:changed', load)
offLog = on('launchpad:log', ev => {
const d = Array.isArray(ev) ? ev[0] : ev
if (!d || d.appId !== logOpen.value) return
logLines.value = [...logLines.value, { id: Date.now(), appId: d.appId, level: d.level || 'info', line: d.line || '', createdAt: new Date().toISOString() }]
nextTickScroll()
})
document.addEventListener('click', onDocClick)
if (route.query.projectId) {
await addFromProjectId(route.query.projectId)
router.replace({ path: '/launchpad', query: {} })
} else if (consumePinQuery()) {
router.replace({ path: '/launchpad', query: {} })
}
})
onUnmounted(() => {
clearInterval(timer)
clearTimeout(subLeaveTimer)
offChanged?.()
offLog?.()
document.removeEventListener('click', onDocClick)
})
watch(() => route.query.projectId, async id => {
if (id) {
await addFromProjectId(id)
router.replace({ path: '/launchpad', query: {} })
}
})
watch(() => route.query.pin, async v => {
if (v === '1' && consumePinQuery()) {
router.replace({ path: '/launchpad', query: {} })
}
})
onUnmounted(() => { clearInterval(timer); offChanged?.() })
</script>
<template>
<div class="page launchpad-page">
<header class="page-head sticky-head">
<div><h1>{{ t('launchpad') }}</h1><p>{{ t('launchpadSubtitle') }}</p></div>
<div class="lp-tools">
<label class="lp-sys-toggle"><input type="checkbox" :checked="showSys" @change="toggleSys" />{{ t('lpShowSys') }}<em v-if="hiddenCount && !showSys">{{ hiddenCount }}</em></label>
<button class="btn secondary" @click="aiOpen = true"><Sparkles />{{ t('aiScopeBtn') }}</button>
<button class="btn secondary" :disabled="loading" @click="load"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
<button class="btn" @click="openForm(null)"><Plus />{{ t('lpAddApp') }}</button>
<header class="page-head sticky-head lp-head">
<div class="lp-head-top">
<div><h1>{{ t('launchpad') }}</h1><p>{{ t('launchpadSubtitle') }}</p></div>
<div class="lp-tools">
<button class="btn secondary" @click="aiOpen = true"><Sparkles />{{ t('aiScopeBtn') }}</button>
<button class="btn secondary" :disabled="loading" @click="load"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
<button class="btn secondary" @click="openProjectPick"><FolderGit2 />{{ t('lpAddFromProject') }}</button>
<button class="btn" @click="openForm(null)"><Plus />{{ t('lpAddApp') }}</button>
</div>
</div>
<div class="lp-filters">
<label class="lp-search"><Search /><input v-model.trim="nameQ" type="search" :placeholder="t('lpSearchName')" /></label>
<label class="lp-search lp-search-port"><Search /><input v-model.trim="portQ" type="search" inputmode="numeric" :placeholder="t('lpSearchPort')" /></label>
</div>
<div class="lp-cats">
<span class="lp-cats-label">{{ t('lpPrimaryCat') }}</span>
<button type="button" class="lp-cat" :class="{ on: !primaryQ }" @click="setPrimary(0)">{{ t('lpCatAll') }}</button>
<button v-for="c in primaryCats" :key="c.id" type="button" class="lp-cat" :class="{ on: primaryQ === c.id }" @click="setPrimary(c.id)">{{ c.name }}</button>
<button type="button" class="lp-cat lp-cat-manage" :title="t('lpManagePrimary')" @click="openCatMgr"><Settings2 /></button>
</div>
<div class="lp-cats lp-cats-sub">
<span class="lp-cats-label">{{ t('lpSecondaryCat') }}</span>
<button type="button" class="lp-cat" :class="{ on: !catQ }" @click="setCat('')">{{ t('lpCatAll') }}</button>
<button v-for="c in secondaryCats" :key="c" type="button" class="lp-cat" :class="{ on: catQ === c }" @click="setCat(c)">{{ c }}</button>
</div>
</header>
@@ -136,15 +585,29 @@ onUnmounted(() => { clearInterval(timer); offChanged?.() })
<h2 class="lp-title"><Pin />{{ t('lpMyApps') }}<em>{{ myApps.length }}</em></h2>
<div v-if="!myApps.length" class="lp-empty">{{ t('lpEmptyApps') }}</div>
<div v-else class="lp-grid">
<article v-for="x in myApps" :key="'a' + x.id" class="card lp-card" :class="{ running: x.running }">
<article v-for="x in myApps" :key="'a' + x.id" class="card lp-card" :class="{ running: statusOf(x) === 'running', starting: statusOf(x) === 'starting', failed: statusOf(x) === 'failed', dimmed: statusOf(x) === 'stopped' }" @contextmenu.prevent="openCtx($event, x)">
<header>
<span class="lp-icon" :style="{ color: kindUI(x.kind).color, background: `color-mix(in srgb, ${kindUI(x.kind).color} 14%, transparent)` }"><component :is="kindUI(x.kind).icon" /></span>
<div class="lp-name"><b :title="x.exe || x.name">{{ x.name }}</b><small>{{ x.kind }}</small></div>
<i class="lp-dot" :class="{ on: x.running }" :title="x.running ? t('lpRunning') : t('lpStopped')" />
<span class="lp-icon" :style="{ color: kindUI(x.kind).color, background: `color-mix(in srgb, ${kindUI(x.kind).color} 14%, transparent)` }">
<img v-if="displayIcon(x)" :src="displayIcon(x)" alt="" />
<component v-else :is="kindUI(x.kind).icon" />
</span>
<div class="lp-name">
<b :title="x.exe || x.name">{{ x.name }}</b>
<small>
{{ x.kind }}
<template v-if="primaryName(x.categoryId)"> · {{ primaryName(x.categoryId) }}</template>
<template v-if="x.category"> · {{ x.category }}</template>
</small>
</div>
<span class="lp-status" :class="statusOf(x)">
<LoaderCircle v-if="statusOf(x) === 'starting'" class="spin" />
<i v-else class="lp-dot" :class="{ on: statusOf(x) === 'running' }" />
{{ statusText(x) }}
</span>
</header>
<div class="lp-ports">
<span v-for="p in (x.ports?.length ? x.ports : (x.port ? [x.port] : [])).slice(0, 4)" :key="p" class="lp-port">:{{ p }}</span>
<span v-if="(x.ports?.length || 0) > 4" class="lp-port more">+{{ x.ports.length - 4 }}</span>
<button v-for="p in entryPorts(x).slice(0, 4)" :key="p" type="button" class="lp-port lp-port-link" :title="t('lpOpenPort', { p })" @click="openPort(p)">:{{ p }}<ExternalLink /></button>
<span v-if="entryPorts(x).length > 4" class="lp-port more">+{{ entryPorts(x).length - 4 }}</span>
<small v-if="x.pid" class="lp-pid">PID {{ x.pid }}</small>
</div>
<div v-if="x.running" class="lp-res">
@@ -152,10 +615,19 @@ onUnmounted(() => { clearInterval(timer); offChanged?.() })
<span :title="t('lpMem')"><MemoryStick />{{ fmtMem(x.memMB) }}</span>
<span :title="'IO'"><HardDrive />{{ fmtIO(x.ioKBs) }}</span>
</div>
<p v-if="statusOf(x) === 'failed' && x.lastError" class="lp-err-line" :title="x.lastError">{{ x.lastError }}</p>
<p v-else-if="x.lastLog" class="lp-log-preview" :title="x.lastLog">{{ x.lastLog }}</p>
<p v-if="x.dir || x.cmdline" class="lp-meta" :title="x.cmdline || x.dir">{{ x.dir || x.cmdline }}</p>
<footer>
<button v-if="!x.running" class="lp-act go" :disabled="busy[x.id]" @click="start(x)"><Play />{{ t('lpStart') }}</button>
<button v-else class="lp-act halt" :disabled="busy[x.id]" @click="stop(x)"><Square />{{ t('lpStop') }}</button>
<button v-if="statusOf(x) !== 'running' && statusOf(x) !== 'starting'" class="lp-act go" :disabled="!!busy[x.id]" @click="start(x)"><Play />{{ t('lpStart') }}</button>
<template v-else>
<button class="lp-act halt icon-only" :disabled="!!busy[x.id] || statusOf(x) === 'starting'" :title="statusOf(x) === 'starting' ? t('lpStarting') : t('lpStop')" @click="stop(x)">
<LoaderCircle v-if="statusOf(x) === 'starting'" class="spin" /><Square v-else />
</button>
<button class="lp-act icon-only" :disabled="!!busy[x.id] || statusOf(x) === 'starting'" :title="t('lpRestart')" @click="restart(x)"><RotateCcw /></button>
</template>
<button class="lp-act" :title="t('lpLogs')" @click="openLogs(x)"><ScrollText /></button>
<button v-if="x.id" class="lp-act" :title="t('lpFetchIcon')" @click="fetchIcon(x)"><Image /></button>
<span class="lp-gap" />
<button class="lp-act" :title="t('edit')" @click="openForm(x)"><Pencil /></button>
<button class="lp-act danger" :title="t('delete')" @click="removeApp(x)"><Trash2 /></button>
@@ -164,36 +636,64 @@ onUnmounted(() => { clearInterval(timer); offChanged?.() })
</div>
</section>
<section class="lp-section">
<h2 class="lp-title"><Rocket />{{ t('lpScanned') }}<em>{{ scanned.length }}</em></h2>
<div v-if="!scanned.length" class="lp-empty">{{ t('lpEmptyScan') }}</div>
<div v-else class="lp-grid">
<article v-for="x in scanned" :key="'p' + x.pid" class="card lp-card running">
<header>
<span class="lp-icon" :style="{ color: kindUI(x.kind).color, background: `color-mix(in srgb, ${kindUI(x.kind).color} 14%, transparent)` }"><component :is="kindUI(x.kind).icon" /></span>
<div class="lp-name"><b :title="x.exe || x.name">{{ x.name }}</b><small>{{ x.kind }} · PID {{ x.pid }}</small></div>
<i class="lp-dot on" :title="t('lpRunning')" />
</header>
<div class="lp-ports">
<span v-for="p in x.ports.slice(0, 4)" :key="p" class="lp-port">:{{ p }}</span>
<span v-if="x.ports.length > 4" class="lp-port more">+{{ x.ports.length - 4 }}</span>
</div>
<div class="lp-res">
<span :title="'CPU'"><Cpu />{{ fmtCPU(x.cpu) }}</span>
<span :title="t('lpMem')"><MemoryStick />{{ fmtMem(x.memMB) }}</span>
<span :title="'IO'"><HardDrive />{{ fmtIO(x.ioKBs) }}</span>
</div>
<p v-if="x.cmdline || x.exe" class="lp-meta" :title="x.cmdline || x.exe">{{ x.cmdline || x.exe }}</p>
<footer>
<button class="lp-act" @click="openForm(x)"><Pin />{{ t('lpPin') }}</button>
<span class="lp-gap" />
<button class="lp-act halt" :disabled="busy[x.pid]" @click="stop(x)"><Square />{{ t('lpStop') }}</button>
</footer>
</article>
</div>
</section>
<Teleport to="body">
<div v-if="ctx.open && ctx.app" ref="ctxEl" class="ctx-menu" :style="{ left: ctx.x + 'px', top: ctx.y + 'px' }" @click.stop @mouseleave="clearSubSoon">
<button type="button" @mouseenter="ctx.sub = ''" @click="openForm(ctx.app)"><Pencil />{{ t('edit') }}</button>
<button v-if="statusOf(ctx.app) === 'running'" type="button" @mouseenter="ctx.sub = ''" @click="restart(ctx.app)"><RotateCcw />{{ t('lpRestart') }}</button>
<button
type="button"
class="ctx-parent"
:class="{ open: ctx.sub === 'pack' }"
@mouseenter="ctxPackCmds(ctx.app).length ? openSub('pack', $event) : (ctx.sub = '')"
@click="ctxPackCmds(ctx.app).length ? openSub('pack', $event) : openPackConfig(ctx.app)"
>
<Package />{{ t('packRun') }}<ChevronRight v-if="ctxPackCmds(ctx.app).length" class="ctx-chevron" />
</button>
<button type="button" class="ctx-parent" :class="{ open: ctx.sub === 'primary' }" @mouseenter="openSub('primary', $event)" @click="openSub('primary', $event)">
<Tags />{{ t('lpSetPrimary') }}<ChevronRight class="ctx-chevron" />
</button>
<button type="button" class="ctx-parent" :class="{ open: ctx.sub === 'secondary' }" @mouseenter="openSub('secondary', $event)" @click="openSub('secondary', $event)">
<Tags />{{ t('lpSetSecondary') }}<ChevronRight class="ctx-chevron" />
</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="pickCardIcon(ctx.app)"><ImageUp />{{ t('lpPickIcon') }}</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="fetchIcon(ctx.app)"><Image />{{ t('lpFetchIcon') }}</button>
<button type="button" @mouseenter="ctx.sub = ''" @click="clearCardIcon(ctx.app)"><X />{{ t('lpClearIcon') }}</button>
<button type="button" class="danger" @mouseenter="ctx.sub = ''" @click="removeApp(ctx.app)"><Trash2 />{{ t('delete') }}</button>
</div>
<!-- Windows 式级联独立浮层不盖住一级贴边时翻到左侧并夹紧视口 -->
<div
v-if="ctx.open && ctx.app && ctx.sub"
ref="flyEl"
class="ctx-flyout"
:style="flyStyle"
@click.stop
@mouseenter="keepSub"
@mouseleave="clearSubSoon"
>
<template v-if="ctx.sub === 'primary'">
<button type="button" :class="{ on: !ctx.app.categoryId }" @click="patchAppCats(ctx.app, { categoryId: 0 })">{{ t('lpCatNone') }}</button>
<button v-for="c in primaryCats" :key="c.id" type="button" :class="{ on: ctx.app.categoryId === c.id }" @click="patchAppCats(ctx.app, { categoryId: c.id })">{{ c.name }}</button>
</template>
<template v-else-if="ctx.sub === 'secondary'">
<button type="button" :class="{ on: !ctx.app.category }" @click="patchAppCats(ctx.app, { category: '' })">{{ t('lpCatNone') }}</button>
<button v-for="c in CAT_PRESETS" :key="c" type="button" :class="{ on: ctx.app.category === c }" @click="patchAppCats(ctx.app, { category: c })">{{ c }}</button>
</template>
<template v-else-if="ctx.sub === 'pack'">
<button v-for="(c, i) in ctxPackCmds(ctx.app)" :key="i" type="button" :title="c.cmd" @click="runPackCmd(ctx.app, c)">{{ c.name || c.cmd }}</button>
<button type="button" class="on" @click="openPackConfig(ctx.app)"><Package />{{ t('packCmdsConfig') }}</button>
</template>
</div>
<PackCmdsModal
:open="packModal.open"
scope="lp"
:target-id="packModal.id"
:title="packModal.title"
:dir="packModal.dir"
@close="packModal.open = false"
@saved="packTick++"
/>
<div v-if="editing" class="overlay" @click.self="editing = null">
<section class="modal lp-modal">
<header class="lp-modal-head">
@@ -201,17 +701,38 @@ onUnmounted(() => { clearInterval(timer); offChanged?.() })
<button type="button" class="nm-close" :title="t('close')" @click="editing = null"><X /></button>
</header>
<div class="lp-form">
<div class="lp-icon-edit">
<span class="lp-icon lg" :style="{ color: kindUI(editing.kind).color, background: `color-mix(in srgb, ${kindUI(editing.kind).color} 14%, transparent)` }">
<img v-if="editing.icon || kindIcons[editing.kind]" :src="editing.icon || kindIcons[editing.kind]" alt="" />
<component v-else :is="kindUI(editing.kind).icon" />
</span>
<div class="lp-icon-btns">
<button type="button" class="btn secondary" @click="pickLocalIcon"><ImageUp />{{ t('lpPickIcon') }}</button>
<button type="button" class="btn secondary" @click="peekFormIcon"><Image />{{ t('lpFetchIcon') }}</button>
<button v-if="editing.icon" type="button" class="btn secondary" @click="clearFormIcon"><X />{{ t('lpClearIcon') }}</button>
</div>
</div>
<label class="lp-field"><span>{{ t('lpName') }}</span><input v-model="editing.name" :placeholder="t('lpName')" /></label>
<label class="lp-field"><span>{{ t('lpPrimaryCat') }}</span>
<select v-model.number="editing.categoryId">
<option :value="0">{{ t('lpCatNone') }}</option>
<option v-for="c in primaryCats" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</label>
<label class="lp-field"><span>{{ t('lpSecondaryCat') }}</span>
<input v-model.trim="editing.category" list="lp-cat-list" :placeholder="t('lpCategoryPh')" />
<datalist id="lp-cat-list"><option v-for="c in CAT_PRESETS" :key="c" :value="c" /></datalist>
</label>
<div class="lp-row">
<label class="lp-field"><span>{{ t('lpKind') }}</span>
<select v-model="editing.kind" @change="loadSuggest">
<select v-model="editing.kind" @change="onKindChange">
<option v-for="k in KINDS" :key="k" :value="k">{{ k }}</option>
</select>
</label>
<label class="lp-field"><span>{{ t('lpPort') }}</span><input v-model="editing.port" type="number" min="0" max="65535" /></label>
</div>
<label class="lp-field"><span>{{ t('lpDir') }}</span>
<span class="lp-dir-row"><input v-model="editing.dir" :placeholder="t('lpDir')" /><button type="button" class="btn secondary" @click="pickDir"><FolderOpen /></button></span>
<span class="lp-dir-row"><input v-model="editing.dir" :placeholder="t('lpDir')" @change="detectDir(true)" /><button type="button" class="btn secondary" @click="pickDir"><FolderOpen /></button></span>
</label>
<label class="lp-field"><span>{{ t('lpStartCmd') }}</span><input v-model="editing.startCmd" placeholder="npm run dev" /></label>
<div v-if="suggest.start?.length" class="lp-suggest">
@@ -231,6 +752,63 @@ onUnmounted(() => { clearInterval(timer); offChanged?.() })
</footer>
</section>
</div>
<div v-if="catMgr" class="overlay" @click.self="catMgr = false">
<section class="modal lp-modal compact-modal">
<header class="lp-modal-head">
<h2><Tags />{{ t('lpManagePrimary') }}</h2>
<button type="button" class="nm-close" @click="catMgr = false"><X /></button>
</header>
<div class="lp-form">
<label class="lp-field"><span>{{ catForm.id ? t('edit') : t('lpAddPrimary') }}</span>
<span class="lp-dir-row">
<input v-model.trim="catForm.name" :placeholder="t('lpPrimaryPh')" @keyup.enter="savePrimaryCat" />
<button type="button" class="btn" @click="savePrimaryCat">{{ t('save') }}</button>
</span>
</label>
<div v-if="!primaryCats.length" class="lp-empty">{{ t('lpNoPrimary') }}</div>
<div v-for="c in primaryCats" :key="c.id" class="lp-proj-row lp-cat-row">
<b @click="editPrimaryCat(c)">{{ c.name }}</b>
<button type="button" class="lp-act danger" :title="t('delete')" @click="deletePrimaryCat(c)"><Trash2 /></button>
</div>
</div>
</section>
</div>
<div v-if="projectPick" class="overlay" @click.self="projectPick = false">
<section class="modal lp-modal">
<header class="lp-modal-head">
<h2><FolderGit2 />{{ t('lpAddFromProject') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="projectPick = false"><X /></button>
</header>
<div class="lp-form">
<label class="lp-search"><Search /><input v-model.trim="projectQ" type="search" :placeholder="t('lpSearchProject')" /></label>
<div v-if="!filteredProjects.length" class="lp-empty">{{ t('lpNoProjects') }}</div>
<button v-for="p in filteredProjects" :key="p.id" type="button" class="lp-proj-row" @click="pickProject(p)">
<b>{{ p.name }}</b>
<small :title="p.path">{{ p.path }}</small>
</button>
</div>
</section>
</div>
<div v-if="logOpen" class="overlay" @click.self="logOpen = null">
<section class="modal lp-modal lp-log-modal" @click.stop>
<header class="lp-modal-head">
<h2><ScrollText />{{ t('lpLogs') }} · {{ logAppName }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="logOpen = null"><X /></button>
</header>
<div class="lp-log-toolbar">
<small>{{ t('lpLogsLocalHint') }}</small>
<button type="button" class="btn secondary" :disabled="logLoading" @click="openLogs({ id: logOpen })"><RefreshCw :class="{ spin: logLoading }" />{{ t('lpRefresh') }}</button>
<button type="button" class="btn secondary" @click="clearLogs"><Trash2 />{{ t('lpClearLogs') }}</button>
</div>
<div class="lp-log-body">
<div v-if="!logLines.length && !logLoading" class="lp-empty">{{ t('lpLogsEmpty') }}</div>
<pre v-for="(line, i) in logLines" :key="line.id || i" class="lp-log-line" :class="line.level">{{ line.line }}</pre>
</div>
</section>
</div>
</Teleport>
<AIScopeDrawer v-if="aiOpen" kind="launchpad" :title="t('launchpad')" @close="aiOpen = false" />
</div>

View File

@@ -0,0 +1,225 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
Package, RefreshCw, Trash2, LoaderCircle, Check, CircleX, Terminal,
FolderOpen, Copy, Eraser
} from 'lucide-vue-next'
import { call, on, copyText } from '../api'
import { useAppStore } from '../store'
const { t } = useI18n()
const store = useAppStore()
const route = useRoute()
const router = useRouter()
const tasks = ref([])
const selectedId = ref('')
const detail = ref(null)
const loading = ref(false)
const logEl = ref(null)
const stickBottom = ref(true)
let offTask = null
let offLog = null
const selected = computed(() => tasks.value.find(x => x.id === selectedId.value) || null)
const logLines = computed(() => detail.value?.logs || [])
function statusLabel(s) {
if (s === 'running') return t('packTaskRunning')
if (s === 'done') return t('packTaskDone')
return t('packTaskFailed')
}
function fmtTime(s) {
if (!s) return ''
return String(s).replace('T', ' ').slice(5, 19)
}
function lineClass(line) {
const s = String(line || '')
if (s.startsWith('✗') || /error|failed|失败/i.test(s)) return 'err'
if (s.startsWith('✓') || /success|完成|done/i.test(s)) return 'ok'
if (s.startsWith('▶') || s.startsWith('$') || s.startsWith('cwd:') || s.startsWith('pid=')) return 'meta'
return ''
}
async function loadList() {
loading.value = true
try { tasks.value = await call('ListLocalPackTasks') || [] } catch { tasks.value = [] }
loading.value = false
if (!selectedId.value && tasks.value.length) {
selectedId.value = tasks.value[0].id
} else if (selectedId.value && !tasks.value.some(x => x.id === selectedId.value)) {
selectedId.value = tasks.value[0]?.id || ''
}
}
async function loadDetail(id) {
if (!id) { detail.value = null; return }
try {
detail.value = await call('GetLocalPackTask', id)
await scrollLog(true)
} catch {
detail.value = null
}
}
function selectTask(id) {
selectedId.value = id
router.replace({ path: '/pack-tasks', query: id ? { id } : {} })
}
async function clearDone() {
try {
tasks.value = await call('ClearFinishedLocalPackTasks') || []
if (selectedId.value && !tasks.value.some(x => x.id === selectedId.value)) {
selectedId.value = tasks.value[0]?.id || ''
router.replace({ path: '/pack-tasks', query: selectedId.value ? { id: selectedId.value } : {} })
}
} catch { /* ignore */ }
}
async function dismiss(id) {
try {
tasks.value = await call('DismissLocalPackTask', id) || []
if (selectedId.value === id) {
selectedId.value = tasks.value[0]?.id || ''
router.replace({ path: '/pack-tasks', query: selectedId.value ? { id: selectedId.value } : {} })
}
} catch { /* ignore */ }
}
async function copyLogs() {
const text = (detail.value?.logs || []).join('\n')
try {
await copyText(text)
store.showToast({ type: 'success', text: t('packLogCopied') })
} catch {
store.showToast({ type: 'error', text: t('copyFailed') })
}
}
function onLogScroll() {
const el = logEl.value
if (!el) return
stickBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < 48
}
async function scrollLog(force) {
await nextTick()
const el = logEl.value
if (!el) return
if (force || stickBottom.value) el.scrollTop = el.scrollHeight
}
watch(selectedId, id => { loadDetail(id) })
watch(() => route.query.id, id => {
const v = String(id || '')
if (v && v !== selectedId.value) selectedId.value = v
})
onMounted(async () => {
if (route.query.id) selectedId.value = String(route.query.id)
await loadList()
if (selectedId.value) await loadDetail(selectedId.value)
offTask = on('pack:task', async ev => {
await loadList()
const d = Array.isArray(ev) ? ev[0] : ev
if (d?.id && d.id === selectedId.value && detail.value?.id === d.id) {
detail.value = {
...detail.value,
status: d.status || detail.value.status,
pid: d.pid || detail.value.pid,
error: d.error ?? detail.value.error,
endedAt: d.endedAt || detail.value.endedAt
}
}
})
offLog = on('pack:log', ev => {
const d = Array.isArray(ev) ? ev[0] : ev
if (!d?.taskId || d.taskId !== selectedId.value) return
if (!detail.value || detail.value.id !== d.taskId) return
detail.value = { ...detail.value, logs: [...(detail.value.logs || []), d.line] }
scrollLog(false)
})
})
onUnmounted(() => {
offTask?.()
offLog?.()
})
</script>
<template>
<div class="page pack-tasks-page">
<header class="page-head sticky-head lp-head">
<div class="lp-head-top">
<div>
<h1>{{ t('packTasksPage') }}</h1>
<p>{{ t('packTasksPageSub') }}</p>
</div>
<div class="lp-tools">
<button class="btn secondary" :disabled="loading" @click="loadList"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
<button class="btn secondary" :disabled="!tasks.some(x => x.status !== 'running')" @click="clearDone"><Trash2 />{{ t('packTasksClear') }}</button>
</div>
</div>
</header>
<div class="pack-tasks-layout">
<aside class="pack-tasks-side">
<div v-if="!tasks.length" class="lp-empty">{{ t('packTasksEmpty') }}</div>
<button
v-for="x in tasks"
:key="x.id"
type="button"
class="pack-task-row"
:class="[x.status, { on: x.id === selectedId }]"
@click="selectTask(x.id)"
>
<span class="pack-task-st">
<LoaderCircle v-if="x.status === 'running'" class="spin" />
<Check v-else-if="x.status === 'done'" />
<CircleX v-else />
</span>
<div class="pack-task-main">
<b>{{ x.title || x.cmd }}</b>
<small>{{ statusLabel(x.status) }} · {{ fmtTime(x.startedAt) }}</small>
<code v-if="x.cmd !== x.title">{{ x.cmd }}</code>
</div>
<button v-if="x.status !== 'running'" type="button" class="pack-task-rm" :title="t('delete')" @click.stop="dismiss(x.id)"><Trash2 /></button>
</button>
</aside>
<section class="pack-tasks-console card">
<template v-if="selected || detail">
<header class="pack-console-head">
<div>
<h2><Terminal />{{ detail?.title || selected?.title || t('packTasks') }}</h2>
<p>
<em :class="detail?.status || selected?.status">{{ statusLabel(detail?.status || selected?.status) }}</em>
<span v-if="detail?.pid || selected?.pid">PID {{ detail?.pid || selected?.pid }}</span>
<span v-if="detail?.startedAt">{{ fmtTime(detail.startedAt) }}</span>
</p>
</div>
<div class="pack-console-acts">
<button type="button" class="btn secondary" :disabled="!logLines.length" @click="copyLogs"><Copy />{{ t('packLogCopy') }}</button>
</div>
</header>
<div v-if="detail?.dir || detail?.cmd" class="pack-console-meta">
<span v-if="detail.dir" :title="detail.dir"><FolderOpen />{{ detail.dir }}</span>
<code v-if="detail.cmd">$ {{ detail.cmd }}</code>
</div>
<div ref="logEl" class="pack-console-body" @scroll="onLogScroll">
<pre v-for="(line, i) in logLines" :key="i" class="pack-console-line" :class="lineClass(line)">{{ line }}</pre>
<div v-if="!logLines.length" class="pack-console-empty">{{ t('packLogEmpty') }}</div>
</div>
<p v-if="detail?.error" class="pack-console-err">{{ detail.error }}</p>
</template>
<div v-else class="pack-console-placeholder">
<Eraser />
<p>{{ t('packTasksPick') }}</p>
</div>
</section>
</div>
</div>
</template>

View File

@@ -0,0 +1,278 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Browser } from '@wailsio/runtime'
import {
Activity, RefreshCw, Pin, Square, Search, ExternalLink, Cpu, MemoryStick,
HardDrive, Network, Gauge, ChevronDown, ChevronUp, Hexagon, Zap, FileCode2,
Coffee, Database, Globe, Boxes, Box
} from 'lucide-vue-next'
import ChartView from '../components/ChartView.vue'
import { call, on } from '../api'
const { t } = useI18n()
const router = useRouter()
const entries = ref([])
const loading = ref(false)
const showSys = ref(localStorage.getItem('cc-pm-sys') === '1')
const nameQ = ref('')
const portQ = ref('')
const busy = ref({})
const metrics = ref(null)
const history = ref([]) // { t, cpu, mem, gpu, recv, sent }
const showCharts = ref(localStorage.getItem('cc-pm-charts') !== '0')
const kindIcons = ref({})
const HISTORY_MAX = 60
let timer = 0
let metricsTimer = 0
let offChanged = null
const SYS_NAMES = ['system', 'svchost.exe', 'lsass.exe', 'wininit.exe', 'services.exe', 'csrss.exe', 'winlogon.exe', 'spoolsv.exe', 'searchindexer.exe', 'memcompression', 'registry']
const isSys = x => !x.id && (SYS_NAMES.includes((x.name || '').toLowerCase()) || /^PID \d+$/.test(x.name || ''))
const KIND_UI = {
node: { icon: Hexagon, color: '#8cc84b' },
go: { icon: Zap, color: '#00add8' },
python: { icon: FileCode2, color: '#ffd343' },
java: { icon: Coffee, color: '#f89820' },
php: { icon: FileCode2, color: '#a78bfa' },
dotnet: { icon: Boxes, color: '#8b5cf6' },
exe: { icon: Box, color: '#60a5fa' },
mysql: { icon: Database, color: '#4f9df5' },
redis: { icon: Database, color: '#f16a5b' },
nginx: { icon: Globe, color: '#26c795' },
web: { icon: Globe, color: '#4f9df5' },
other: { icon: Box, color: 'var(--muted)' }
}
const kindUI = k => KIND_UI[k] || KIND_UI.other
function displayIcon(x) {
if (x?.icon) return x.icon
return kindIcons.value[x?.kind] || ''
}
function matchEntry(x) {
const nq = nameQ.value.trim().toLowerCase()
if (nq) {
const hay = [x.name, x.exe, x.cmdline, x.dir].filter(Boolean).join('\n').toLowerCase()
if (!hay.includes(nq)) return false
}
const pq = portQ.value.trim()
if (pq) {
const ports = x.ports?.length ? x.ports : (x.port ? [x.port] : [])
if (!ports.some(p => String(p).includes(pq))) return false
}
return true
}
const scanned = computed(() => entries.value.filter(x => !x.id && (showSys.value || !isSys(x)) && matchEntry(x)))
const hiddenCount = computed(() => entries.value.filter(x => !x.id && isSys(x)).length)
const fmtCPU = v => (v >= 10 ? Number(v).toFixed(0) : Number(v).toFixed(1)) + '%'
const fmtMem = v => v >= 1024 ? (v / 1024).toFixed(1) + ' GB' : Number(v).toFixed(0) + ' MB'
const fmtIO = v => v >= 1024 ? (v / 1024).toFixed(1) + ' MB/s' : Number(v).toFixed(0) + ' KB/s'
const fmtNet = v => {
const n = Number(v) || 0
if (n >= 1024) return (n / 1024).toFixed(2) + ' MB/s'
return n.toFixed(1) + ' KB/s'
}
async function load() {
loading.value = true
try { entries.value = await call('ListLaunchEntries') } catch { /* ignore */ }
loading.value = false
}
async function loadMetrics() {
try {
const m = await call('GetHostMetrics')
metrics.value = m
const point = {
t: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
cpu: Number(m.cpu) || 0,
mem: Number(m.memPercent) || 0,
gpu: m.gpu < 0 ? null : Number(m.gpu),
recv: Number(m.netRecvKBs) || 0,
sent: Number(m.netSentKBs) || 0
}
history.value = [...history.value, point].slice(-HISTORY_MAX)
} catch { /* ignore */ }
}
function toggleSys() {
showSys.value = !showSys.value
localStorage.setItem('cc-pm-sys', showSys.value ? '1' : '0')
}
function toggleCharts() {
showCharts.value = !showCharts.value
localStorage.setItem('cc-pm-charts', showCharts.value ? '1' : '0')
}
function openPort(p) {
const n = Number(p)
if (!n || n <= 0) return
try { Browser.OpenURL(`http://127.0.0.1:${n}`) } catch { /* ignore */ }
}
async function pin(x) {
router.push({ path: '/launchpad', query: { pin: '1', name: x.name || '', kind: x.kind || 'other', port: String(x.port || x.ports?.[0] || 0), dir: x.dir || '', pid: String(x.pid || 0) } })
}
async function stop(x) {
if (!confirm(t('lpStopConfirm', { name: x.name }))) return
const key = x.pid
busy.value[key] = 'stop'
try { await call('StopLaunchApp', 0, x.pid || 0) } catch { /* ignore */ }
busy.value[key] = false
setTimeout(load, 500)
}
function lineOpt(series) {
return {
backgroundColor: 'transparent',
grid: { left: 36, right: 12, top: 24, bottom: 28 },
tooltip: { trigger: 'axis' },
legend: { show: series.length > 1, top: 0, textStyle: { color: 'var(--muted)', fontSize: 11 } },
xAxis: {
type: 'category',
data: history.value.map(h => h.t),
axisLabel: { color: 'var(--muted)', fontSize: 10 },
axisLine: { lineStyle: { color: 'var(--border)' } }
},
yAxis: {
type: 'value',
min: 0,
max: series.some(s => s.unit === '%') ? 100 : undefined,
axisLabel: { color: 'var(--muted)', fontSize: 10 },
splitLine: { lineStyle: { color: 'var(--border)', opacity: 0.45 } }
},
series: series.map(s => ({
name: s.name,
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { opacity: 0.12 },
lineStyle: { width: 2, color: s.color },
itemStyle: { color: s.color },
data: history.value.map(h => h[s.key] == null ? null : h[s.key])
}))
}
}
const cpuChart = computed(() => lineOpt([{ name: 'CPU', key: 'cpu', color: '#5da8ff', unit: '%' }]))
const memChart = computed(() => lineOpt([{ name: t('lpMem'), key: 'mem', color: '#53d6a2', unit: '%' }]))
const gpuChart = computed(() => lineOpt([{ name: 'GPU', key: 'gpu', color: '#f7cb4d', unit: '%' }]))
const netChart = computed(() => lineOpt([
{ name: t('pmNetDown'), key: 'recv', color: '#4fd1a1' },
{ name: t('pmNetUp'), key: 'sent', color: '#a78bfa' }
]))
const gpuAvailable = computed(() => metrics.value && metrics.value.gpu >= 0)
onMounted(async () => {
try { kindIcons.value = await call('ListKindIcons') || {} } catch { kindIcons.value = {} }
load()
loadMetrics()
timer = setInterval(load, 5000)
metricsTimer = setInterval(loadMetrics, 2000)
offChanged = on('launchpad:changed', load)
})
onUnmounted(() => {
clearInterval(timer)
clearInterval(metricsTimer)
offChanged?.()
})
</script>
<template>
<div class="page port-monitor-page">
<header class="page-head sticky-head lp-head">
<div class="lp-head-top">
<div>
<h1>{{ t('portMonitor') }}</h1>
<p>{{ t('portMonitorSubtitle') }}</p>
</div>
<div class="lp-tools">
<button class="btn secondary" @click="toggleCharts">
<component :is="showCharts ? ChevronUp : ChevronDown" />
{{ showCharts ? t('pmHideCharts') : t('pmShowCharts') }}
</button>
<button class="btn secondary" :disabled="loading" @click="load"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
</div>
</div>
</header>
<section class="pm-overview card">
<h2 class="lp-title"><Activity />{{ t('pmOverview') }}</h2>
<div class="pm-stats">
<div class="pm-stat">
<span class="pm-stat-label"><Cpu />CPU</span>
<b>{{ metrics ? fmtCPU(metrics.cpu) : '—' }}</b>
</div>
<div class="pm-stat">
<span class="pm-stat-label"><MemoryStick />{{ t('lpMem') }}</span>
<b>{{ metrics ? fmtCPU(metrics.memPercent) : '—' }}</b>
<small v-if="metrics">{{ fmtMem(metrics.memUsedMB) }} / {{ fmtMem(metrics.memTotalMB) }}</small>
</div>
<div class="pm-stat">
<span class="pm-stat-label"><Gauge />GPU</span>
<template v-if="gpuAvailable">
<b>{{ fmtCPU(metrics.gpu) }}</b>
<small>{{ metrics.gpuName }} · {{ fmtMem(metrics.gpuMemUsedMB) }} / {{ fmtMem(metrics.gpuMemTotalMB) }}</small>
</template>
<b v-else class="muted">{{ t('pmGpuNA') }}</b>
</div>
<div class="pm-stat">
<span class="pm-stat-label"><Network />{{ t('pmBandwidth') }}</span>
<b v-if="metrics"> {{ fmtNet(metrics.netRecvKBs) }} · {{ fmtNet(metrics.netSentKBs) }}</b>
<b v-else></b>
</div>
</div>
<div v-if="showCharts" class="pm-charts">
<div class="pm-chart-card"><header>CPU</header><ChartView :option="cpuChart" class="pm-chart" /></div>
<div class="pm-chart-card"><header>{{ t('lpMem') }}</header><ChartView :option="memChart" class="pm-chart" /></div>
<div class="pm-chart-card"><header>GPU</header><ChartView :option="gpuChart" class="pm-chart" /></div>
<div class="pm-chart-card"><header>{{ t('pmBandwidth') }}</header><ChartView :option="netChart" class="pm-chart" /></div>
</div>
</section>
<section class="lp-section">
<div class="pm-ports-head">
<h2 class="lp-title"><HardDrive />{{ t('lpScanned') }}<em>{{ scanned.length }}</em></h2>
<div class="lp-filters">
<label class="lp-sys-toggle"><input type="checkbox" :checked="showSys" @change="toggleSys" />{{ t('lpShowSys') }}<em v-if="hiddenCount && !showSys">{{ hiddenCount }}</em></label>
<label class="lp-search"><Search /><input v-model.trim="nameQ" type="search" :placeholder="t('lpSearchName')" /></label>
<label class="lp-search lp-search-port"><Search /><input v-model.trim="portQ" type="search" inputmode="numeric" :placeholder="t('lpSearchPort')" /></label>
</div>
</div>
<div v-if="!scanned.length" class="lp-empty">{{ t('lpEmptyScan') }}</div>
<div v-else class="lp-grid">
<article v-for="x in scanned" :key="'p' + x.pid" class="card lp-card running">
<header>
<span class="lp-icon" :style="{ color: kindUI(x.kind).color, background: `color-mix(in srgb, ${kindUI(x.kind).color} 14%, transparent)` }">
<img v-if="displayIcon(x)" :src="displayIcon(x)" alt="" />
<component v-else :is="kindUI(x.kind).icon" />
</span>
<div class="lp-name"><b :title="x.exe || x.name">{{ x.name }}</b><small>{{ x.kind }} · PID {{ x.pid }}</small></div>
<i class="lp-dot on" :title="t('lpRunning')" />
</header>
<div class="lp-ports">
<button v-for="p in x.ports.slice(0, 4)" :key="p" type="button" class="lp-port lp-port-link" :title="t('lpOpenPort', { p })" @click="openPort(p)">:{{ p }}<ExternalLink /></button>
<span v-if="x.ports.length > 4" class="lp-port more">+{{ x.ports.length - 4 }}</span>
</div>
<div class="lp-res">
<span :title="'CPU'"><Cpu />{{ fmtCPU(x.cpu) }}</span>
<span :title="t('lpMem')"><MemoryStick />{{ fmtMem(x.memMB) }}</span>
<span :title="'IO'"><HardDrive />{{ fmtIO(x.ioKBs) }}</span>
</div>
<p v-if="x.dir || x.cmdline || x.exe" class="lp-meta" :title="x.dir || x.cmdline || x.exe">{{ x.dir || x.cmdline || x.exe }}</p>
<footer>
<button class="lp-act" @click="pin(x)"><Pin />{{ t('lpPin') }}</button>
<span class="lp-gap" />
<button class="lp-act halt" :disabled="busy[x.pid]" @click="stop(x)"><Square />{{ t('lpStop') }}</button>
</footer>
</article>
</div>
</section>
</div>
</template>

View File

@@ -6,6 +6,8 @@ import { UserRound, ImageUp, KeyRound, CloudUpload, LogIn, LogOut, RefreshCw, Wi
import { call, isNative } from '../api'
import { useAppStore } from '../store'
import { teams, teamsErr, teamsLoading, loadTeams as loadTeamsShared, switchTeam as switchTeamShared } from '../team'
import RemoteImg from '../components/RemoteImg.vue'
import ImagePreview from '../components/ImagePreview.vue'
const store = useAppStore(), { t } = useI18n(), native = isNative()
const router = useRouter()
@@ -14,19 +16,24 @@ const pwdForm = reactive({ old: '', next: '', confirm: '' })
const busy = ref(''), msg = ref('')
const loaded = ref(false)
const avatarOpen = ref(false)
const AVATAR_HIST_KEY = 'cc-avatar-history'
const avatarHistory = ref([]) // [{mode,value,preview?}]
const serverAvatars = ref([])
const previewOpen = ref(false)
const previewSrc = ref('')
const TABS = ['info', 'teams', 'assets', 'security', 'sync']
const tab = ref(TABS.includes(localStorage.getItem('cc-profile-tab')) ? localStorage.getItem('cc-profile-tab') : 'info')
const doneTodos = ref(0)
const resolvedTickets = ref(0)
const sync = computed(() => store.syncStatus)
// ---- 全局文件存储(管理员在设置页配置;此处只读,决定素材库可用性与提示文案) ----
const fsCfg = reactive({ mode: 'local', baseUrl: '', apiKey: '' })
const fsCfg = reactive({ mode: 'local', baseUrl: '' })
const serverStorage = computed(() => fsCfg.mode === 'server')
async function loadFileStorage() {
try {
const c = await call('GetFileStorageConfig')
Object.assign(fsCfg, { mode: c.mode || 'local', baseUrl: c.baseUrl || '', apiKey: c.apiKey || '' })
Object.assign(fsCfg, { mode: c.mode || 'local', baseUrl: c.baseUrl || '' })
} catch {}
}
@@ -76,6 +83,10 @@ async function copyAsset(f) {
store.showToast({ type: 'success', key: 'assetsCopiedToast' })
} catch { store.showToast({ type: 'error', key: 'assetsCopyFailed' }) }
}
function openAssetPreview(f) {
previewSrc.value = f.url
previewOpen.value = true
}
function fmtSize(n) {
if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + ' MB'
if (n >= 1024) return Math.round(n / 1024) + ' KB'
@@ -203,19 +214,115 @@ async function persist() {
await store.saveSettings({ avatarMode: form.avatarMode, avatarValue: form.avatarValue, imageMode: form.imageMode })
}
watch(() => [form.avatarMode, form.avatarValue, form.imageMode], persist)
function loadLocalAvatarHistory() {
try {
const raw = JSON.parse(localStorage.getItem(AVATAR_HIST_KEY) || '[]')
return Array.isArray(raw) ? raw.filter(x => x && x.value).slice(0, 12) : []
} catch { return [] }
}
function saveLocalAvatarHistory(list) {
avatarHistory.value = list.slice(0, 12)
localStorage.setItem(AVATAR_HIST_KEY, JSON.stringify(avatarHistory.value.map(({ mode, value }) => ({ mode, value }))))
}
async function loadAvatarHistory() {
if (sync.value.loggedIn) {
try {
const items = await call('ListAvatarHistory')
avatarHistory.value = (items || []).map(x => ({ id: x.id, mode: x.mode, value: x.value, createdAt: x.createdAt }))
// 首次:把本地残留历史迁移到线上
const local = loadLocalAvatarHistory()
if (local.length && !avatarHistory.value.length) {
for (const it of [...local].reverse()) {
try { await call('PushAvatarHistory', it.mode || 'base64', it.value) } catch { /* ignore */ }
}
localStorage.removeItem(AVATAR_HIST_KEY)
const again = await call('ListAvatarHistory')
avatarHistory.value = (again || []).map(x => ({ id: x.id, mode: x.mode, value: x.value, createdAt: x.createdAt }))
}
return
} catch { /* 回退本地 */ }
}
avatarHistory.value = loadLocalAvatarHistory()
}
async function pushAvatarHistory(mode, value) {
if (!value) return
if (sync.value.loggedIn) {
try {
const items = await call('PushAvatarHistory', mode || 'base64', value)
avatarHistory.value = (items || []).map(x => ({ id: x.id, mode: x.mode, value: x.value, createdAt: x.createdAt }))
await refreshHistPreviews()
return
} catch { /* 回退本地 */ }
}
const next = [{ mode: mode || 'base64', value }, ...avatarHistory.value.filter(x => x.value !== value)]
saveLocalAvatarHistory(next)
await refreshHistPreviews()
}
async function resolveHistPreview(item) {
if (item._src) return item._src
if (item.mode === 'url' || /^https?:\/\//i.test(item.value)) {
try {
const { resolveImageSrc } = await import('../api')
item._src = await resolveImageSrc(item.value)
} catch { item._src = item.value }
} else if (item.mode === 'path') {
try { item._src = await call('ReadImageAsDataURL', item.value) } catch { item._src = '' }
} else {
item._src = item.value
}
return item._src
}
async function loadServerAvatars() {
serverAvatars.value = []
if (!serverStorage.value || !sync.value.loggedIn) return
try {
const r = await call('ListServerFiles', 'mine', 0, 1)
serverAvatars.value = (r.items || []).filter(f => f.kind === 'avatar')
} catch {}
}
const histPreviews = ref({})
async function refreshHistPreviews() {
const map = {}
for (const it of avatarHistory.value) {
map[it.value] = await resolveHistPreview(it)
}
histPreviews.value = map
}
// 存储走向由后端按管理员全局配置实时决定autoserver 时上传返回 url否则 base64。
async function pickAvatar() {
try {
const r = await call('PickAvatarImage', 'auto')
if (r && r.value) {
if (form.avatarValue) await pushAvatarHistory(form.avatarMode, form.avatarValue)
form.avatarMode = r.mode || 'base64'
form.avatarValue = r.value
await pushAvatarHistory(form.avatarMode, form.avatarValue)
}
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
function clearAvatar() { form.avatarMode = ''; form.avatarValue = '' }
async function clearAvatar() {
if (form.avatarValue) await pushAvatarHistory(form.avatarMode, form.avatarValue)
form.avatarMode = ''
form.avatarValue = ''
}
function selectHistory(item) {
form.avatarMode = item.mode || 'base64'
form.avatarValue = item.value
}
async function selectServerAvatar(f) {
form.avatarMode = 'url'
form.avatarValue = f.url
await pushAvatarHistory('url', f.url)
}
// 打开弹窗时刷新全局配置,保证提示文案与实际走向一致
watch(avatarOpen, v => { if (v) loadFileStorage() })
watch(avatarOpen, async v => {
if (!v) return
await loadFileStorage()
await loadAvatarHistory()
await refreshHistPreviews()
await loadServerAvatars()
})
async function syncNow() {
if (busy.value) return
busy.value = 'sync'; msg.value = ''
@@ -386,7 +493,7 @@ onUnmounted(() => removeEventListener('keydown', onKey))
<section v-else-if="tab === 'assets'" class="panel profile-card">
<header class="pc-head">
<span class="pc-badge img"><Images /></span>
<div><b>{{ t('assetsTab') }}</b><small>{{ t('assetsHint') }}</small></div>
<div><b>{{ t('assetsTab') }}</b><small>{{ sync.userId === 1 ? t('assetsHintAdmin') : t('assetsHint') }}</small></div>
</header>
<div v-if="!sync.loggedIn" class="pc-empty">
<span class="pc-empty-ico"><Images /></span>
@@ -413,7 +520,7 @@ onUnmounted(() => removeEventListener('keydown', onKey))
<p v-if="assetsErr" class="db-message">{{ assetsErr }}</p>
<div v-if="assets.length" class="assets-grid">
<figure v-for="f in assets" :key="f.id" class="asset-card">
<a :href="f.url" target="_blank" rel="noreferrer"><img :src="f.url" loading="lazy" alt="" /></a>
<button type="button" class="asset-thumb" @click="openAssetPreview(f)"><RemoteImg :src="f.url" /></button>
<figcaption>
<b :title="f.original || f.name">{{ f.kind === 'avatar' ? t('assetsKindAvatar') : t('assetsKindContent') }} · {{ fmtSize(f.size) }}</b>
<small v-if="assetsScope !== 'mine'">{{ f.username || ('#' + f.userId) }}</small>
@@ -506,10 +613,27 @@ onUnmounted(() => removeEventListener('keydown', onKey))
</div>
</div>
</div>
<!-- 存储方式由管理员全局配置强制决定用户不再自行选择 -->
<p class="auto-update-hint">{{ t('storageFollowHint', { mode: serverStorage ? t('fsModeServer') : t('fsModeLocal') }) }}</p>
<div v-if="avatarHistory.length" class="avatar-hist">
<b>{{ t('avatarHistory') }}</b>
<div class="avatar-hist-grid">
<button v-for="it in avatarHistory" :key="it.value" type="button" class="avatar-hist-item" :class="{ on: form.avatarValue === it.value }" :title="t('avatarReselect')" @click="selectHistory(it)">
<img v-if="histPreviews[it.value]" :src="histPreviews[it.value]" alt="" />
<UserRound v-else />
</button>
</div>
</div>
<div v-if="serverAvatars.length" class="avatar-hist">
<b>{{ t('avatarServerHistory') }}</b>
<div class="avatar-hist-grid">
<button v-for="f in serverAvatars" :key="f.id" type="button" class="avatar-hist-item" :class="{ on: form.avatarValue === f.url }" :title="t('avatarReselect')" @click="selectServerAvatar(f)">
<RemoteImg :src="f.url" />
</button>
</div>
</div>
</div>
</section>
</div>
</Teleport>
<ImagePreview :open="previewOpen" :src="previewSrc" @close="previewOpen = false" />
</div></template>

View File

@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck, Flame, Sparkles, MessageCircleQuestion } from 'lucide-vue-next'
import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck, Flame, Sparkles, MessageCircleQuestion, Rocket } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import ChartView from '../components/ChartView.vue'
import GitHeatmap from '../components/GitHeatmap.vue'
@@ -131,6 +131,7 @@ onMounted(load)
<div class="detail-head-row">
<button class="back" @click="router.push('/projects')"><ArrowLeft />{{ t('back') }}</button>
<div><h1>{{ p.name }}</h1><p>{{ p.path }}</p></div>
<button class="btn secondary" @click="router.push({ path: '/launchpad', query: { projectId: p.id } })"><Rocket />{{ t('lpToLaunchpad') }}</button>
<button class="btn secondary ai-entry" @click="openChat()"><Sparkles />{{ t('aiAskGo') }}</button>
</div>
<div class="tabs detail-tabs">

View File

@@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Database, Plus, Trash2, Upload, BarChart3, Folder, Languages, CheckCircle2, Info, Copy, Timer, Rocket, Sparkles, CloudUpload, Wifi } from 'lucide-vue-next'
import { Database, Plus, Trash2, Upload, BarChart3, Folder, Languages, CheckCircle2, Info, Copy, Timer, Rocket, Sparkles, CloudUpload, Wifi, Image, ImageUp, X } from 'lucide-vue-next'
import { call, isNative } from '../api'
import { useAppStore } from '../store'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
@@ -10,8 +10,8 @@ import AIScopeDrawer from '../components/AIScopeDrawer.vue'
const route=useRoute()
// tab 记忆:优先 URL 深链,其次上次停留的 tabsync 已迁往个人主页,做合法性回退)
// 分区切换入口在侧边栏“设置”一级导航的二级菜单,页内不再有 tabbar。
const TABS=['rules','appearance','ai','filestorage','database']
const TAB_TITLE={rules:'tabRules',appearance:'tabAppearance',ai:'aiAnalysis',filestorage:'tabFileStorage',database:'tabDatabase'}
const TABS=['rules','appearance','ai','filestorage','kindicons','database']
const TAB_TITLE={rules:'tabRules',appearance:'tabAppearance',ai:'aiAnalysis',filestorage:'tabFileStorage',kindicons:'tabKindIcons',database:'tabDatabase'}
const pickTab=v=>TABS.includes(String(v))?String(v):''
const tab=ref(pickTab(route.query.tab)||pickTab(localStorage.getItem('cc-settings-tab'))||'rules'),rules=ref([]),form=reactive({pattern:'',category:'custom'})
const aiOpen=ref(false)
@@ -47,12 +47,12 @@ async function toggleAutostart(){
}
// ---- 全局文件存储(仅管理员 id=1权威配置存远端 MySQL保存即全员生效 ----
const isAdmin=computed(()=>store.syncStatus.userId===1)
const fsCfg=reactive({mode:'local',baseUrl:'',apiKey:''})
const fsCfg=reactive({mode:'local',baseUrl:''})
const fsBusy=ref(''),fsLoaded=ref(false)
async function loadFileStorage(){
try{
const c=await call('GetFileStorageConfig')
Object.assign(fsCfg,{mode:c.mode||'local',baseUrl:c.baseUrl||'',apiKey:c.apiKey||''})
Object.assign(fsCfg,{mode:c.mode||'local',baseUrl:c.baseUrl||''})
}catch{}
fsLoaded.value=true
}
@@ -60,9 +60,28 @@ async function saveFileStorage(){
if(fsBusy.value)return
fsBusy.value='save'
try{
const has=await call('AdminHasStepUp')
if(!has){
const code=window.prompt(t('adminStepupHint'),'')
if(!code){fsBusy.value='';return}
await call('AdminStepUp',String(code).trim())
}
await call('SaveFileStorageConfig',{...fsCfg})
store.showToast({type:'success',key:'fsSavedToast'})
}catch(e){store.showToast({type:'error',text:errText(e)})}
}catch(e){
const code=String(e).split(':')[0].trim()
if(code==='ADMIN_STEPUP_REQUIRED'||code==='ADMIN_IP_CHANGED'){
const tip=code==='ADMIN_IP_CHANGED'?t('adminIpChangedRisk'):t('adminStepupHint')
const c=window.prompt(tip,'')
if(c){
try{
await call('AdminStepUp',String(c).trim())
await call('SaveFileStorageConfig',{...fsCfg})
store.showToast({type:'success',key:'fsSavedToast'})
}catch(e2){store.showToast({type:'error',text:errText(e2)})}
}
}else store.showToast({type:'error',text:errText(e)})
}
finally{fsBusy.value=''}
}
async function testFileStorage(){
@@ -75,7 +94,42 @@ async function testFileStorage(){
finally{fsBusy.value=''}
}
const errText=e=>{const code=String(e).split(':')[0].trim();return t('errors.'+code)!=='errors.'+code?t('errors.'+code):String(e)}
watch(tab,v=>{if(v==='filestorage')loadFileStorage()})
async function checkSoftwareUpdate(){
try{
const r=await call('CheckAppUpdate',true)
if(r?.upToDate) store.showToast({type:'success',text:t('aboutUpToDate',{version:r.current||r.latest||'—'})})
else store.showToast({type:'info',text:t('aboutUpdateAvailable',{latest:r.latest,current:r.current})})
}catch(e){store.showToast({type:'error',text:errText(e)})}
}
watch(tab,v=>{if(v==='filestorage')loadFileStorage();if(v==='kindicons')loadKindIcons()})
const knownKinds=ref([])
const kindIcons=ref({})
const kindBusy=ref('')
async function loadKindIcons(){
try{
knownKinds.value=await call('ListKnownLaunchKinds')||[]
kindIcons.value=await call('ListKindIcons')||{}
}catch{knownKinds.value=[];kindIcons.value={}}
}
async function pickKindIcon(kind){
if(kindBusy.value)return
kindBusy.value=kind
try{
const u=await call('PickKindIcon',kind)
if(u)kindIcons.value={...kindIcons.value,[kind]:u}
}catch(e){store.showToast({type:'error',text:errText(e)})}
finally{kindBusy.value=''}
}
async function clearKindIcon(kind){
if(kindBusy.value)return
kindBusy.value=kind
try{
await call('ClearKindIcon',kind)
const next={...kindIcons.value};delete next[kind];kindIcons.value=next
}catch(e){store.showToast({type:'error',text:errText(e)})}
finally{kindBusy.value=''}
}
async function add(){if(!form.pattern)return;await call('AddRule',form.pattern,form.category);form.pattern='';await load()}
async function remove(r){if(!r.builtin){await call('DeleteRule',r.id);await load()}}
@@ -99,7 +153,7 @@ watch(()=>[settings.theme,settings.locale,settings.glassOpacity,settings.loading
let aiKeyTimer=null
watch(()=>[settings.sparkKey,settings.deepSeekKey],()=>{clearTimeout(aiKeyTimer);aiKeyTimer=setTimeout(save,600)})
watch(()=>route.query.tab,v=>{const n=pickTab(v);if(n)tab.value=n})
onMounted(async()=>{await load();apply();if(tab.value==='filestorage')loadFileStorage()})
onMounted(async()=>{await load();apply();if(tab.value==='filestorage')loadFileStorage();if(tab.value==='kindicons')loadKindIcons()})
onUnmounted(()=>{clearTimeout(aiKeyTimer)})
</script>
@@ -121,6 +175,7 @@ onUnmounted(()=>{clearTimeout(aiKeyTimer)})
<label v-if="settings.autoUpdateMode!=='everyNHours'">{{t('triggerTime')}}<input v-model="settings.autoUpdateTime" type="time" class="interval-input"/></label>
<p class="auto-update-hint">{{t('autoUpdateHint')}}</p>
</template>
<button type="button" class="btn secondary" style="margin-top:.5rem" @click="checkSoftwareUpdate">{{t('checkAppUpdate')}}</button>
</section></template>
<template v-else-if="tab==='ai'">
<section class="panel form-panel"><h2><Sparkles/>{{t('aiProviderTitle')}}</h2>
@@ -138,8 +193,7 @@ onUnmounted(()=>{clearTimeout(aiKeyTimer)})
<template v-if="isAdmin">
<label>{{t('fsMode')}}<select v-model="fsCfg.mode"><option value="local">{{t('fsModeLocal')}}</option><option value="server">{{t('fsModeServer')}}</option></select></label>
<template v-if="fsCfg.mode==='server'">
<label>{{t('fsBaseUrl')}}<input v-model.trim="fsCfg.baseUrl" placeholder="http://192.168.1.10:8788"/></label>
<label>{{t('fsApiKey')}}<input v-model.trim="fsCfg.apiKey" type="password" :placeholder="t('fsApiKeyPh')"/></label>
<label>{{t('fsBaseUrl')}}<input v-model.trim="fsCfg.baseUrl" placeholder="https://o-api.nailaoyun.cn/pms-api"/></label>
</template>
<p class="auto-update-hint">{{t('fsPageHint')}}</p>
<div class="fs-page-actions">
@@ -150,6 +204,28 @@ onUnmounted(()=>{clearTimeout(aiKeyTimer)})
<div v-else class="preview-notice"><Info/><div><b>{{t('fsAdminOnlyTitle')}}</b><small>{{t('fsAdminOnlyDesc')}}</small></div></div>
</section>
</template>
<template v-else-if="tab==='kindicons'">
<section class="panel form-panel">
<h2><Image/>{{t('kindIconsTitle')}}</h2>
<p class="auto-update-hint">{{t('kindIconsHint')}}</p>
<template v-if="isAdmin">
<div class="kind-icons-grid">
<div v-for="k in knownKinds" :key="k" class="kind-icon-card">
<span class="kind-icon-preview">
<img v-if="kindIcons[k]" :src="kindIcons[k]" alt="" />
<Image v-else />
</span>
<b>{{k}}</b>
<div class="kind-icon-ops">
<button type="button" class="btn secondary" :disabled="!!kindBusy" @click="pickKindIcon(k)"><ImageUp/>{{t('avatarPick')}}</button>
<button v-if="kindIcons[k]" type="button" class="btn secondary" :disabled="!!kindBusy" @click="clearKindIcon(k)"><X/>{{t('lpClearIcon')}}</button>
</div>
</div>
</div>
</template>
<div v-else class="preview-notice"><Info/><div><b>{{t('fsAdminOnlyTitle')}}</b><small>{{t('kindIconsAdminOnly')}}</small></div></div>
</section>
</template>
<template v-else><section class="panel database-panel"><div class="db-title"><h2><Database/>{{t('dbLocation')}}</h2><span class="db-connected"><CheckCircle2/>{{t('dbConnected')}}</span></div><div v-if="!native" class="preview-notice"><Info/><div><b>{{t('previewModeTitle')}}</b><small>{{t('previewModeDb')}}</small></div></div><div class="db-path"><span>{{t('dbCurrentLoc')}}</span><code :title="settings.databasePath">{{settings.databasePath||t('dbNoPath')}}</code><button :title="t('copyPathTitle')" :disabled="!settings.databasePath" @click="copyPath"><Copy/></button></div><p class="db-relocate-hint">{{t('dbRelocateHint')}}</p><p v-if="dbMessage" class="db-message">{{dbMessage}}</p><button class="btn secondary migrate" :disabled="!native" @click="migrate"><Upload/>{{t('migrateBtn')}}</button></section>
<section class="panel danger-zone"><h2><Trash2/>{{t('dangerZone')}}</h2><p>{{t('dangerDesc')}}</p><div><button @click="clear('stats')"><BarChart3/><span><b>{{t('clearStats')}}</b><small>{{t('clearStatsDesc')}}</small></span></button><button @click="clear('project')"><Folder/><span><b>{{t('clearProject')}}</b><small>{{t('clearProjectDesc')}}</small></span></button><button class="danger" @click="clear('all')"><Trash2/><span><b>{{t('clearAllData')}}</b><small>{{t('clearAllDesc')}}</small></span></button></div></section></template>
<AIScopeDrawer v-if="aiOpen" kind="config" :title="t('settings')" @close="aiOpen=false"/>

View File

@@ -6,6 +6,7 @@ import { call, isNative } from '../api'
import { useAppStore } from '../store'
import { teams, teamsErr, teamsLoading, currentTeam, isTeamAdmin, loadTeams, switchTeam, teamErrCode } from '../team'
import DatePicker from '../components/DatePicker.vue'
import RemoteImg from '../components/RemoteImg.vue'
const store = useAppStore(), { t } = useI18n(), native = isNative()
const members = ref([])
@@ -186,7 +187,7 @@ onMounted(refreshAll)
<section class="team-members">
<div v-for="m in members" :key="m.userId" class="panel team-member">
<span class="tm-avatar">
<img v-if="m.avatar" :src="m.avatar" alt="" />
<RemoteImg v-if="m.avatar" :src="m.avatar" alt="" />
<b v-else>{{ (m.nickname || m.username)[0].toUpperCase() }}</b>
</span>
<div class="tm-main">

View File

@@ -2,7 +2,7 @@
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 } from 'lucide-vue-next'
import { Star, Folder, Code2, GitCommitHorizontal, ListTodo, TicketCheck, StickyNote, ArrowRight, Circle, CircleDot, CircleCheck, Play, Check, CalendarDays, Plus, Bell, Save } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import AIDayPanel from '../components/AIDayPanel.vue'
import { call } from '../api'
@@ -13,9 +13,12 @@ const store = useAppStore()
const { t } = useI18n()
const todos = ref([])
const tickets = ref([])
const notes = ref([])
const note = ref(null)
const noteText = ref('')
const noteSavedAt = ref('')
const noteDirty = ref(false)
const noteSaving = ref(false)
const messages = ref([])
let noteTimer
@@ -42,22 +45,67 @@ const weekTickets = computed(() => tickets.value.filter(x =>
))
const overdue = v => v && dueDate(v) < new Date()
const noteTitle = n => {
const line = (n?.content || '').split('\n').find(l => l.trim()) || ''
return line.trim().replace(/^#+\s*/, '') || t('noteUntitled')
}
const noteTime = s => String(s || '').replace('T', ' ').slice(5, 16)
async function loadNotes() {
try { notes.value = await call('ListNotes', 50) || [] } catch { notes.value = [] }
}
async function load() {
;[todos.value, tickets.value, messages.value] = await Promise.all([
call('ListTodos', 'all', 0),
call('ListTickets', 'all', 0),
call('ListMessages', 8)
])
note.value = await call('GetNote')
noteText.value = note.value.content
await loadNotes()
if (notes.value.length) {
note.value = notes.value[0]
noteText.value = note.value.content || ''
} else {
note.value = await call('GetNote')
noteText.value = note.value?.content || ''
await loadNotes()
}
noteDirty.value = false
}
function editNote() {
noteDirty.value = true
clearTimeout(noteTimer)
noteTimer = setTimeout(async () => {
// 带 id 保存,避免笔记中心新建笔记后误写到"最近一条"
noteTimer = setTimeout(() => { saveNote(false) }, 800)
}
async function saveNote(manual = true) {
if (noteSaving.value) return
noteSaving.value = true
clearTimeout(noteTimer)
try {
note.value = await call('SaveNoteByID', note.value?.id || 0, noteText.value)
noteSavedAt.value = new Date().toTimeString().slice(0, 8)
}, 600)
noteDirty.value = false
await loadNotes()
if (manual) store.showToast({ type: 'success', text: t('noteSaved') })
} catch (e) {
if (manual) store.showToast({ type: 'error', text: String(e?.message || e) })
}
noteSaving.value = false
}
async function selectNote(n) {
if (note.value?.id === n.id) return
if (noteDirty.value) await saveNote(false)
note.value = n
noteText.value = n.content || ''
noteDirty.value = false
noteSavedAt.value = ''
}
async function newNote() {
if (noteDirty.value) await saveNote(false)
note.value = await call('SaveNoteByID', 0, '')
noteText.value = ''
noteDirty.value = false
noteSavedAt.value = ''
await loadNotes()
}
async function completeTodo(x) {
await call('SetTodoStatus', x.id, 'done')
@@ -149,10 +197,32 @@ onUnmounted(() => clearTimeout(noteTimer))
<section class="panel wb-col wb-note-panel">
<div class="section-head">
<h2><StickyNote class="panel-icon" />{{ t('notepad') }}</h2>
<small v-if="noteSavedAt" class="wb-note-saved">{{ t('autoSaved') }} {{ noteSavedAt }}</small>
<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>
<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 secondary" @click="router.push('/notes')">{{ t('viewAll') }}<ArrowRight /></button>
</div>
</div>
<div class="wb-note-body">
<aside class="wb-note-list">
<button
v-for="n in notes"
:key="n.id"
type="button"
class="wb-note-item"
:class="{ on: n.id === note?.id }"
@click="selectNote(n)"
>
<b>{{ noteTitle(n) }}</b>
<time>{{ noteTime(n.updatedAt) }}</time>
</button>
<div v-if="!notes.length" class="wb-note-list-empty">{{ t('noteEmpty') }}</div>
</aside>
<textarea v-model="noteText" class="wb-note" :placeholder="t('notepadPlaceholder')" @input="editNote" />
</div>
<textarea v-model="noteText" class="wb-note" :placeholder="t('notepadPlaceholder')" @input="editNote" />
</section>
<section class="panel wb-col">