更新若干功能
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal, Home, ListTodo, TicketCheck, CalendarDays, CalendarCheck2, Sparkles, UserRound, ClipboardList, Wrench, Rocket, ChevronDown, Bell, StickyNote, ListFilter, Palette, Bot, Users, NotebookPen, CloudUpload, Power } 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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-available,App 层弹出安装对话框
|
||||
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>
|
||||
|
||||
83
frontend/src/components/ImagePreview.vue
Normal file
83
frontend/src/components/ImagePreview.vue
Normal 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>
|
||||
99
frontend/src/components/LocalPackCenter.vue
Normal file
99
frontend/src/components/LocalPackCenter.vue
Normal 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>
|
||||
@@ -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') }
|
||||
|
||||
131
frontend/src/components/PackCmdsModal.vue
Normal file
131
frontend/src/components/PackCmdsModal.vue
Normal 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>
|
||||
22
frontend/src/components/RemoteImg.vue
Normal file
22
frontend/src/components/RemoteImg.vue
Normal 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>
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
35
frontend/src/packCmds.js
Normal 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)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 拉取成 dataURL(WebView 直接拉外站 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() {
|
||||
|
||||
399
frontend/src/views/Admin.vue
Normal file
399
frontend/src/views/Admin.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
225
frontend/src/views/PackTasks.vue
Normal file
225
frontend/src/views/PackTasks.vue
Normal 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>
|
||||
278
frontend/src/views/PortMonitor.vue
Normal file
278
frontend/src/views/PortMonitor.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
// 存储走向由后端按管理员全局配置实时决定(auto):server 时上传返回 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 深链,其次上次停留的 tab(sync 已迁往个人主页,做合法性回退)
|
||||
// 分区切换入口在侧边栏“设置”一级导航的二级菜单,页内不再有 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"/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user