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:
李琦
2026-08-17 09:04:53 +08:00
parent c65cedf9fa
commit c430a0f6da
25 changed files with 1666 additions and 240 deletions

View File

@@ -0,0 +1,188 @@
<script setup>
// 启动台「保存为应用」表单弹窗:从 Launchpad 抽出,供启动台与端口监控复用。
// props.initial 非空即打开;带 _suggest 时用其作为启停命令建议(来自项目草稿)。
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Rocket, X, FolderOpen, Image, ImageUp, Hexagon, Zap, FileCode2, Coffee, Database, Globe, Boxes, Box } from 'lucide-vue-next'
import { call } from '../api'
const props = defineProps({
initial: { type: Object, default: null },
initialError: { type: String, default: '' }
})
const emit = defineEmits(['close', 'saved'])
const { t } = useI18n()
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' },
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
const form = ref(null)
const suggest = ref({ start: [], stop: [] })
const err = ref('')
const primaryCats = ref([])
const kindIcons = ref({})
let metaLoaded = false
async function loadMeta() {
if (metaLoaded) return
metaLoaded = true
try { primaryCats.value = await call('ListLaunchCategories') || [] } catch { primaryCats.value = [] }
try { kindIcons.value = await call('ListKindIcons') || {} } catch { kindIcons.value = {} }
}
watch(() => props.initial, async x => {
if (!x) { form.value = null; return }
err.value = ''
form.value = {
id: x.id || 0,
name: x.name || '',
kind: x.kind || 'other',
port: x.port || 0,
dir: x.dir || '',
startCmd: x.startCmd || '',
stopCmd: x.stopCmd || '',
icon: x.icon || '',
categoryId: x.categoryId || 0,
category: x.category || ''
}
loadMeta()
if (x._suggest) {
suggest.value = { start: x._suggest.start || [], stop: x._suggest.stop || [] }
peekIcon()
} else if (form.value.dir && !form.value.startCmd) {
await detectDir(false)
} else {
await loadSuggest()
}
}, { immediate: true })
async function loadSuggest() {
try { suggest.value = await call('LaunchCmdSuggest', form.value.kind) } catch { suggest.value = { start: [], stop: [] } }
}
async function detectDir(overwrite = true) {
if (!form.value?.dir) return
try {
const p = await call('DetectLaunchProfile', form.value.dir)
if (!p) return
if (overwrite || !form.value.name) form.value.name = p.name || form.value.name
if (overwrite || !form.value.kind || form.value.kind === 'other') form.value.kind = p.kind || form.value.kind
if (overwrite || !form.value.port) form.value.port = p.port || form.value.port
if (overwrite || !form.value.startCmd) form.value.startCmd = p.startCmd || form.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) {
form.value.dir = d
await detectDir(true)
}
} catch { /* 用户取消 */ }
}
async function peekIcon() {
if (!form.value) return
try {
form.value.icon = await call('PeekLaunchIcon', Number(form.value.port) || 0, form.value.dir || '')
} catch { /* ignore */ }
}
async function pickLocalIcon() {
if (!form.value) return
try {
const u = await call('PickLaunchIconImage')
if (u) form.value.icon = u
} catch (e) { err.value = String(e?.message || e) }
}
async function save() {
err.value = ''
try {
const saved = await call('SaveLaunchApp', {
...form.value,
port: Number(form.value.port) || 0,
categoryId: Number(form.value.categoryId) || 0
})
emit('saved', saved)
} catch (e) {
err.value = String(e?.message || e)
}
}
</script>
<template>
<Teleport to="body">
<div v-if="form" class="overlay" @click.self="emit('close')">
<section class="modal lp-modal">
<header class="lp-modal-head">
<h2><Rocket />{{ form.id ? t('lpEditApp') : t('lpAddApp') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="emit('close')"><X /></button>
</header>
<div class="lp-form">
<div class="lp-icon-edit">
<span class="lp-icon lg" :style="{ color: kindUI(form.kind).color, background: `color-mix(in srgb, ${kindUI(form.kind).color} 14%, transparent)` }">
<img v-if="form.icon || kindIcons[form.kind]" :src="form.icon || kindIcons[form.kind]" alt="" />
<component v-else :is="kindUI(form.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="peekIcon"><Image />{{ t('lpFetchIcon') }}</button>
<button v-if="form.icon" type="button" class="btn secondary" @click="form.icon = ''"><X />{{ t('lpClearIcon') }}</button>
</div>
</div>
<label class="lp-field"><span>{{ t('lpName') }}</span><input v-model="form.name" :placeholder="t('lpName')" /></label>
<label class="lp-field"><span>{{ t('lpPrimaryCat') }}</span>
<select v-model.number="form.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="form.category" list="lafm-cat-list" :placeholder="t('lpCategoryPh')" />
<datalist id="lafm-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="form.kind" @change="loadSuggest">
<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="form.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="form.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="form.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="form.startCmd = c">{{ c }}</button>
</div>
<label class="lp-field"><span>{{ t('lpStopCmd') }}</span><input v-model="form.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="form.stopCmd = c">{{ c }}</button>
</div>
<p v-if="err || initialError" class="lp-err">{{ err || initialError }}</p>
</div>
<footer class="lp-modal-foot">
<button class="btn secondary" @click="emit('close')">{{ t('cancel') }}</button>
<button class="btn" @click="save">{{ t('save') }}</button>
</footer>
</section>
</div>
</Teleport>
</template>