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:
188
frontend/src/components/LaunchAppFormModal.vue
Normal file
188
frontend/src/components/LaunchAppFormModal.vue
Normal 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>
|
||||
Reference in New Issue
Block a user