更新若干功能
This commit is contained in:
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