1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。
2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
<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 { Shield, Users, Building2, Upload, BarChart3, RefreshCw, CheckCircle2, Ban, KeyRound, FolderOpen, UserRound, X, ListTodo, TicketCheck, StickyNote } from 'lucide-vue-next'
|
||||
import { call, on } from '../api'
|
||||
import { useAppStore } from '../store'
|
||||
import ChartView from '../components/ChartView.vue'
|
||||
import RemoteImg from '../components/RemoteImg.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useAppStore()
|
||||
@@ -21,6 +23,8 @@ const users = ref([])
|
||||
const teams = ref([])
|
||||
const releases = ref([])
|
||||
const releaseForm = reactive({ version: '', channel: 'stable', changelog: '', filePath: '' })
|
||||
const userDetail = ref(null)
|
||||
const userDetailLoading = ref(false)
|
||||
|
||||
const errText = e => {
|
||||
const code = String(e).split(':')[0].trim()
|
||||
@@ -108,6 +112,90 @@ 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 openUserDetail(id) {
|
||||
if (!id) return
|
||||
userDetailLoading.value = true
|
||||
userDetail.value = { id }
|
||||
try {
|
||||
userDetail.value = await call('AdminGetUser', id)
|
||||
} catch (e) {
|
||||
userDetail.value = null
|
||||
err.value = errText(e)
|
||||
} finally {
|
||||
userDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 概览图表(ECharts:折线 DAU、柱状+折线 Token、饼图分布) ----
|
||||
const PIE_COLORS = ['#7b73ff', '#4fd1a1', '#4da5ff', '#f4c84a', '#ef6683', '#23b5d3']
|
||||
const axisX = data => ({ type: 'category', data, axisLabel: { color: 'var(--muted)', fontSize: 10 }, axisLine: { lineStyle: { color: 'var(--border)' } } })
|
||||
const axisY = (extra = {}) => ({ type: 'value', axisLabel: { color: 'var(--muted)', fontSize: 10 }, splitLine: { lineStyle: { color: 'var(--border)', opacity: 0.45 } }, ...extra })
|
||||
|
||||
const dauChart = computed(() => {
|
||||
const s = overview.value?.dauSeries || []
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 40, right: 16, top: 24, bottom: 26 },
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: axisX(s.map(p => p.date.slice(5))),
|
||||
yAxis: axisY({ minInterval: 1 }),
|
||||
series: [{
|
||||
name: t('adminStatDAU'), type: 'line', smooth: true, showSymbol: false,
|
||||
areaStyle: { opacity: 0.16 }, lineStyle: { width: 2, color: '#5da8ff' }, itemStyle: { color: '#5da8ff' },
|
||||
data: s.map(p => p.count || 0)
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const tokenChart = computed(() => {
|
||||
const s = overview.value?.tokenSeries || []
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 48, right: 40, top: 28, bottom: 26 },
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { top: 0, textStyle: { color: 'var(--muted)', fontSize: 11 } },
|
||||
xAxis: axisX(s.map(p => p.date.slice(5))),
|
||||
yAxis: [axisY(), axisY({ minInterval: 1, splitLine: { show: false } })],
|
||||
series: [
|
||||
{ name: 'Prompt', type: 'bar', stack: 'tok', barMaxWidth: 16, itemStyle: { color: '#7b73ff', borderRadius: [0, 0, 0, 0] }, data: s.map(p => p.promptTokens || 0) },
|
||||
{ name: 'Completion', type: 'bar', stack: 'tok', barMaxWidth: 16, itemStyle: { color: '#4fd1a1', borderRadius: [3, 3, 0, 0] }, data: s.map(p => p.completionTokens || 0) },
|
||||
{ name: t('adminCalls'), type: 'line', yAxisIndex: 1, smooth: true, showSymbol: false, lineStyle: { width: 2, color: '#f4c84a' }, itemStyle: { color: '#f4c84a' }, data: s.map(p => p.calls || 0) }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const pieBase = data => ({
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, textStyle: { color: 'var(--muted)', fontSize: 11 } },
|
||||
series: [{
|
||||
type: 'pie', radius: ['46%', '70%'], center: ['50%', '44%'],
|
||||
label: { show: false }, avoidLabelOverlap: true,
|
||||
data: data.map((x, i) => ({ ...x, itemStyle: { color: PIE_COLORS[i % PIE_COLORS.length] } }))
|
||||
}]
|
||||
})
|
||||
|
||||
const providerPie = computed(() => pieBase((overview.value?.providerSeries || []).map(p => ({
|
||||
name: p.provider || 'unknown',
|
||||
value: (p.promptTokens || 0) + (p.completionTokens || 0)
|
||||
}))))
|
||||
const hasProviderData = computed(() => (overview.value?.providerSeries || []).some(p => (p.promptTokens || 0) + (p.completionTokens || 0) > 0))
|
||||
|
||||
const dataPie = computed(() => {
|
||||
const d = overview.value?.dataDist || {}
|
||||
return pieBase([
|
||||
{ name: t('todos'), value: d.todos || 0 },
|
||||
{ name: t('tickets'), value: d.tickets || 0 },
|
||||
{ name: t('notesPage'), value: d.notes || 0 },
|
||||
{ name: t('teamTasks'), value: d.teamTasks || 0 }
|
||||
])
|
||||
})
|
||||
const hasDataDist = computed(() => {
|
||||
const d = overview.value?.dataDist || {}
|
||||
return (d.todos || 0) + (d.tickets || 0) + (d.notes || 0) + (d.teamTasks || 0) > 0
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
busy.value = 'load'
|
||||
err.value = ''
|
||||
@@ -220,22 +308,24 @@ onUnmounted(() => { offFilesDropped?.() })
|
||||
<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 class="chart-grid">
|
||||
<div class="chart-card">
|
||||
<h3>{{ t('adminDAUSeries') }}</h3>
|
||||
<ChartView class="admin-chart" :option="dauChart" />
|
||||
</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 class="chart-card">
|
||||
<h3>{{ t('adminTokenSeries') }}</h3>
|
||||
<ChartView class="admin-chart" :option="tokenChart" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>{{ t('adminProviderPie') }}</h3>
|
||||
<ChartView v-if="hasProviderData" class="admin-chart" :option="providerPie" />
|
||||
<div v-else class="chart-empty">{{ t('empty') }}</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>{{ t('adminDataPie') }}</h3>
|
||||
<ChartView v-if="hasDataDist" class="admin-chart" :option="dataPie" />
|
||||
<div v-else class="chart-empty">{{ t('empty') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -246,7 +336,12 @@ onUnmounted(() => { offFilesDropped?.() })
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id">
|
||||
<td>{{ u.id }}</td>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>
|
||||
<button class="user-cell" :title="t('adminViewDetail')" @click="openUserDetail(u.id)">
|
||||
<span class="ua"><RemoteImg v-if="u.avatar" :src="u.avatar" /><UserRound v-else /></span>
|
||||
<b>{{ u.username }}</b>
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ u.nickname||'—' }}</td>
|
||||
<td class="mono">{{ u.lastLoginIp||'—' }}</td>
|
||||
<td>
|
||||
@@ -271,7 +366,15 @@ onUnmounted(() => { offFilesDropped?.() })
|
||||
<tr v-for="tm in teams" :key="tm.id">
|
||||
<td>{{ tm.id }}</td>
|
||||
<td>{{ tm.name }}</td>
|
||||
<td>{{ tm.ownerName||tm.ownerId }}</td>
|
||||
<td>
|
||||
<button class="user-cell" :title="t('adminViewDetail')" @click="openUserDetail(tm.ownerId)">
|
||||
<span class="ua"><RemoteImg v-if="tm.ownerAvatar" :src="tm.ownerAvatar" /><UserRound v-else /></span>
|
||||
<span class="uc-main">
|
||||
<b>{{ tm.ownerNickname||tm.ownerName||('#'+tm.ownerId) }}</b>
|
||||
<small>ID {{ tm.ownerId }}<template v-if="tm.ownerName"> · {{ tm.ownerName }}</template></small>
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ tm.members }}</td>
|
||||
<td>
|
||||
<button class="btn sm" :class="{danger:tm.aiBanned}" @click="patchTeam(tm, tm.aiBanned?0:1)">
|
||||
@@ -353,6 +456,42 @@ onUnmounted(() => { offFilesDropped?.() })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userDetail" class="modal-mask" @click.self="userDetail=null">
|
||||
<div class="modal ud-modal">
|
||||
<div class="ud-head">
|
||||
<h3>{{ t('adminUserDetail') }}</h3>
|
||||
<button type="button" class="ud-close" :title="t('close')" @click="userDetail=null"><X :size="16"/></button>
|
||||
</div>
|
||||
<p v-if="userDetailLoading" class="muted"><RefreshCw class="spin" :size="14"/> {{ t('loading') }}</p>
|
||||
<template v-else>
|
||||
<div class="ud-hero">
|
||||
<span class="ua lg"><RemoteImg v-if="userDetail.avatar" :src="userDetail.avatar" /><UserRound v-else /></span>
|
||||
<div class="ud-id">
|
||||
<b>{{ userDetail.nickname||userDetail.username }}</b>
|
||||
<small>ID {{ userDetail.id }} · {{ userDetail.username }}<template v-if="userDetail.title"> · {{ userDetail.title }}</template></small>
|
||||
<div class="ud-badges">
|
||||
<em v-if="userDetail.disabled" class="ud-badge danger">{{ t('adminColDisabled') }}</em>
|
||||
<em v-if="userDetail.aiBanned" class="ud-badge warn">{{ t('adminBanAI') }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ud-counts">
|
||||
<div class="ud-count"><ListTodo :size="16"/><b>{{ userDetail.todoCount||0 }}</b><span>{{ t('todos') }}</span></div>
|
||||
<div class="ud-count"><TicketCheck :size="16"/><b>{{ userDetail.ticketCount||0 }}</b><span>{{ t('tickets') }}</span></div>
|
||||
<div class="ud-count"><StickyNote :size="16"/><b>{{ userDetail.noteCount||0 }}</b><span>{{ t('notesPage') }}</span></div>
|
||||
<div class="ud-count"><Building2 :size="16"/><b>{{ userDetail.teamCount||0 }}</b><span>{{ t('adminCntTeams') }}</span></div>
|
||||
</div>
|
||||
<div class="ud-meta">
|
||||
<div><span>{{ t('adminJoinedAt') }}</span><b>{{ (userDetail.createdAt||'').slice(0,10)||'—' }}</b></div>
|
||||
<div><span>{{ t('adminLastSeen') }}</span><b>{{ (userDetail.lastSeenAt||'').slice(0,16).replace('T',' ')||'—' }}</b></div>
|
||||
<div><span>IP</span><b class="mono">{{ userDetail.lastLoginIp||'—' }}</b></div>
|
||||
<div><span>{{ t('adminOwnedTeams') }}</span><b>{{ userDetail.ownedTeams||0 }}</b></div>
|
||||
</div>
|
||||
<p v-if="userDetail.bio" class="ud-bio">{{ userDetail.bio }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -365,11 +504,46 @@ onUnmounted(() => { offFilesDropped?.() })
|
||||
.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}
|
||||
.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}
|
||||
.chart-card{border:1px solid var(--border);border-radius:12px;padding:.85rem 1rem .5rem}
|
||||
.chart-card h3{margin:0 0 .35rem;font-size:.9rem;opacity:.85}
|
||||
.admin-chart{height:230px;width:100%}
|
||||
.chart-empty{height:230px;display:flex;align-items:center;justify-content:center;opacity:.5;font-size:.85rem}
|
||||
.user-cell{display:inline-flex;align-items:center;gap:.5rem;background:transparent;border:0;color:inherit;cursor:pointer;padding:.15rem .3rem;border-radius:8px;text-align:left}
|
||||
.user-cell:hover{background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent)}
|
||||
.user-cell b{font-weight:600}
|
||||
.uc-main{display:flex;flex-direction:column;line-height:1.25}
|
||||
.uc-main small{opacity:.6;font-size:.72rem}
|
||||
.ua{width:26px;height:26px;border-radius:50%;overflow:hidden;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:var(--accent,#3b82f6);flex:none}
|
||||
.ua img{width:100%;height:100%;object-fit:cover}
|
||||
.ua svg{width:15px;height:15px}
|
||||
.ua.lg{width:52px;height:52px}
|
||||
.ua.lg svg{width:26px;height:26px}
|
||||
.ud-modal{width:min(460px,92vw)}
|
||||
.ud-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.75rem}
|
||||
.ud-head h3{margin:0}
|
||||
.ud-close{background:transparent;border:0;color:inherit;cursor:pointer;opacity:.7;padding:.25rem;border-radius:6px}
|
||||
.ud-close:hover{opacity:1;background:color-mix(in srgb,currentColor 10%,transparent)}
|
||||
.ud-hero{display:flex;align-items:center;gap:.85rem;margin-bottom:1rem}
|
||||
.ud-id{display:flex;flex-direction:column;gap:.15rem;min-width:0}
|
||||
.ud-id b{font-size:1.05rem}
|
||||
.ud-id small{opacity:.65}
|
||||
.ud-badges{display:flex;gap:.35rem;margin-top:.15rem}
|
||||
.ud-badge{font-size:.7rem;font-style:normal;padding:.1rem .4rem;border-radius:999px;border:1px solid currentColor}
|
||||
.ud-badge.danger{color:#ef4444}
|
||||
.ud-badge.warn{color:#f59e0b}
|
||||
.ud-counts{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin-bottom:1rem}
|
||||
.ud-count{border:1px solid var(--border);border-radius:10px;padding:.6rem .4rem;display:flex;flex-direction:column;align-items:center;gap:.2rem}
|
||||
.ud-count svg{opacity:.7}
|
||||
.ud-count b{font-size:1.15rem}
|
||||
.ud-count span{font-size:.72rem;opacity:.65}
|
||||
.ud-meta{display:grid;grid-template-columns:1fr 1fr;gap:.5rem .85rem}
|
||||
.ud-meta div{display:flex;flex-direction:column;gap:.1rem}
|
||||
.ud-meta span{font-size:.72rem;opacity:.6}
|
||||
.ud-meta b{font-size:.85rem;font-weight:500}
|
||||
.ud-bio{margin:.85rem 0 0;font-size:.85rem;opacity:.8;white-space:pre-wrap}
|
||||
.spin{animation:ud-spin 1s linear infinite}
|
||||
@keyframes ud-spin{to{transform:rotate(360deg)}}
|
||||
.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}
|
||||
|
||||
@@ -347,42 +347,15 @@ function consumePendingAction() {
|
||||
}
|
||||
watch(() => store.pendingAction, consumePendingAction)
|
||||
|
||||
// ---- 云端项目待绑定:同账号在其它机器添加的项目,本机需要选目录落地 ----
|
||||
// ---- 云端项目待认领:同账号在其它机器添加的项目,横幅提示 → 全局认领弹窗选目录/忽略 ----
|
||||
const cloudPending = ref([])
|
||||
const bindTarget = ref(null) // { name, description, group } 待绑定项
|
||||
const bindDir = ref('')
|
||||
const bindBusy = ref(false)
|
||||
const bindErr = ref('')
|
||||
let offSync = null
|
||||
|
||||
async function loadPending() {
|
||||
try { cloudPending.value = (await call('ListCloudPendingProjects')) || [] } catch { cloudPending.value = [] }
|
||||
}
|
||||
function openBind(it) {
|
||||
bindTarget.value = it
|
||||
bindDir.value = ''
|
||||
bindErr.value = ''
|
||||
}
|
||||
async function browseBind() {
|
||||
const p = await call('SelectDirectory')
|
||||
if (p) bindDir.value = p
|
||||
}
|
||||
async function bindNow() {
|
||||
if (bindBusy.value || !bindTarget.value) return
|
||||
bindErr.value = ''
|
||||
if (!bindDir.value.trim()) { bindErr.value = t('pathRequired'); return }
|
||||
bindBusy.value = true
|
||||
try {
|
||||
await call('BindCloudProject', bindTarget.value.name, bindDir.value.trim())
|
||||
bindTarget.value = null
|
||||
await Promise.all([store.refresh(), loadPending()])
|
||||
store.showToast({ type: 'success', key: 'cloudBindDone' })
|
||||
} catch (e) {
|
||||
bindErr.value = errorText(e)
|
||||
} finally {
|
||||
bindBusy.value = false
|
||||
}
|
||||
}
|
||||
// 认领弹窗关闭后刷新横幅计数(认领/忽略都会减少待认领数)
|
||||
watch(() => store.cloudClaimOpen, v => { if (!v) loadPending() })
|
||||
onMounted(() => {
|
||||
consumePendingAction()
|
||||
loadPending()
|
||||
@@ -413,13 +386,7 @@ watch(() => store.projects.length, () => loadKindFallback())
|
||||
<small>{{ t('cloudPendingDesc') }}</small>
|
||||
</header>
|
||||
<div class="cloud-pending-list">
|
||||
<div v-for="it in cloudPending" :key="it.name" class="cloud-pending-item">
|
||||
<div class="cp-main">
|
||||
<b>{{ it.name }}</b>
|
||||
<small>{{ [it.group, it.description].filter(Boolean).join(' · ') }}</small>
|
||||
</div>
|
||||
<button class="btn secondary" @click="openBind(it)"><FolderOpen />{{ t('cloudBindBtn') }}</button>
|
||||
</div>
|
||||
<button class="btn primary" @click="store.cloudClaimOpen = true"><FolderOpen />{{ t('cloudClaimGo') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 展开:三张统计大卡;折叠:一行小卡片(语言分布区一并收起) -->
|
||||
@@ -606,14 +573,5 @@ watch(() => store.projects.length, () => loadKindFallback())
|
||||
<footer><button type="button" class="btn secondary" :disabled="groupSaving" @click="groupModal = false">{{ t('cancel') }}</button><button class="btn primary" :disabled="groupSaving"><RefreshCw v-if="groupSaving" class="spin" />{{ groupSaving ? t('saving') : t('saveProjectGroup') }}</button></footer>
|
||||
</form>
|
||||
</div>
|
||||
<div v-if="bindTarget" class="overlay" @click.self="!bindBusy && (bindTarget = null)">
|
||||
<form class="modal compact-modal" @submit.prevent="bindNow">
|
||||
<header><h2>{{ t('cloudBindTitle') }}</h2><button type="button" :disabled="bindBusy" @click="bindTarget = null">×</button></header>
|
||||
<label>{{ t('projectName') }}<input :value="bindTarget.name" disabled /></label>
|
||||
<label>{{ t('projectPath') }}<div class="browse"><input v-model="bindDir" :disabled="bindBusy" required /><button type="button" class="btn secondary" :disabled="bindBusy" @click="browseBind"><FolderOpen />{{ t('browse') }}</button></div></label>
|
||||
<p v-if="bindErr" class="form-error">{{ bindErr }}</p>
|
||||
<footer><button type="button" class="btn secondary" :disabled="bindBusy" @click="bindTarget = null">{{ t('cancel') }}</button><button class="btn primary" :disabled="bindBusy"><RefreshCw v-if="bindBusy" class="spin" />{{ bindBusy ? t('saving') : t('cloudBindBtn') }}</button></footer>
|
||||
</form>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Browser } from '@wailsio/runtime'
|
||||
import { 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 { Plus, RefreshCw, Play, Square, Pencil, Trash2, X, Hexagon, Zap, FileCode2, Coffee, Database, Globe, Boxes, Box, Cpu, MemoryStick, HardDrive, Sparkles, Search, ExternalLink, FolderGit2, ScrollText, LoaderCircle, Image, ImageUp, Tags, Settings2, RotateCcw, ChevronRight, Package, Pin } from 'lucide-vue-next'
|
||||
import { call, on } from '../api'
|
||||
import { useAppStore } from '../store'
|
||||
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
|
||||
import PackCmdsModal from '../components/PackCmdsModal.vue'
|
||||
import LaunchAppFormModal from '../components/LaunchAppFormModal.vue'
|
||||
import RefreshIntervalPicker from '../components/RefreshIntervalPicker.vue'
|
||||
import { getPackCmds } from '../packCmds'
|
||||
|
||||
// 启动台:扫描本机监听端口的服务 + 管理保存的应用(启动/停止/资源占用)。
|
||||
@@ -24,7 +26,6 @@ const primaryQ = ref(Number(localStorage.getItem('cc-lp-primary') || 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({})
|
||||
const projectPick = ref(false)
|
||||
const projects = ref([])
|
||||
@@ -42,6 +43,8 @@ const flyEl = ref(null)
|
||||
const flyStyle = ref({ left: '0px', top: '0px' })
|
||||
const packModal = ref({ open: false, id: 0, title: '', dir: '' })
|
||||
const packTick = ref(0) // 配置保存后刷新右键子菜单
|
||||
// 自动刷新间隔(秒);0=仅手动刷新。本地持久化,默认 30 秒。
|
||||
const refreshSec = ref(localStorage.getItem('cc-lp-refresh') == null ? 30 : Number(localStorage.getItem('cc-lp-refresh')) || 0)
|
||||
let subLeaveTimer = 0
|
||||
let timer = 0
|
||||
let offChanged = null
|
||||
@@ -131,7 +134,9 @@ async function load() {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// applyProfile / openForm 只组装初始值,表单交互由 LaunchAppFormModal 组件完成。
|
||||
function applyProfile(p, base = {}) {
|
||||
editErr.value = ''
|
||||
editing.value = {
|
||||
id: base.id || 0,
|
||||
name: p.name || base.name || '',
|
||||
@@ -142,93 +147,29 @@ function applyProfile(p, base = {}) {
|
||||
stopCmd: p.stopCmd || '',
|
||||
icon: p.icon || '',
|
||||
categoryId: base.categoryId || p.categoryId || primaryQ.value || 0,
|
||||
category: base.category || p.category || ''
|
||||
category: base.category || p.category || '',
|
||||
_suggest: { start: p.start || [], stop: p.stop || [] }
|
||||
}
|
||||
suggest.value = { start: p.start || [], stop: p.stop || [] }
|
||||
peekFormIcon()
|
||||
}
|
||||
|
||||
async function openForm(x) {
|
||||
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 || '', 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
|
||||
await detectDir(true)
|
||||
}
|
||||
} catch { /* 用户取消 */ }
|
||||
}
|
||||
async function onKindChange() {
|
||||
await loadSuggest()
|
||||
}
|
||||
async function saveForm() {
|
||||
async function onFormSaved() {
|
||||
editing.value = null
|
||||
editErr.value = ''
|
||||
try {
|
||||
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)
|
||||
}
|
||||
await load()
|
||||
}
|
||||
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 = ''
|
||||
} catch { /* 未找到 */ }
|
||||
}
|
||||
async function pickCardIcon(x) {
|
||||
ctx.value.open = false
|
||||
@@ -511,11 +452,22 @@ const logAppName = computed(() => entries.value.find(e => e.id === logOpen.value
|
||||
|
||||
function onDocClick() { if (ctx.value.open) closeCtx() }
|
||||
|
||||
// 自动刷新定时器:间隔可配置,0=仅手动。
|
||||
function restartTimer() {
|
||||
clearInterval(timer)
|
||||
timer = 0
|
||||
if (refreshSec.value > 0) timer = setInterval(load, refreshSec.value * 1000)
|
||||
}
|
||||
watch(refreshSec, v => {
|
||||
localStorage.setItem('cc-lp-refresh', String(v))
|
||||
restartTimer()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
loadCats()
|
||||
loadKindIcons()
|
||||
load()
|
||||
timer = setInterval(load, 5000)
|
||||
restartTimer()
|
||||
offChanged = on('launchpad:changed', load)
|
||||
offLog = on('launchpad:log', ev => {
|
||||
const d = Array.isArray(ev) ? ev[0] : ev
|
||||
@@ -559,6 +511,7 @@ watch(() => route.query.pin, async v => {
|
||||
<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>
|
||||
<RefreshIntervalPicker v-model="refreshSec" />
|
||||
<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>
|
||||
@@ -694,65 +647,6 @@ watch(() => route.query.pin, async v => {
|
||||
@saved="packTick++"
|
||||
/>
|
||||
|
||||
<div v-if="editing" class="overlay" @click.self="editing = null">
|
||||
<section class="modal lp-modal">
|
||||
<header class="lp-modal-head">
|
||||
<h2><Rocket />{{ editing.id ? t('lpEditApp') : t('lpAddApp') }}</h2>
|
||||
<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="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')" @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">
|
||||
<small>{{ t('lpSuggest') }}</small>
|
||||
<button v-for="c in suggest.start" :key="c" type="button" @click="editing.startCmd = c">{{ c }}</button>
|
||||
</div>
|
||||
<label class="lp-field"><span>{{ t('lpStopCmd') }}</span><input v-model="editing.stopCmd" :placeholder="t('lpStopBlank')" /></label>
|
||||
<div v-if="suggest.stop?.length" class="lp-suggest">
|
||||
<small>{{ t('lpSuggest') }}</small>
|
||||
<button v-for="c in suggest.stop" :key="c" type="button" @click="editing.stopCmd = c">{{ c }}</button>
|
||||
</div>
|
||||
<p v-if="editErr" class="lp-err">{{ editErr }}</p>
|
||||
</div>
|
||||
<footer class="lp-modal-foot">
|
||||
<button class="btn secondary" @click="editing = null">{{ t('cancel') }}</button>
|
||||
<button class="btn" @click="saveForm">{{ t('save') }}</button>
|
||||
</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">
|
||||
@@ -810,6 +704,7 @@ watch(() => route.query.pin, async v => {
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
<LaunchAppFormModal :initial="editing" :initial-error="editErr" @close="editing = null; editErr = ''" @saved="onFormSaved" />
|
||||
<AIScopeDrawer v-if="aiOpen" kind="launchpad" :title="t('launchpad')" @close="aiOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } 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
|
||||
Coffee, Database, Globe, Boxes, Box, Rocket, X
|
||||
} from 'lucide-vue-next'
|
||||
import ChartView from '../components/ChartView.vue'
|
||||
import LaunchAppFormModal from '../components/LaunchAppFormModal.vue'
|
||||
import RefreshIntervalPicker from '../components/RefreshIntervalPicker.vue'
|
||||
import { call, on } from '../api'
|
||||
import { useAppStore } from '../store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
|
||||
const entries = ref([])
|
||||
const loading = ref(false)
|
||||
@@ -24,6 +28,10 @@ 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 pinDraft = ref(null) // 「存为应用」弹窗(本页保存,不再跳转启动台)
|
||||
const savedAsk = ref(false) // 保存成功后询问是否前往启动台
|
||||
// 自动刷新间隔(秒);0=仅手动刷新。默认 30 秒。
|
||||
const refreshSec = ref(localStorage.getItem('cc-pm-refresh') == null ? 30 : Number(localStorage.getItem('cc-pm-refresh')) || 0)
|
||||
const HISTORY_MAX = 60
|
||||
let timer = 0
|
||||
let metricsTimer = 0
|
||||
@@ -114,8 +122,30 @@ function openPort(p) {
|
||||
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) } })
|
||||
// 存为应用:本页弹窗填写保存,保存成功后再询问是否前往启动台(不强制跳转)。
|
||||
function pin(x) {
|
||||
pinDraft.value = {
|
||||
id: 0,
|
||||
name: x.name || '',
|
||||
kind: x.kind || 'other',
|
||||
port: x.port || x.ports?.[0] || 0,
|
||||
dir: x.dir || '',
|
||||
startCmd: '',
|
||||
stopCmd: '',
|
||||
icon: '',
|
||||
categoryId: 0,
|
||||
category: ''
|
||||
}
|
||||
}
|
||||
function onPinSaved() {
|
||||
pinDraft.value = null
|
||||
savedAsk.value = true
|
||||
store.showToast({ type: 'success', key: 'pmSavedToast' })
|
||||
load()
|
||||
}
|
||||
function goLaunchpad() {
|
||||
savedAsk.value = false
|
||||
router.push('/launchpad')
|
||||
}
|
||||
|
||||
async function stop(x) {
|
||||
@@ -169,12 +199,31 @@ const netChart = computed(() => lineOpt([
|
||||
|
||||
const gpuAvailable = computed(() => metrics.value && metrics.value.gpu >= 0)
|
||||
|
||||
// 自动刷新:列表与系统指标共用同一间隔;0=仅手动。
|
||||
function restartTimers() {
|
||||
clearInterval(timer)
|
||||
clearInterval(metricsTimer)
|
||||
timer = 0
|
||||
metricsTimer = 0
|
||||
if (refreshSec.value > 0) {
|
||||
timer = setInterval(load, refreshSec.value * 1000)
|
||||
metricsTimer = setInterval(loadMetrics, refreshSec.value * 1000)
|
||||
}
|
||||
}
|
||||
watch(refreshSec, v => {
|
||||
localStorage.setItem('cc-pm-refresh', String(v))
|
||||
restartTimers()
|
||||
})
|
||||
function manualRefresh() {
|
||||
load()
|
||||
loadMetrics()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try { kindIcons.value = await call('ListKindIcons') || {} } catch { kindIcons.value = {} }
|
||||
load()
|
||||
loadMetrics()
|
||||
timer = setInterval(load, 5000)
|
||||
metricsTimer = setInterval(loadMetrics, 2000)
|
||||
restartTimers()
|
||||
offChanged = on('launchpad:changed', load)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
@@ -197,7 +246,8 @@ onUnmounted(() => {
|
||||
<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>
|
||||
<RefreshIntervalPicker v-model="refreshSec" />
|
||||
<button class="btn secondary" :disabled="loading" @click="manualRefresh"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -274,5 +324,22 @@ onUnmounted(() => {
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LaunchAppFormModal :initial="pinDraft" @close="pinDraft = null" @saved="onPinSaved" />
|
||||
<Teleport to="body">
|
||||
<div v-if="savedAsk" class="overlay" @click.self="savedAsk = false">
|
||||
<section class="modal exit-modal" @click.stop>
|
||||
<header class="modal-head">
|
||||
<h2><Rocket />{{ t('pmSavedTitle') }}</h2>
|
||||
<button type="button" class="nm-close" :title="t('close')" @click="savedAsk = false"><X /></button>
|
||||
</header>
|
||||
<p class="exit-hint">{{ t('pmSavedAsk') }}</p>
|
||||
<div class="exit-actions">
|
||||
<button type="button" class="btn secondary" @click="savedAsk = false">{{ t('pmStayHere') }}</button>
|
||||
<button type="button" class="btn primary" @click="goLaunchpad">{{ t('pmGoLaunchpad') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -153,6 +153,7 @@ function setTab(v) {
|
||||
if (v === 'teams') loadTeams()
|
||||
// 素材库可用性跟随全局配置,进入时先刷新配置再拉列表(管理员可能刚在设置页改过)
|
||||
if (v === 'assets') { loadTeams(); loadFileStorage().then(() => reloadAssets()) }
|
||||
if (v === 'sync') loadProjSync()
|
||||
}
|
||||
// 按时段问候,让页面更像“我的空间”而不是后台表单
|
||||
const greeting = computed(() => {
|
||||
@@ -333,6 +334,44 @@ async function syncNow() {
|
||||
} catch (e) { msg.value = errText(e); store.showToast({ type: 'error', key: 'syncFailToast' }) }
|
||||
finally { busy.value = '' }
|
||||
}
|
||||
// ---- 项目云同步:自动/手动模式、手动拉取、认领入口与忽略恢复 ----
|
||||
const projSyncMode = ref('auto')
|
||||
const ignoredProjects = ref([])
|
||||
const pendingCount = ref(0)
|
||||
|
||||
async function loadProjSync() {
|
||||
projSyncMode.value = store.settings.projectSyncMode || 'auto'
|
||||
if (!sync.value.loggedIn) { ignoredProjects.value = []; pendingCount.value = 0; return }
|
||||
try { ignoredProjects.value = (await call('ListCloudIgnoredProjects')) || [] } catch { ignoredProjects.value = [] }
|
||||
try { pendingCount.value = ((await call('ListCloudPendingProjects')) || []).length } catch { pendingCount.value = 0 }
|
||||
}
|
||||
async function changeProjSyncMode() {
|
||||
await store.saveSettings({ projectSyncMode: projSyncMode.value })
|
||||
store.showToast({ type: 'success', key: projSyncMode.value === 'auto' ? 'projSyncAutoToast' : 'projSyncManualToast' })
|
||||
}
|
||||
async function syncProjectsNow() {
|
||||
if (busy.value) return
|
||||
busy.value = 'projsync'
|
||||
try {
|
||||
const st = await call('SyncProjectsNow')
|
||||
store.syncStatus = st
|
||||
store.showToast({ type: 'success', key: 'syncDoneToast', params: { pushed: st.pushed, pulled: st.pulled } })
|
||||
await loadProjSync()
|
||||
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
|
||||
finally { busy.value = '' }
|
||||
}
|
||||
async function unignoreProject(name) {
|
||||
try {
|
||||
await call('UnignoreCloudProject', name)
|
||||
store.showToast({ type: 'success', key: 'projUnignoredToast', params: { name } })
|
||||
await loadProjSync()
|
||||
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
|
||||
}
|
||||
function openClaim() {
|
||||
store.cloudClaimOpen = true
|
||||
}
|
||||
// 认领弹窗关闭后刷新待认领计数
|
||||
watch(() => store.cloudClaimOpen, v => { if (!v && tab.value === 'sync') loadProjSync() })
|
||||
async function syncLogout() {
|
||||
await call('SyncLogout')
|
||||
store.showToast({ type: 'success', key: 'logoutToast' })
|
||||
@@ -359,6 +398,7 @@ onMounted(async () => {
|
||||
await loadFileStorage()
|
||||
if (tab.value === 'teams') loadTeams()
|
||||
if (tab.value === 'assets') { loadTeams(); reloadAssets() }
|
||||
if (tab.value === 'sync') loadProjSync()
|
||||
addEventListener('keydown', onKey)
|
||||
})
|
||||
onUnmounted(() => removeEventListener('keydown', onKey))
|
||||
@@ -591,6 +631,37 @@ onUnmounted(() => removeEventListener('keydown', onKey))
|
||||
<span v-for="s in scopeTags" :key="s.key" class="pc-tag"><component :is="s.icon" />{{ t(s.key) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="sync.loggedIn">
|
||||
<div class="proj-sync">
|
||||
<header class="proj-sync-head">
|
||||
<b><Folder />{{ t('projSyncTitle') }}</b>
|
||||
<small>{{ t('projSyncHint') }}</small>
|
||||
</header>
|
||||
<div class="proj-sync-row">
|
||||
<label class="proj-sync-mode">
|
||||
<span>{{ t('projSyncModeLabel') }}</span>
|
||||
<select v-model="projSyncMode" @change="changeProjSyncMode">
|
||||
<option value="auto">{{ t('projSyncModeAuto') }}</option>
|
||||
<option value="manual">{{ t('projSyncModeManual') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn secondary" :disabled="!!busy" @click="syncProjectsNow">
|
||||
<RefreshCw :class="{ spin: busy === 'projsync' }" />{{ busy === 'projsync' ? t('syncingBtn') : t('projSyncNowBtn') }}
|
||||
</button>
|
||||
<button v-if="pendingCount" class="btn primary" @click="openClaim">
|
||||
<CloudUpload />{{ t('projClaimBtn', { n: pendingCount }) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="ignoredProjects.length" class="proj-ignored">
|
||||
<small>{{ t('projIgnoredTitle') }}</small>
|
||||
<div class="proj-ignored-list">
|
||||
<span v-for="n in ignoredProjects" :key="n" class="proj-ignored-item">
|
||||
{{ n }}<button type="button" :title="t('projUnignoreBtn')" @click="unignoreProject(n)"><X /></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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, Rocket } 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, ListFilter, Trash2 } from 'lucide-vue-next'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import ChartView from '../components/ChartView.vue'
|
||||
import GitHeatmap from '../components/GitHeatmap.vue'
|
||||
@@ -67,8 +67,41 @@ async function load() {
|
||||
loading.value = true
|
||||
;[p.value, git.value, structure.value, insights.value] = await Promise.all([call('GetProject', +route.params.id), call('GetGitStats', +route.params.id), call('GetStructure', +route.params.id), call('GetProjectInsights', +route.params.id)])
|
||||
try { diag.value = await call('GetGitDiagnostics', +route.params.id) } catch { diag.value = null }
|
||||
loadProjRules()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// ---- 项目专属排除规则:与全局规则叠加,仅本项目生效;随项目身份上云,认领后自动带回 ----
|
||||
const projRules = ref([])
|
||||
const rulePattern = ref('')
|
||||
const ruleBusy = ref(false)
|
||||
|
||||
async function loadProjRules() {
|
||||
try { projRules.value = (await call('GetProjectRules', +route.params.id)) || [] } catch { projRules.value = [] }
|
||||
}
|
||||
async function addProjRule() {
|
||||
const v = rulePattern.value.trim()
|
||||
if (!v || ruleBusy.value) return
|
||||
ruleBusy.value = true
|
||||
try {
|
||||
await call('AddProjectRule', +route.params.id, v, 'custom')
|
||||
rulePattern.value = ''
|
||||
await loadProjRules()
|
||||
store.showToast({ type: 'success', key: 'projRuleAddedToast' })
|
||||
} catch (e) {
|
||||
store.showToast({ type: 'error', text: String(e) })
|
||||
} finally {
|
||||
ruleBusy.value = false
|
||||
}
|
||||
}
|
||||
async function removeProjRule(r) {
|
||||
try {
|
||||
await call('DeleteRule', r.id)
|
||||
await loadProjRules()
|
||||
} catch (e) {
|
||||
store.showToast({ type: 'error', text: String(e) })
|
||||
}
|
||||
}
|
||||
async function refreshInsights() {
|
||||
insightLoading.value = true
|
||||
try {
|
||||
@@ -155,6 +188,17 @@ onMounted(load)
|
||||
<section class="panel shine-card"><h2>{{ t('languageDistribution') }}</h2><ChartView v-if="p.languages.length" :option="pie" /><div v-else class="empty">{{ t('empty') }}</div></section>
|
||||
<section class="panel shine-card"><h2>{{ t('languageDetails') }}</h2><div class="language-list"><div v-for="(x, i) in p.languages" :key="x.name"><b><i :style="{ background: colors[i % colors.length] }" />{{ x.name }}</b><span>{{ x.files }} {{ t('files') }}</span><strong>{{ fmt(x.code) }} {{ t('lines') }}</strong></div></div></section>
|
||||
</div>
|
||||
<section class="panel proj-rules-panel">
|
||||
<h2><ListFilter class="panel-icon" />{{ t('projRulesTitle') }}<small>{{ t('projRulesHint') }}</small></h2>
|
||||
<div class="proj-rules-add">
|
||||
<input v-model="rulePattern" :placeholder="t('rulePatternPh')" :disabled="ruleBusy" @keyup.enter="addProjRule" />
|
||||
<button class="btn secondary" :disabled="ruleBusy || !rulePattern.trim()" @click="addProjRule"><Plus />{{ t('add') }}</button>
|
||||
</div>
|
||||
<div class="proj-rules-chips">
|
||||
<button v-for="r in projRules" :key="r.id" type="button" class="proj-rule-chip" :title="t('delete')" @click="removeProjRule(r)">{{ r.pattern }}<Trash2 /></button>
|
||||
<span v-if="!projRules.length" class="proj-rules-empty">{{ t('projRulesEmpty') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else-if="tab === 'git'">
|
||||
|
||||
@@ -9,6 +9,7 @@ import MarkdownView from '../components/MarkdownView.vue'
|
||||
import DueQuickPick from '../components/DueQuickPick.vue'
|
||||
import DatePicker from '../components/DatePicker.vue'
|
||||
import LifecycleTimeline from '../components/LifecycleTimeline.vue'
|
||||
import AITaskGenerator from '../components/AITaskGenerator.vue'
|
||||
import { useMdEditor } from '../mdeditor'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -124,6 +125,10 @@ onUnmounted(() => offTasks())
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="todo-toolbar">
|
||||
<AITaskGenerator kind="ticket" :default-project-id="projectFilter || store.projects[0]?.id || 0" @saved="reloadAndNotify" />
|
||||
</div>
|
||||
|
||||
<section class="panel ticket-panel">
|
||||
<article v-for="x in filtered" :key="x.id" class="ticket-row" :class="[x.status, x.priority, { overdue: overdue(x) }]">
|
||||
<span class="ticket-type" :class="x.type" :title="t('ticketType.' + x.type)"><component :is="typeIcon[x.type] || ListChecks" /></span>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAppStore } from '../store'
|
||||
import MarkdownView from '../components/MarkdownView.vue'
|
||||
import DueQuickPick from '../components/DueQuickPick.vue'
|
||||
import LifecycleTimeline from '../components/LifecycleTimeline.vue'
|
||||
import AITaskGenerator from '../components/AITaskGenerator.vue'
|
||||
import { useMdEditor } from '../mdeditor'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -126,6 +127,10 @@ onUnmounted(() => offTasks())
|
||||
<button class="btn secondary" @click="load"><RefreshCw />{{ t('refresh') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="todo-toolbar">
|
||||
<AITaskGenerator kind="todo" :default-project-id="projectFilter" @saved="reloadAndNotify" />
|
||||
</div>
|
||||
|
||||
<div v-if="view === 'board'" class="todo-board">
|
||||
<section v-for="col in columns" :key="col.status" class="todo-column" :class="col.status">
|
||||
<header><component :is="statusIcon[col.status]" /><b>{{ t('todoStatus.' + col.status) }}</b><span>{{ col.items.length }}</span></header>
|
||||
|
||||
Reference in New Issue
Block a user