Files
NotesAgentic/frontend/src/features/community/CommunityView.vue
T

168 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import type { CommunityKey, CommunityRelease, CommunitySource, PackageKind } from '@/contracts/community'
import { cachedCatalog, discoverSource, fetchCatalog, installRelease, loadSources, saveSources } from '@/services/communityService'
import { isDesktop } from '@/services/platform/desktop'
import { reviewTrust, confirmTrust, type TrustReview } from '@/services/extensionTrustService'
import DesktopPackages from './DesktopPackages.vue'
import AppDialog from '@/components/common/AppDialog.vue'
const kinds: { id: PackageKind | ''; label: string }[] = [
{ id: '', label: '全部' }, { id: 'theme', label: '主题' }, { id: 'skill', label: 'Skill' },
{ id: 'plugin', label: 'Plugin' }, { id: 'mcp', label: 'MCP 配置' }, { id: 'persona', label: '人设' },
{ id: 'template', label: '笔记模板' }, { id: 'model', label: '模型方案' },
]
const stagedRefresh = ref(0)
const sources = ref(loadSources()), selectedSource = ref(sources.value[0]?.id ?? '')
const url = ref(''), query = ref(''), kind = ref<PackageKind | ''>('')
const items = ref<CommunityRelease[]>([]), detail = ref<CommunityRelease | null>(null)
const candidateKeys = ref<CommunityKey[]>([]), candidateUrl = ref('')
const candidateSourceId = ref(''), candidateEnabled = ref(true), trustReviews = ref<TrustReview[]>([])
const busy = ref(false), error = ref(''), notice = ref(''), offline = ref(false)
let controller: AbortController | undefined
let version = 0
const candidates = ref<{ key: string; value: string }[]>([])
function refreshCandidates() {
candidates.value = Object.keys(localStorage).filter(key => key.startsWith('community-candidate:')).map(key => ({ key, value: localStorage.getItem(key) ?? '' }))
}
refreshCandidates()
function removeCandidate(key: string) { localStorage.removeItem(key); refreshCandidates() }
function cancel() { version++; controller?.abort(); busy.value = false }
onBeforeUnmount(cancel)
function source(): CommunitySource {
const value = sources.value.find(item => item.id === selectedSource.value)
if (!value) throw new Error('请先添加并选择一个来源')
return value
}
async function run(action: (signal: AbortSignal, current: () => boolean) => Promise<void>) {
cancel(); const request = ++version
controller = new AbortController(); busy.value = true; error.value = ''; notice.value = ''
try { await action(controller.signal, () => request === version) }
catch (reason) { if (request === version) error.value = reason instanceof Error ? reason.message : String(reason) }
finally { if (request === version) busy.value = false }
}
function inspectSource() {
const snapshot = url.value.trim()
void run(async (signal, current) => {
const discovered = await discoverSource(snapshot, signal)
const reviews = isDesktop() ? await reviewTrust(snapshot, discovered.source_id, discovered.keys, true) : []
if (current()) { candidateKeys.value = discovered.keys; candidateSourceId.value = discovered.source_id; candidateEnabled.value = true; trustReviews.value = reviews; candidateUrl.value = snapshot }
})
}
function trustSource() {
const snapshot = { url: candidateUrl.value, keys: candidateKeys.value, sourceId: candidateSourceId.value, enabled: candidateEnabled.value, reviews: trustReviews.value }
void run(async (_signal, current) => {
if (isDesktop()) await confirmTrust(snapshot.reviews)
const existing = sources.value.find(item => item.url === snapshot.url)
const value: CommunitySource = { id: existing?.id ?? crypto.randomUUID(), source_id: snapshot.sourceId, url: snapshot.url, enabled: snapshot.enabled, keys: snapshot.keys, fetchedAt: new Date().toISOString() }
sources.value = [...sources.value.filter(item => item.id !== value.id), value]
saveSources(sources.value)
if (current()) { selectedSource.value = value.id; candidateUrl.value = ''; candidateKeys.value = []; trustReviews.value = []; notice.value = snapshot.enabled ? '来源公钥已固定。可以搜索目录。' : '来源已停用。'; items.value = [] }
})
}
function search() {
void run(async (signal, current) => {
const selected = source()
try {
const result = await fetchCatalog(selected, query.value, kind.value, signal)
if (current()) { items.value = result.items; offline.value = false; notice.value = result.total > result.items.length ? `展示前 ${result.items.length} 项,请缩小搜索范围。` : '' }
} catch (reason) {
const cached = cachedCatalog(selected)
if (current() && cached) { items.value = cached.items; offline.value = true }
throw reason
}
})
}
function install() {
if (!detail.value) return
const selected = detail.value, selectedRegistry = source()
void run(async (signal, current) => {
const result = await installRelease(selectedRegistry, selected, signal)
if (current() || (signal.aborted && controller?.signal === signal)) { notice.value = result; refreshCandidates(); stagedRefresh.value++ }
})
}
function toggleSource() {
const selected = source()
if (!isDesktop()) { selected.enabled = !selected.enabled; saveSources(sources.value); items.value = []; cancel(); return }
if (!selected.source_id) { error.value = '请先重新检查此来源并确认公钥,将旧来源设置迁入桌面信任库。'; return }
void run(async (_signal, current) => {
const reviews = await reviewTrust(selected.url, selected.source_id!, selected.keys, !selected.enabled)
if (current()) { candidateKeys.value = selected.keys; candidateSourceId.value = selected.source_id!; candidateEnabled.value = !selected.enabled; trustReviews.value = reviews; candidateUrl.value = selected.url }
})
}
</script>
<template>
<main class="community-page feature-page">
<header class="feature-header">
<div><h1>社区目录</h1><p>连接您选择的来源安装后仍需独立启用和授权关闭社区不影响本地编辑</p></div>
</header>
<section class="community-panel panel" aria-label="社区来源与搜索">
<div class="community-controls">
<label>来源地址 <input v-model="url" placeholder="https://community.example.org" :disabled="busy" /></label>
<button class="btn" :disabled="busy || !url.trim()" @click="inspectSource">检查来源与公钥</button>
<label>已添加来源 <select v-model="selectedSource" @change="search"><option value="">请选择</option><option v-for="item in sources" :key="item.id" :value="item.id">{{ item.url }}{{ item.enabled ? '' : '已停用' }}</option></select></label>
<button class="btn" :disabled="!selectedSource || busy" @click="toggleSource">启用 / 停用来源</button>
</div>
<div class="community-controls">
<label>关键词 <input v-model="query" @keydown.enter="search" /></label>
<label>类别 <select v-model="kind"><option v-for="item in kinds" :key="item.id" :value="item.id">{{ item.label }}</option></select></label>
<button class="btn btn-primary" :disabled="busy || !selectedSource" @click="search">搜索 / 刷新</button>
<button v-if="busy" class="btn" @click="cancel">取消</button>
</div>
<p v-if="busy" role="status">正在处理</p>
<p v-if="error" role="alert">{{ error }}</p>
<p v-if="notice" role="status">{{ notice }}</p>
<p v-if="offline">当前为离线缓存仅供浏览安装需要重新核对撤回和签名状态</p>
<p v-if="!busy && !items.length">尚无发行记录添加来源后搜索目录</p>
<div class="community-grid">
<button v-for="item in items" :key="item.release_id" class="community-card" @click="detail = item">
<strong>{{ item.name }}</strong><span>{{ item.type }} · {{ item.version }}</span>
<span>{{ item.description }}</span><span>{{ item.namespace }}/{{ item.package_id }} · {{ item.license }}</span>
<span v-if="item.withdrawn">已撤回</span>
</button>
</div>
</section>
<DesktopPackages v-if="isDesktop()" :refresh-key="stagedRefresh" />
<section v-if="candidates.length">
<h2>已保存的声明式候选</h2><p>这些候选尚未应用到人设MCP 或模型运行配置</p>
<details v-for="item in candidates" :key="item.key"><summary>{{ item.key.replace('community-candidate:', '') }}</summary><pre>{{ item.value }}</pre><button class="btn" @click="removeCandidate(item.key)">删除候选</button></details>
</section>
<AppDialog v-if="candidateUrl" label="核对来源公钥" @close="candidateUrl = ''; trustReviews = []">
<p v-if="error" role="alert">{{ error }}</p><p>{{ candidateUrl }}</p><p>请与来源维护者公布的公钥核对确认后固定这些公钥密钥改变时不会自动信任</p>
<p>来源标识{{ candidateSourceId }} · {{ candidateEnabled ? '启用' : '停用' }}</p>
<pre>{{ JSON.stringify(candidateKeys, null, 2) }}</pre>
<div v-for="review in trustReviews" :key="review.review_id">
<p>{{ review.proposed.namespace }} / {{ review.proposed.key_id }}{{ review.previous ? '更新已有信任设置' : '首次确认' }}</p>
<details v-if="review.previous"><summary>原有公钥与状态</summary><pre>{{ JSON.stringify(review.previous, null, 2) }}</pre></details>
<p>确认摘要{{ review.fingerprint }}</p>
</div>
<p v-if="trustReviews.length">确认在两分钟内有效过期或设置已改变时请关闭对话框并重新检查来源</p>
<button class="btn btn-primary" :disabled="busy || !candidateKeys.length" @click="trustSource">确认来源设置</button>
</AppDialog>
<AppDialog v-if="detail" label="发行详情与安装" @close="detail = null">
<template v-if="detail">
<h2>{{ detail.name }} {{ detail.version }}</h2><p>{{ detail.description }}</p>
<dl><dt>作者 / 来源</dt><dd>{{ detail.author_id }} / {{ detail.namespace }}</dd><dt>许可证</dt><dd>{{ detail.license }}</dd><dt>大小 / 摘要</dt><dd>{{ detail.size }} 字节<br />{{ detail.sha256 }}</dd><dt>兼容平台</dt><dd>{{ detail.platforms.join(', ') }} / {{ detail.architectures.join(', ') }}</dd><dt>权限</dt><dd>{{ detail.permissions.join(', ') || '无' }}</dd><dt>依赖</dt><dd>{{ JSON.stringify(detail.dependencies) }}</dd></dl>
<pre>{{ detail.changelog }}</pre>
<p>安装不会自动启用包或其依赖人设模板MCP 与模型方案仅保存为可检查的候选</p>
<button class="btn btn-primary" :disabled="busy || detail.withdrawn || offline" @click="install">{{ isDesktop() ? '校验并暂存' : '校验并安装' }}</button>
</template>
</AppDialog>
</main>
</template>
<style scoped>
.community-page { padding: var(--space-xl); overflow: auto; min-width: 0; }
.feature-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-xl); }
.feature-header h1, .feature-header p { margin: 0; }
.feature-header p { margin-top: var(--space-xs); color: var(--color-text-secondary); }
.community-panel { padding: var(--space-xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-surface-primary); }
.community-controls { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-md); margin-block: var(--space-lg); }
label { display: grid; gap: var(--space-xs); }
input, select { color: var(--color-text-primary); background: var(--color-background-secondary); border: 1px solid var(--color-border-subtle); padding: var(--space-sm); }
.community-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-md); }
.community-card { display: grid; gap: var(--space-sm); text-align: left; padding: var(--space-lg); color: var(--color-text-primary); background: var(--color-background-secondary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); overflow-wrap: anywhere; }
pre, dd { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 100%; }
</style>