diff --git a/src/components/dialog/PluginMarketSettingDialog.vue b/src/components/dialog/PluginMarketSettingDialog.vue index b170bee8..51a0e02a 100644 --- a/src/components/dialog/PluginMarketSettingDialog.vue +++ b/src/components/dialog/PluginMarketSettingDialog.vue @@ -10,27 +10,121 @@ const display = useDisplay() const { t } = useI18n() const $toast = useToast() +type EditorMode = 'list' | 'text' + +interface RepoParseResult { + repos: string[] + invalidRepos: string[] + duplicateRepos: string[] +} + +const editorMode = ref('list') const repoList = ref([]) +const repoText = ref('') const newRepoUrl = ref('') const editingIndex = ref(null) const editingUrl = ref('') const emit = defineEmits(['save', 'close']) +const parsedTextRepos = computed(() => parseRepoInput(repoText.value)) +const activeRepoCount = computed(() => (editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length)) +const saveDisabled = computed( + () => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0), +) + +/** 判断仓库地址是否为可保存的 HTTP URL。 */ +function isValidRepoUrl(url: string) { + return /^https?:\/\//i.test(url) +} + +/** 将粘贴的仓库地址文本解析为有效、无效和重复地址列表。 */ +function parseRepoInput(value: string): RepoParseResult { + const repos: string[] = [] + const invalidRepos: string[] = [] + const duplicateRepos: string[] = [] + const seenRepos = new Set() + + value + .split(/[\n,,]+/) + .map(repo => repo.trim()) + .filter(Boolean) + .forEach(repo => { + if (!isValidRepoUrl(repo)) { + invalidRepos.push(repo) + + return + } + + if (seenRepos.has(repo)) { + duplicateRepos.push(repo) + + return + } + + seenRepos.add(repo) + repos.push(repo) + }) + + return { + repos, + invalidRepos, + duplicateRepos: [...new Set(duplicateRepos)], + } +} + +/** 将列表模式中的仓库地址同步到文本模式。 */ +function syncTextFromList() { + repoText.value = repoList.value.join('\n') +} + +/** 将文本模式中的仓库地址同步到列表模式,并忽略无法加入列表的无效地址。 */ +function syncListFromText() { + const result = parseRepoInput(repoText.value) + + repoList.value = result.repos + syncTextFromList() + + if (result.invalidRepos.length > 0) { + $toast.warning(t('dialog.pluginMarketSetting.invalidTextIgnored', { count: result.invalidRepos.length })) + } +} + +/** 切换仓库维护模式,并在切换时同步当前模式的编辑内容。 */ +function switchEditorMode(mode: EditorMode | undefined) { + if (!mode || mode === editorMode.value) return + + if (editorMode.value === 'text') { + syncListFromText() + } + + if (mode === 'text') { + syncTextFromList() + } + + editorMode.value = mode +} + +/** 加载插件市场仓库配置。 */ async function queryMarketRepoSetting() { try { const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET') if (result && result.data && result.data.value) { - repoList.value = result.data.value.split(',').filter((repo: string) => repo.trim() !== '') + repoList.value = parseRepoInput(result.data.value).repos + syncTextFromList() } } catch (error) { console.log(error) } } +/** 保存插件市场仓库配置。 */ async function saveHandle() { try { - const repoStringToSave = repoList.value.join(',') + const reposToSave = normalizeCurrentRepos() + if (!reposToSave) return + + const repoStringToSave = reposToSave.join(',') const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave) if (result.success) { @@ -42,54 +136,88 @@ async function saveHandle() { } } +/** 获取当前维护模式下可保存的仓库地址。 */ +function normalizeCurrentRepos() { + if (editorMode.value === 'text') { + const result = parseRepoInput(repoText.value) + + if (result.invalidRepos.length > 0) { + $toast.error(t('dialog.pluginMarketSetting.invalidText', { count: result.invalidRepos.length })) + + return null + } + + repoList.value = result.repos + syncTextFromList() + + return result.repos + } + + return repoList.value +} + +/** 校验单个仓库地址是否可以加入或更新到列表。 */ +function validateRepoUrl(url: string, editingRepoIndex: number | null = null) { + if (!url) return false + + if (!isValidRepoUrl(url)) { + $toast.error(t('dialog.pluginMarketSetting.invalidUrl')) + + return false + } + + const duplicated = repoList.value.some((repo, index) => repo === url && index !== editingRepoIndex) + if (duplicated) { + $toast.error(t('dialog.pluginMarketSetting.duplicateUrl')) + + return false + } + + return true +} + +/** 添加一个仓库地址到列表。 */ function addRepo() { const url = newRepoUrl.value.trim() - if (!url) return - - if (!url.startsWith('http://') && !url.startsWith('https://')) { - $toast.error(t('dialog.pluginMarketSetting.invalidUrl')) - return - } - - if (repoList.value.includes(url)) { - $toast.error(t('dialog.pluginMarketSetting.duplicateUrl')) - return - } + if (!validateRepoUrl(url)) return repoList.value.push(url) newRepoUrl.value = '' + syncTextFromList() } +/** 从列表中删除一个仓库地址。 */ function removeRepo(index: number) { repoList.value.splice(index, 1) + syncTextFromList() } +/** 进入指定仓库地址的行内编辑状态。 */ function startEdit(index: number) { editingIndex.value = index editingUrl.value = repoList.value[index] } +/** 保存当前行内编辑的仓库地址。 */ function saveEdit() { if (editingIndex.value === null) return const url = editingUrl.value.trim() - if (!url) return - - if (!url.startsWith('http://') && !url.startsWith('https://')) { - $toast.error(t('dialog.pluginMarketSetting.invalidUrl')) - return - } + if (!validateRepoUrl(url, editingIndex.value)) return repoList.value[editingIndex.value] = url + syncTextFromList() editingIndex.value = null editingUrl.value = '' } +/** 取消当前行内编辑状态。 */ function cancelEdit() { editingIndex.value = null editingUrl.value = '' } +/** 将仓库地址格式化为更易扫描的显示名称。 */ function formatRepoDisplay(url: string) { try { const parsedUrl = new URL(url) @@ -108,6 +236,16 @@ function formatRepoDisplay(url: string) { return url } +/** 获取仓库地址的主机名用于辅助显示。 */ +function formatRepoHost(url: string) { + try { + return new URL(url).hostname + } catch { + return '' + } +} + +/** 返回拖拽列表项的稳定键。 */ function repoItemKey(repo: string) { return repo } @@ -118,108 +256,239 @@ onMounted(() => {