189 lines
8.0 KiB
Vue
189 lines
8.0 KiB
Vue
|
|
<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>
|