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:
@@ -17,6 +17,7 @@ import NoteCenter from './components/NoteCenter.vue'
|
||||
import LocalPackCenter from './components/LocalPackCenter.vue'
|
||||
import TeamSwitcher from './components/TeamSwitcher.vue'
|
||||
import TitleBar from './components/TitleBar.vue'
|
||||
import CloudClaimModal from './components/CloudClaimModal.vue'
|
||||
import { call, isNative, on } from './api'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -369,6 +370,7 @@ watch(activeTask, task => {
|
||||
<LoginModal v-if="store.loginOpen" />
|
||||
<CommandPalette v-if="store.paletteOpen" />
|
||||
<AboutModal v-if="aboutOpen" @close="aboutOpen = false" />
|
||||
<CloudClaimModal />
|
||||
<DailyCard />
|
||||
</div>
|
||||
<div v-else class="boot-loading with-titlebar"><Database class="spin" />正在检查数据库...</div>
|
||||
|
||||
200
frontend/src/components/AITaskGenerator.vue
Normal file
200
frontend/src/components/AITaskGenerator.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<script setup>
|
||||
// 一句话 AI 生成多条待办/工单:输入 → AIGenerateTasks 拆解 → 预览勾选/微调 → 批量保存。
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Sparkles, X, LoaderCircle, Check } from 'lucide-vue-next'
|
||||
import { call } from '../api'
|
||||
import { useAppStore } from '../store'
|
||||
|
||||
const props = defineProps({
|
||||
kind: { type: String, required: true }, // todo | ticket
|
||||
defaultProjectId: { type: Number, default: 0 }
|
||||
})
|
||||
const emit = defineEmits(['saved'])
|
||||
const { t } = useI18n()
|
||||
const store = useAppStore()
|
||||
|
||||
const text = ref('')
|
||||
const busy = ref(false)
|
||||
const saving = ref(false)
|
||||
const err = ref('')
|
||||
const drafts = ref(null) // null=未打开预览
|
||||
const projectId = ref(0)
|
||||
|
||||
const errText = e => {
|
||||
const code = String(e?.message || e).split(':')[0].trim()
|
||||
const k = 'errors.' + code
|
||||
return t(k) !== k ? t(k) : String(e?.message || e)
|
||||
}
|
||||
|
||||
const selectedCount = computed(() => (drafts.value || []).filter(d => d._on).length)
|
||||
const needProject = computed(() => props.kind === 'ticket' && !projectId.value)
|
||||
|
||||
async function generate() {
|
||||
const q = text.value.trim()
|
||||
if (!q || busy.value) return
|
||||
busy.value = true
|
||||
err.value = ''
|
||||
try {
|
||||
const list = await call('AIGenerateTasks', props.kind, q)
|
||||
drafts.value = (list || []).map(d => ({ ...d, _on: true }))
|
||||
projectId.value = props.defaultProjectId || 0
|
||||
} catch (e) {
|
||||
err.value = errText(e)
|
||||
store.showToast({ type: 'error', text: err.value })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelected() {
|
||||
if (saving.value || !selectedCount.value || needProject.value) return
|
||||
saving.value = true
|
||||
err.value = ''
|
||||
let ok = 0
|
||||
try {
|
||||
for (const d of drafts.value) {
|
||||
if (!d._on || !d.title.trim()) continue
|
||||
if (props.kind === 'todo') {
|
||||
await call('SaveTodo', { id: 0, title: d.title.trim(), content: d.content || '', projectId: Number(projectId.value) || 0, dueAt: d.dueAt || '', priority: d.priority, status: 'open' })
|
||||
} else {
|
||||
await call('SaveTicket', { id: 0, title: d.title.trim(), description: d.description || '', type: d.type || 'task', projectId: Number(projectId.value), startAt: d.startAt, dueAt: d.dueAt, priority: d.priority, status: 'open' })
|
||||
}
|
||||
ok++
|
||||
}
|
||||
drafts.value = null
|
||||
text.value = ''
|
||||
store.showToast({ type: 'success', text: t('aiGenSavedToast', { n: ok }) })
|
||||
emit('saved')
|
||||
} catch (e) {
|
||||
err.value = errText(e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
const on = selectedCount.value < drafts.value.length
|
||||
drafts.value.forEach(d => { d._on = on })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ai-gen">
|
||||
<label class="ai-gen-bar" :class="{ busy }">
|
||||
<Sparkles />
|
||||
<input
|
||||
v-model="text"
|
||||
:placeholder="t(kind === 'todo' ? 'aiGenPhTodo' : 'aiGenPhTicket')"
|
||||
:disabled="busy"
|
||||
@keyup.enter="generate"
|
||||
/>
|
||||
<button type="button" class="btn primary sm" :disabled="busy || !text.trim()" @click="generate">
|
||||
<LoaderCircle v-if="busy" class="spin" /><Sparkles v-else />{{ busy ? t('aiGenBusy') : t('aiGenBtn') }}
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="drafts" class="overlay" @click.self="drafts = null">
|
||||
<section class="modal ai-gen-modal" @click.stop>
|
||||
<header class="ai-gen-head">
|
||||
<h2><Sparkles />{{ t('aiGenPreviewTitle') }}</h2>
|
||||
<button type="button" class="nm-close" :title="t('close')" @click="drafts = null"><X /></button>
|
||||
</header>
|
||||
<div class="ai-gen-tools">
|
||||
<button type="button" class="btn secondary sm" @click="toggleAll">{{ selectedCount < drafts.length ? t('aiGenSelectAll') : t('aiGenSelectNone') }}</button>
|
||||
<label class="ai-gen-proj">
|
||||
<span>{{ t('aiGenProject') }}{{ kind === 'ticket' ? ' *' : '' }}</span>
|
||||
<select v-model.number="projectId">
|
||||
<option :value="0">{{ kind === 'ticket' ? t('aiGenPickProject') : t('noProject') }}</option>
|
||||
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ai-gen-list">
|
||||
<article v-for="(d, i) in drafts" :key="i" class="ai-gen-item" :class="{ off: !d._on }">
|
||||
<label class="ai-gen-check"><input v-model="d._on" type="checkbox" /></label>
|
||||
<div class="ai-gen-fields">
|
||||
<input v-model="d.title" class="ai-gen-title" :placeholder="t('ticketTitle')" />
|
||||
<div class="ai-gen-row">
|
||||
<select v-if="kind === 'ticket'" v-model="d.type">
|
||||
<option value="feature">{{ t('ticketType.feature') }}</option>
|
||||
<option value="bug">{{ t('ticketType.bug') }}</option>
|
||||
<option value="task">{{ t('ticketType.task') }}</option>
|
||||
<option value="improvement">{{ t('ticketType.improvement') }}</option>
|
||||
</select>
|
||||
<select v-model="d.priority">
|
||||
<option value="low">{{ t('priority.low') }}</option>
|
||||
<option value="medium">{{ t('priority.medium') }}</option>
|
||||
<option value="high">{{ t('priority.high') }}</option>
|
||||
</select>
|
||||
<template v-if="kind === 'ticket'">
|
||||
<input v-model="d.startAt" type="date" :title="t('startDate')" />
|
||||
<input v-model="d.dueAt" type="date" :title="t('dueDate')" />
|
||||
</template>
|
||||
<input v-else v-model="d.dueAt" type="datetime-local" :title="t('dueDate')" />
|
||||
</div>
|
||||
<p v-if="kind === 'todo' ? d.content : d.description" class="ai-gen-desc">{{ kind === 'todo' ? d.content : d.description }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-if="err" class="ai-gen-err">{{ err }}</p>
|
||||
<p v-if="needProject" class="ai-gen-hint">{{ t('aiGenNeedProject') }}</p>
|
||||
<footer class="ai-gen-foot">
|
||||
<button type="button" class="btn secondary" @click="drafts = null">{{ t('cancel') }}</button>
|
||||
<button type="button" class="btn primary" :disabled="saving || !selectedCount || needProject" @click="saveSelected">
|
||||
<LoaderCircle v-if="saving" class="spin" /><Check v-else />{{ t('aiGenSaveN', { n: selectedCount }) }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-gen { display: flex; flex: 1; min-width: 220px; }
|
||||
.ai-gen-bar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
padding: .2rem .3rem .2rem .7rem;
|
||||
border: 1px solid color-mix(in srgb, #8b5cf6 45%, var(--border));
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, #8b5cf6 7%, transparent);
|
||||
}
|
||||
.ai-gen-bar > svg { width: 16px; height: 16px; color: #a78bfa; flex: none; }
|
||||
.ai-gen-bar input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
padding: .45rem 0;
|
||||
font-size: .88rem;
|
||||
}
|
||||
.btn.sm { padding: .38rem .7rem; font-size: .82rem; }
|
||||
.ai-gen-modal { width: min(680px, 94vw); max-height: 84vh; display: flex; flex-direction: column; }
|
||||
.ai-gen-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .6rem; }
|
||||
.ai-gen-head h2 { display: flex; align-items: center; gap: .45rem; font-size: 1.05rem; margin: 0; }
|
||||
.ai-gen-head svg { width: 18px; height: 18px; color: #a78bfa; }
|
||||
.ai-gen-tools { display: flex; align-items: center; gap: .75rem; margin-bottom: .6rem; flex-wrap: wrap; }
|
||||
.ai-gen-proj { display: inline-flex; align-items: center; gap: .45rem; font-size: .85rem; color: var(--muted); }
|
||||
.ai-gen-proj select { padding: .35rem .5rem; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text); }
|
||||
.ai-gen-list { flex: 1; overflow: auto; display: flex; flex-direction: column; gap: .5rem; min-height: 120px; }
|
||||
.ai-gen-item { display: flex; gap: .6rem; border: 1px solid var(--border); border-radius: 10px; padding: .6rem .7rem; }
|
||||
.ai-gen-item.off { opacity: .45; }
|
||||
.ai-gen-check { padding-top: .35rem; }
|
||||
.ai-gen-fields { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .4rem; }
|
||||
.ai-gen-title { width: 100%; padding: .4rem .55rem; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text); font-weight: 600; }
|
||||
.ai-gen-row { display: flex; gap: .4rem; flex-wrap: wrap; }
|
||||
.ai-gen-row select, .ai-gen-row input { padding: .3rem .45rem; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text); font-size: .82rem; }
|
||||
.ai-gen-desc { margin: 0; font-size: .8rem; color: var(--muted); white-space: pre-wrap; }
|
||||
.ai-gen-err { color: #ef4444; font-size: .85rem; margin: .5rem 0 0; }
|
||||
.ai-gen-hint { color: #f59e0b; font-size: .85rem; margin: .5rem 0 0; }
|
||||
.ai-gen-foot { display: flex; justify-content: flex-end; gap: .5rem; margin-top: .85rem; }
|
||||
.spin { animation: aigen-spin 1s linear infinite; }
|
||||
@keyframes aigen-spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
149
frontend/src/components/CloudClaimModal.vue
Normal file
149
frontend/src/components/CloudClaimModal.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup>
|
||||
// 云端项目认领弹窗:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只弹一次),
|
||||
// 每个项目可「选目录认领」或「忽略」;忽略后可在 个人主页-云同步 里恢复。
|
||||
// 手动打开入口:store.cloudClaimOpen = true(工作台横幅 / 个人主页)。
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CloudDownload, FolderOpen, X, EyeOff, RefreshCw, FolderGit2, ListFilter } from 'lucide-vue-next'
|
||||
import { call, on } from '../api'
|
||||
import { useAppStore } from '../store'
|
||||
|
||||
const store = useAppStore()
|
||||
const { t } = useI18n()
|
||||
const items = ref([])
|
||||
const busy = ref('') // 正在绑定/忽略的项目名
|
||||
const errs = ref({})
|
||||
let offSync = null
|
||||
|
||||
const dismissKey = () => 'cc-cloud-claim-seen:' + (store.syncStatus.userId || 0)
|
||||
const signature = list => list.map(x => x.name).sort().join('\n')
|
||||
|
||||
const errText = e => {
|
||||
const code = String(e?.message || e).split(':')[0].trim()
|
||||
const k = 'errors.' + code
|
||||
return t(k) !== k ? t(k) : String(e?.message || e)
|
||||
}
|
||||
|
||||
async function refresh(auto) {
|
||||
if (!store.syncStatus.loggedIn) { items.value = []; return }
|
||||
try { items.value = (await call('ListCloudPendingProjects')) || [] } catch { items.value = [] }
|
||||
if (!items.value.length) {
|
||||
if (store.cloudClaimOpen) store.cloudClaimOpen = false
|
||||
return
|
||||
}
|
||||
// 自动弹出只针对“没见过的组合”:认领/忽略过或点过稍后,同一批不再打扰
|
||||
if (auto && localStorage.getItem(dismissKey()) !== signature(items.value)) store.cloudClaimOpen = true
|
||||
}
|
||||
|
||||
function later() {
|
||||
localStorage.setItem(dismissKey(), signature(items.value))
|
||||
store.cloudClaimOpen = false
|
||||
}
|
||||
|
||||
async function claim(it) {
|
||||
if (busy.value) return
|
||||
errs.value = { ...errs.value, [it.name]: '' }
|
||||
let dir = ''
|
||||
try { dir = await call('SelectDirectory') } catch { return }
|
||||
if (!dir) return
|
||||
busy.value = it.name
|
||||
try {
|
||||
await call('BindCloudProject', it.name, dir)
|
||||
items.value = items.value.filter(x => x.name !== it.name)
|
||||
await store.refresh()
|
||||
store.showToast({ type: 'success', key: 'cloudBindDone' })
|
||||
if (!items.value.length) store.cloudClaimOpen = false
|
||||
} catch (e) {
|
||||
errs.value = { ...errs.value, [it.name]: errText(e) }
|
||||
} finally {
|
||||
busy.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function ignore(it) {
|
||||
if (busy.value) return
|
||||
busy.value = it.name
|
||||
try {
|
||||
await call('IgnoreCloudProject', it.name)
|
||||
items.value = items.value.filter(x => x.name !== it.name)
|
||||
store.showToast({ type: 'success', key: 'cloudClaimIgnoredToast', params: { name: it.name } })
|
||||
if (!items.value.length) store.cloudClaimOpen = false
|
||||
} catch (e) {
|
||||
errs.value = { ...errs.value, [it.name]: errText(e) }
|
||||
} finally {
|
||||
busy.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 手动打开(横幅/个人主页入口)时拉一次最新清单
|
||||
watch(() => store.cloudClaimOpen, v => { if (v) refresh(false) })
|
||||
watch(() => store.syncStatus.loggedIn, v => { if (!v) { items.value = []; store.cloudClaimOpen = false } })
|
||||
|
||||
onMounted(() => {
|
||||
refresh(true)
|
||||
offSync = on('sync:done', () => refresh(true))
|
||||
})
|
||||
onUnmounted(() => { offSync?.() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="store.cloudClaimOpen" class="overlay" @click.self="later">
|
||||
<section class="modal claim-modal" @click.stop>
|
||||
<header class="claim-head">
|
||||
<h2><CloudDownload />{{ t('cloudPendingTitle') }}<em v-if="items.length">{{ items.length }}</em></h2>
|
||||
<button type="button" class="nm-close" :title="t('close')" @click="later"><X /></button>
|
||||
</header>
|
||||
<p class="claim-desc">{{ t('cloudClaimDesc') }}</p>
|
||||
<div class="claim-list">
|
||||
<article v-for="it in items" :key="it.name" class="claim-item">
|
||||
<span class="claim-ico"><FolderGit2 /></span>
|
||||
<div class="claim-main">
|
||||
<b>{{ it.name }}</b>
|
||||
<small>{{ [it.group, it.description].filter(Boolean).join(' · ') || '—' }}</small>
|
||||
<small v-if="it.rules?.length" class="claim-rules"><ListFilter />{{ t('cloudClaimRules', { n: it.rules.length }) }}</small>
|
||||
<p v-if="errs[it.name]" class="claim-err">{{ errs[it.name] }}</p>
|
||||
</div>
|
||||
<div class="claim-ops">
|
||||
<button type="button" class="btn primary sm" :disabled="!!busy" @click="claim(it)">
|
||||
<RefreshCw v-if="busy === it.name" class="spin" /><FolderOpen v-else />{{ t('cloudClaimPick') }}
|
||||
</button>
|
||||
<button type="button" class="btn secondary sm" :disabled="!!busy" :title="t('cloudClaimIgnoreHint')" @click="ignore(it)">
|
||||
<EyeOff />{{ t('cloudClaimIgnore') }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!items.length" class="claim-empty">{{ t('cloudClaimEmpty') }}</p>
|
||||
</div>
|
||||
<footer class="claim-foot">
|
||||
<button type="button" class="btn secondary" @click="later">{{ t('cloudClaimLater') }}</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.claim-modal { width: min(620px, 94vw); max-height: 80vh; display: flex; flex-direction: column; }
|
||||
.claim-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .35rem; }
|
||||
.claim-head h2 { display: flex; align-items: center; gap: .5rem; font-size: 1.05rem; margin: 0; }
|
||||
.claim-head h2 svg { width: 18px; height: 18px; color: #38bdf8; }
|
||||
.claim-head h2 em { font-style: normal; font-size: .75rem; padding: .1rem .5rem; border-radius: 999px; background: color-mix(in srgb, #38bdf8 18%, transparent); color: #38bdf8; }
|
||||
.claim-desc { margin: 0 0 .75rem; font-size: .85rem; color: var(--muted); }
|
||||
.claim-list { flex: 1; overflow: auto; display: flex; flex-direction: column; gap: .5rem; min-height: 100px; }
|
||||
.claim-item { display: flex; align-items: center; gap: .7rem; border: 1px solid var(--border); border-radius: 10px; padding: .65rem .75rem; }
|
||||
.claim-ico { flex: none; width: 36px; height: 36px; display: grid; place-items: center; border-radius: 9px; background: color-mix(in srgb, #38bdf8 12%, transparent); }
|
||||
.claim-ico svg { width: 18px; height: 18px; color: #38bdf8; }
|
||||
.claim-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .1rem; }
|
||||
.claim-main b { font-size: .92rem; }
|
||||
.claim-main small { color: var(--muted); font-size: .78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.claim-rules { display: inline-flex; align-items: center; gap: .25rem; }
|
||||
.claim-rules svg { width: 12px; height: 12px; }
|
||||
.claim-err { margin: .15rem 0 0; color: #ef4444; font-size: .78rem; }
|
||||
.claim-ops { display: flex; flex-direction: column; gap: .35rem; flex: none; }
|
||||
.btn.sm { padding: .34rem .65rem; font-size: .8rem; }
|
||||
.claim-empty { text-align: center; color: var(--muted); padding: 1.5rem 0; }
|
||||
.claim-foot { display: flex; justify-content: flex-end; margin-top: .85rem; }
|
||||
.spin { animation: claim-spin 1s linear infinite; }
|
||||
@keyframes claim-spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
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>
|
||||
42
frontend/src/components/RefreshIntervalPicker.vue
Normal file
42
frontend/src/components/RefreshIntervalPicker.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
// 自动刷新间隔选择:0=不自动刷新(仅手动),其余为秒数。父组件负责持久化与定时器。
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Timer } from 'lucide-vue-next'
|
||||
|
||||
defineProps({ modelValue: { type: Number, default: 30 } })
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const { t } = useI18n()
|
||||
const OPTIONS = [0, 5, 10, 30, 60, 120]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="refresh-pick" :title="t('autoRefreshEvery')">
|
||||
<Timer />
|
||||
<select :value="modelValue" @change="emit('update:modelValue', Number($event.target.value))">
|
||||
<option v-for="o in OPTIONS" :key="o" :value="o">{{ o === 0 ? t('refreshManualOnly') : t('refreshEveryN', { n: o }) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.refresh-pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
padding: 0 .55rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--panel, transparent);
|
||||
color: var(--muted);
|
||||
}
|
||||
.refresh-pick svg { width: 15px; height: 15px; }
|
||||
.refresh-pick select {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: .85rem;
|
||||
padding: .45rem 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -51,6 +51,14 @@ const zh = {
|
||||
pmNetDown: '下行',
|
||||
pmNetUp: '上行',
|
||||
pmGpuNA: '未检测到 GPU 数据',
|
||||
pmSavedTitle: '已存为应用',
|
||||
pmSavedToast: '已存为启动台应用',
|
||||
pmSavedAsk: '应用已保存到启动台,现在前往启动台管理吗?',
|
||||
pmStayHere: '留在本页',
|
||||
pmGoLaunchpad: '前往启动台',
|
||||
autoRefreshEvery: '自动刷新间隔',
|
||||
refreshManualOnly: '仅手动刷新',
|
||||
refreshEveryN: '每 {n} 秒',
|
||||
packRun: '执行打包',
|
||||
packCmdsTitle: '配置打包命令',
|
||||
packCmdsTitleNamed: '配置打包命令 · {name}',
|
||||
@@ -188,6 +196,18 @@ const zh = {
|
||||
ticketStatus: { open: '待处理', in_progress: '处理中', resolved: '已解决', closed: '已关闭' },
|
||||
ticketFlow: { start: '开始处理', resolve: '标记解决', close: '关闭', reopen: '重新打开' },
|
||||
noTickets: '暂无工单',
|
||||
aiGenPhTodo: '一句话描述,AI 帮你拆成多条待办,回车生成...',
|
||||
aiGenPhTicket: '一句话描述需求,AI 帮你拆成多条工单,回车生成...',
|
||||
aiGenBtn: 'AI 生成',
|
||||
aiGenBusy: '生成中...',
|
||||
aiGenPreviewTitle: 'AI 生成预览',
|
||||
aiGenSelectAll: '全选',
|
||||
aiGenSelectNone: '全不选',
|
||||
aiGenProject: '归属项目',
|
||||
aiGenPickProject: '请选择项目',
|
||||
aiGenNeedProject: '工单必须关联项目,请先选择归属项目',
|
||||
aiGenSaveN: '保存所选({n})',
|
||||
aiGenSavedToast: '已保存 {n} 条',
|
||||
today: '今天',
|
||||
selectDate: '选择日期',
|
||||
noSchedule: '当日无排期',
|
||||
@@ -232,11 +252,36 @@ const zh = {
|
||||
aiScopeEmpty: '还没有生成过总结,点击下方按钮让 AI 帮你分析。',
|
||||
aiScopeGen: '生成总结',
|
||||
aiScopeRegen: '重新生成',
|
||||
cloudPendingTitle: '云端项目待绑定',
|
||||
cloudPendingTitle: '云端项目待认领',
|
||||
cloudPendingDesc: '这些项目来自你在其它电脑的同步数据,选择本机目录后即可继续统计',
|
||||
cloudBindBtn: '绑定目录',
|
||||
cloudBindTitle: '绑定云端项目到本机',
|
||||
cloudBindDone: '云端项目已绑定,可以开始统计了',
|
||||
cloudClaimDesc: '逐个选择本机目录认领,或忽略暂不需要的项目(忽略后可在 个人主页 · 云同步 恢复)',
|
||||
cloudClaimGo: '去认领',
|
||||
cloudClaimLater: '稍后再说',
|
||||
cloudClaimPick: '选目录认领',
|
||||
cloudClaimIgnore: '忽略',
|
||||
cloudClaimIgnoreHint: '本机不再提示该项目,可在个人主页恢复',
|
||||
cloudClaimIgnoredToast: '已忽略「{name}」',
|
||||
cloudClaimRules: '{n} 条专属排除规则',
|
||||
cloudClaimEmpty: '没有待认领的项目',
|
||||
projSyncTitle: '项目云同步',
|
||||
projSyncHint: '项目名称/分组/规则随账号上云;统计数据按机器码分开存储,互不覆盖',
|
||||
projSyncModeLabel: '同步方式',
|
||||
projSyncModeAuto: '自动(随每轮同步)',
|
||||
projSyncModeManual: '手动(仅点击时同步)',
|
||||
projSyncAutoToast: '已切换为自动同步项目',
|
||||
projSyncManualToast: '已切换为手动同步,项目清单仅在点击「立即同步项目」时推拉',
|
||||
projSyncNowBtn: '立即同步项目',
|
||||
projClaimBtn: '认领项目({n})',
|
||||
projIgnoredTitle: '已忽略的云端项目',
|
||||
projUnignoreBtn: '恢复',
|
||||
projUnignoredToast: '已恢复「{name}」,将重新出现在待认领清单',
|
||||
projRulesTitle: '项目专属排除规则',
|
||||
projRulesHint: '仅对本项目生效,与全局规则叠加;随项目上云,其它机器认领后自动带回。改动后重新分析生效',
|
||||
projRulesEmpty: '暂无专属规则,本项目仅套用全局排除规则',
|
||||
projRuleAddedToast: '专属规则已添加,重新分析后生效',
|
||||
navTeam: '团队',
|
||||
teamHome: '团队概览',
|
||||
teamHomeSubtitle: '成员、角色与团队设置',
|
||||
@@ -725,6 +770,15 @@ const zh = {
|
||||
adminStatTokens: '今日 Token',
|
||||
adminDAUSeries: '近 14 日日活',
|
||||
adminTokenSeries: '近 14 日 Token',
|
||||
adminCalls: '调用次数',
|
||||
adminProviderPie: 'AI 提供商用量分布',
|
||||
adminDataPie: '云端数据构成',
|
||||
adminViewDetail: '查看详情',
|
||||
adminUserDetail: '用户详情',
|
||||
adminCntTeams: '团队',
|
||||
adminJoinedAt: '注册时间',
|
||||
adminLastSeen: '最近活跃',
|
||||
adminOwnedTeams: '拥有团队',
|
||||
adminColAI: 'AI',
|
||||
adminColDisabled: '账号',
|
||||
adminColMembers: '成员',
|
||||
@@ -1057,6 +1111,14 @@ const en = {
|
||||
pmNetDown: 'Download',
|
||||
pmNetUp: 'Upload',
|
||||
pmGpuNA: 'No GPU metrics',
|
||||
pmSavedTitle: 'Saved as app',
|
||||
pmSavedToast: 'Saved to Launchpad',
|
||||
pmSavedAsk: 'The app was saved to Launchpad. Go there to manage it now?',
|
||||
pmStayHere: 'Stay here',
|
||||
pmGoLaunchpad: 'Go to Launchpad',
|
||||
autoRefreshEvery: 'Auto-refresh interval',
|
||||
refreshManualOnly: 'Manual only',
|
||||
refreshEveryN: 'Every {n}s',
|
||||
packRun: 'Run package',
|
||||
packCmdsTitle: 'Configure package commands',
|
||||
packCmdsTitleNamed: 'Package commands · {name}',
|
||||
@@ -1194,6 +1256,18 @@ const en = {
|
||||
ticketStatus: { open: 'Open', in_progress: 'In progress', resolved: 'Resolved', closed: 'Closed' },
|
||||
ticketFlow: { start: 'Start', resolve: 'Resolve', close: 'Close', reopen: 'Reopen' },
|
||||
noTickets: 'No tickets yet',
|
||||
aiGenPhTodo: 'Describe in one sentence and AI splits it into todos. Enter to generate...',
|
||||
aiGenPhTicket: 'Describe in one sentence and AI splits it into tickets. Enter to generate...',
|
||||
aiGenBtn: 'AI generate',
|
||||
aiGenBusy: 'Generating...',
|
||||
aiGenPreviewTitle: 'AI generated preview',
|
||||
aiGenSelectAll: 'Select all',
|
||||
aiGenSelectNone: 'Select none',
|
||||
aiGenProject: 'Project',
|
||||
aiGenPickProject: 'Pick a project',
|
||||
aiGenNeedProject: 'Tickets must belong to a project. Pick one first.',
|
||||
aiGenSaveN: 'Save selected ({n})',
|
||||
aiGenSavedToast: 'Saved {n} item(s)',
|
||||
today: 'Today',
|
||||
selectDate: 'Select a date',
|
||||
noSchedule: 'Nothing scheduled',
|
||||
@@ -1238,11 +1312,36 @@ const en = {
|
||||
aiScopeEmpty: 'No summary yet — let AI analyze this page below.',
|
||||
aiScopeGen: 'Generate',
|
||||
aiScopeRegen: 'Regenerate',
|
||||
cloudPendingTitle: 'Cloud projects to bind',
|
||||
cloudPendingTitle: 'Cloud projects to claim',
|
||||
cloudPendingDesc: 'These projects were synced from your other computers. Pick a local folder to continue.',
|
||||
cloudBindBtn: 'Bind folder',
|
||||
cloudBindTitle: 'Bind cloud project locally',
|
||||
cloudBindDone: 'Cloud project bound, ready to analyze',
|
||||
cloudClaimDesc: 'Claim each project by picking a local folder, or ignore ones you do not need here (restore later in Profile · Sync).',
|
||||
cloudClaimGo: 'Claim now',
|
||||
cloudClaimLater: 'Later',
|
||||
cloudClaimPick: 'Pick folder & claim',
|
||||
cloudClaimIgnore: 'Ignore',
|
||||
cloudClaimIgnoreHint: 'Hide on this machine; restore in Profile',
|
||||
cloudClaimIgnoredToast: 'Ignored "{name}"',
|
||||
cloudClaimRules: '{n} project rule(s)',
|
||||
cloudClaimEmpty: 'Nothing to claim',
|
||||
projSyncTitle: 'Project cloud sync',
|
||||
projSyncHint: 'Project identity/groups/rules sync with your account; stats are stored per machine and never overwrite each other.',
|
||||
projSyncModeLabel: 'Sync mode',
|
||||
projSyncModeAuto: 'Auto (every sync round)',
|
||||
projSyncModeManual: 'Manual (only on demand)',
|
||||
projSyncAutoToast: 'Project sync set to automatic',
|
||||
projSyncManualToast: 'Manual mode: project docs sync only when you click "Sync projects now"',
|
||||
projSyncNowBtn: 'Sync projects now',
|
||||
projClaimBtn: 'Claim projects ({n})',
|
||||
projIgnoredTitle: 'Ignored cloud projects',
|
||||
projUnignoreBtn: 'Restore',
|
||||
projUnignoredToast: 'Restored "{name}" to the claim list',
|
||||
projRulesTitle: 'Project exclusion rules',
|
||||
projRulesHint: 'Apply to this project only, on top of global rules; synced with the project and restored on claim. Re-analyze to take effect.',
|
||||
projRulesEmpty: 'No project rules yet — only global rules apply',
|
||||
projRuleAddedToast: 'Rule added. Re-analyze to apply.',
|
||||
navTeam: 'Team',
|
||||
teamHome: 'Team overview',
|
||||
teamHomeSubtitle: 'Members, roles and team settings',
|
||||
@@ -1731,6 +1830,15 @@ const en = {
|
||||
adminStatTokens: 'Tokens today',
|
||||
adminDAUSeries: 'DAU (14d)',
|
||||
adminTokenSeries: 'Tokens (14d)',
|
||||
adminCalls: 'Calls',
|
||||
adminProviderPie: 'AI provider usage',
|
||||
adminDataPie: 'Cloud data breakdown',
|
||||
adminViewDetail: 'View detail',
|
||||
adminUserDetail: 'User detail',
|
||||
adminCntTeams: 'Teams',
|
||||
adminJoinedAt: 'Joined',
|
||||
adminLastSeen: 'Last seen',
|
||||
adminOwnedTeams: 'Owned teams',
|
||||
adminColAI: 'AI',
|
||||
adminColDisabled: 'Account',
|
||||
adminColMembers: 'Members',
|
||||
|
||||
@@ -691,6 +691,11 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .
|
||||
.wb-note-item time{font-size:10.5px;color:var(--muted)}
|
||||
.wb-note-list-empty{padding:16px 8px;text-align:center;color:var(--muted);font-size:12px}
|
||||
.wb-note-body .wb-note{min-height:180px;height:100%;resize:vertical}
|
||||
/* 编辑/预览切换 + Markdown 预览框 */
|
||||
.wb-note-tabs{margin-right:2px}
|
||||
.wb-note-tabs button{display:inline-flex;align-items:center;gap:5px;height:30px;padding:0 10px;font-size:12px}
|
||||
.wb-note-tabs button svg{width:13px;height:13px}
|
||||
.wb-note-body .wb-note-preview{min-height:180px;max-height:none;height:100%;overflow:auto}
|
||||
@media (max-width:980px){
|
||||
.wb-note-body{grid-template-columns:1fr}
|
||||
.wb-note-list{max-height:120px;flex-direction:row;flex-wrap:wrap}
|
||||
@@ -1277,6 +1282,22 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
|
||||
.pc-scope-tags{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.pc-tag{display:inline-flex;align-items:center;gap:7px;height:30px;padding:0 13px;border-radius:999px;font-size:12.5px;font-weight:600;color:var(--text);background:rgba(115,103,245,.09);border:1px solid rgba(115,103,245,.25)}
|
||||
.pc-tag svg{width:14px;height:14px;color:#a9a2ff}
|
||||
/* 项目云同步(自动/手动模式、手动拉取、忽略恢复) */
|
||||
.proj-sync{margin-top:20px;padding:14px 16px;border:1px solid var(--border);border-radius:12px;background:var(--surface-2)}
|
||||
.proj-sync-head{display:flex;align-items:baseline;gap:10px;margin-bottom:12px;flex-wrap:wrap}
|
||||
.proj-sync-head b{display:flex;align-items:center;gap:7px;font-size:13.5px}
|
||||
.proj-sync-head b svg{width:15px;height:15px;color:var(--blue)}
|
||||
.proj-sync-head small{color:var(--muted);font-size:12px}
|
||||
.proj-sync-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.proj-sync-mode{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--muted)}
|
||||
.proj-sync-mode select{height:32px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface);color:var(--text);font:inherit;font-size:12.5px}
|
||||
.proj-ignored{margin-top:12px}
|
||||
.proj-ignored>small{display:block;font-size:11.5px;color:var(--muted);margin-bottom:7px}
|
||||
.proj-ignored-list{display:flex;flex-wrap:wrap;gap:7px}
|
||||
.proj-ignored-item{display:inline-flex;align-items:center;gap:6px;padding:4px 8px 4px 11px;border-radius:999px;font-size:12px;border:1px solid var(--border);background:var(--surface);color:var(--muted)}
|
||||
.proj-ignored-item button{display:grid;place-items:center;width:16px;height:16px;border:0;border-radius:50%;background:transparent;color:var(--muted);cursor:pointer;padding:0}
|
||||
.proj-ignored-item button:hover{color:var(--red);background:rgba(240,84,84,.12)}
|
||||
.proj-ignored-item button svg{width:11px;height:11px}
|
||||
/* ============ 项目页统计折叠 ============ */
|
||||
.stats-head-bar{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
|
||||
.stats-head-bar small{color:var(--muted);font-size:12px;letter-spacing:.4px}
|
||||
@@ -1721,6 +1742,18 @@ html[data-theme=light] .lp-log-line{color:#1f2937}
|
||||
.cp-main b{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.cp-main small{color:var(--muted);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.cloud-pending-item .btn{height:30px;padding:0 11px;font-size:12px}
|
||||
/* 项目详情页:项目专属排除规则 */
|
||||
.proj-rules-panel h2{display:flex;align-items:center;gap:8px}
|
||||
.proj-rules-panel h2 small{color:var(--muted);font-weight:400;font-size:12px}
|
||||
.proj-rules-add{display:flex;gap:9px;margin:4px 0 12px}
|
||||
.proj-rules-add input{flex:1;max-width:420px;height:34px;padding:0 12px;border-radius:8px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);font:inherit;font-size:12.5px}
|
||||
.proj-rules-add input:focus{outline:none;border-color:var(--primary)}
|
||||
.proj-rules-chips{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.proj-rule-chip{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:999px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);font:inherit;font-size:12.5px;cursor:pointer;transition:border-color .15s,color .15s}
|
||||
.proj-rule-chip svg{width:12px;height:12px;color:var(--muted)}
|
||||
.proj-rule-chip:hover{border-color:var(--red);color:var(--red)}
|
||||
.proj-rule-chip:hover svg{color:var(--red)}
|
||||
.proj-rules-empty{color:var(--muted);font-size:12.5px;padding:4px 0}
|
||||
|
||||
/* ============ 日历页头紧凑化:单行标题 + 32px 按钮 ============ */
|
||||
.calendar-page .calendar-head{padding:9px 16px;margin-bottom:14px}
|
||||
|
||||
@@ -25,6 +25,8 @@ export const useAppStore = defineStore('app', {
|
||||
avatarSrc: '',
|
||||
loginOpen: false,
|
||||
paletteOpen: false,
|
||||
// 云端项目认领弹窗(CloudClaimModal 全局挂载,工作台横幅/个人主页可手动打开)
|
||||
cloudClaimOpen: false,
|
||||
// 日历页"每日心语"入口:设为 'YYYY-MM-DD' 时 DailyCard 打开对应日期的卡片
|
||||
dailyCardDate: '',
|
||||
// 节日名 → { mode: 'photo'|'art', image: dataURL }(管理员上传,随同步分发)
|
||||
|
||||
@@ -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