码桶

发现社区成员的开源项目

nodes.ts8.5 KB
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { PublicNodesEnvelope } from '@/api/public'
import { useSettingsStore } from '@/stores/settings'

export interface NodeItem {
  title: string
  url: string
  status: boolean
  group?: string
  method?: CustomNodeMethod
  postData?: string
  headers?: string
  httpProtocol?: CustomNodeHttpProtocol
  enhanced?: boolean
}

export interface NodeGroup {
  [groupName: string]: NodeItem[]
}

export interface CustomNode {
  label: string
  value: string
  time: string
  group?: string
  sharedBy?: {
    uin: string
    avatar: string
    nickname: string
  }
  method?: CustomNodeMethod
  postData?: string
  httpProtocol?: CustomNodeHttpProtocol
  enhanced?: boolean
}

export type CustomNodeMethod = 'GET' | 'POST' | 'HEAD'
export type CustomNodeHttpProtocol = 'AUTO' | 'HTTP_1_1' | 'HTTP_2'

type RawNode = PublicNodesEnvelope['list'][string][string]

function loadCustomNodes(): CustomNode[] {
  const stored = JSON.parse(localStorage.getItem('customNodes') || '[]') as Array<CustomNode & { testMode?: unknown; headers?: unknown }>
  return stored.map(({ testMode: _legacyDirection, headers: _legacyHeaders, ...node }) => node)
}

export const useNodesStore = defineStore('nodes', () => {
  const rawNodes = ref<PublicNodesEnvelope['list']>({})
  const customNodes = ref<CustomNode[]>(loadCustomNodes())
  const customNodeGroups = ref<string[]>(normalizeCustomGroups(JSON.parse(localStorage.getItem('customNodeGroups') || '[]'), customNodes.value))
  const lastNodeSwitchReason = ref('')
  let fetchPromise: Promise<void> | null = null

  const groupedNodes = computed<NodeGroup>(() => {
    const grouped: NodeGroup = {}
    const customGroups: Record<string, Record<string, RawNode>> = {}
    const customUrls = new Set(customNodes.value.map(node => node.value))

    if (customNodes.value.length > 0) {
      customNodes.value.forEach(node => {
        const groupName = `自定义 / ${normalizeCustomGroup(node.group)}`
        if (!customGroups[groupName]) customGroups[groupName] = {}
        customGroups[groupName][node.label] = {
          link: node.value,
          status: true,
          group: normalizeCustomGroup(node.group),
          method: node.method || 'GET',
          postData: node.postData || '',
          httpProtocol: node.httpProtocol || 'AUTO',
          enhanced: Boolean(node.enhanced),
        } as RawNode
      })
    }

    const merged = { ...customGroups, ...rawNodes.value }

    for (const group in merged) {
      const isCustomGroup = group === '自定义' || group.startsWith('自定义 /')
      grouped[group] = []
      for (const item in merged[group]) {
        const node = merged[group][item]
        if (!isCustomGroup && customUrls.has(node.link)) continue
        if (!isCustomGroup && node.webVisible === false) continue
        if ((node as any).testMode === 'UPLOAD') continue
        grouped[group].push({
          title: item,
          url: node.link,
          status: node.status,
          group: (node as any).group || '',
          method: (node as any).method || 'GET',
          postData: (node as any).postData || '',
          headers: (node as any).headers || '',
          httpProtocol: (node as any).httpProtocol || 'AUTO',
          enhanced: Boolean((node as any).enhanced),
        })
      }
    }

    return grouped
  })

  function saveCustomNodes() {
    localStorage.setItem('customNodes', JSON.stringify(customNodes.value))
  }

  function saveCustomNodeGroups() {
    localStorage.setItem('customNodeGroups', JSON.stringify(customNodeGroups.value))
  }

  function normalizeCustomGroup(group?: string) {
    const text = (group || '').trim().replace(/\s+/g, ' ')
    return text || '默认'
  }

  function normalizeCustomGroups(groups: unknown, nodes: CustomNode[] = []) {
    const next = new Set<string>(['默认'])
    if (Array.isArray(groups)) {
      groups.forEach(group => next.add(normalizeCustomGroup(String(group || ''))))
    }
    nodes.forEach(node => next.add(normalizeCustomGroup(node.group)))
    return Array.from(next).sort((a, b) => (a === '默认' ? -1 : b === '默认' ? 1 : a.localeCompare(b, 'zh-CN')))
  }

  function syncCustomNodeGroups() {
    customNodeGroups.value = normalizeCustomGroups(customNodeGroups.value, customNodes.value)
    saveCustomNodeGroups()
  }

  function addCustomNodeGroup(group: string) {
    const nextGroup = normalizeCustomGroup(group)
    if (customNodeGroups.value.includes(nextGroup)) return false
    customNodeGroups.value = normalizeCustomGroups([...customNodeGroups.value, nextGroup], customNodes.value)
    saveCustomNodeGroups()
    return true
  }

  function removeCustomNodeGroup(group: string) {
    const nextGroup = normalizeCustomGroup(group)
    if (nextGroup === '默认') return false
    customNodeGroups.value = customNodeGroups.value.filter(item => item !== nextGroup)
    customNodes.value = customNodes.value.map(node => (
      normalizeCustomGroup(node.group) === nextGroup ? { ...node, group: '默认' } : node
    ))
    saveCustomNodeGroups()
    saveCustomNodes()
    return true
  }

  function addCustomNode(label: string, value: string, method: CustomNodeMethod = 'GET', postData = '', group = '默认') {
    const now = new Date()
    const time = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()} ${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}`
    customNodes.value.push({
      label,
      value,
      time,
      group: normalizeCustomGroup(group),
      method,
      postData: method === 'POST' ? postData : '',
      httpProtocol: 'AUTO',
      enhanced: false,
    })
    syncCustomNodeGroups()
    saveCustomNodes()
  }

  function addCustomNodes(nodes: CustomNode[]) {
    if (nodes.length === 0) return
    customNodes.value.push(...nodes.map(node => ({
      ...node,
      group: normalizeCustomGroup(node.group),
      httpProtocol: node.httpProtocol || 'AUTO',
      enhanced: Boolean(node.enhanced),
    })))
    syncCustomNodeGroups()
    saveCustomNodes()
  }

  function updateCustomNode(original: CustomNode, next: Omit<CustomNode, 'time'>) {
    const index = customNodes.value.findIndex(
      n => n.label === original.label && n.value === original.value && n.time === original.time
    )
    if (index === -1) return false
    customNodes.value[index] = {
      ...customNodes.value[index],
      ...next,
      group: normalizeCustomGroup(next.group),
      method: next.method || 'GET',
      postData: next.method === 'POST' ? next.postData || '' : '',
    }
    syncCustomNodeGroups()
    saveCustomNodes()
    return true
  }

  function removeCustomNode(node: CustomNode) {
    customNodes.value = customNodes.value.filter(
      n => n.label !== node.label || n.value !== node.value
    )
    saveCustomNodes()
  }

  function getCustomNodeByUrl(url: string) {
    return customNodes.value.find(node => node.value === url) || null
  }

  async function fetchNodes() {
    if (!fetchPromise) {
      fetchPromise = (async () => {
        try {
          const response = await fetch(`${import.meta.env.BASE_URL}nodes.json`, { cache: 'no-store' })
          if (!response.ok) throw new Error(`nodes.json ${response.status}`)
          const nodesData = await response.json() as PublicNodesEnvelope
          rawNodes.value = nodesData.list
          selectDefaultNode()
        } catch {
          console.error('Failed to fetch local nodes.json')
        }
      })()
    }
    return fetchPromise
  }

  function waitForNodes() {
    return fetchPromise ?? Promise.resolve()
  }

  function selectDefaultNode() {
    const settings = useSettingsStore()
    const availableGroups = Object.entries(groupedNodes.value)
      .filter(([, nodes]) => nodes.some(node => node.status !== false))
      .map(([name]) => name)
    if (!availableGroups.includes(settings.groupValue)) {
      settings.groupValue = availableGroups[0] || ''
    }
    const groups = Object.values(groupedNodes.value)
    const flat = groups.flat()
    const current = flat.find(node => node.url === settings.urlValue && node.status !== false)
    if (current) return

    const first = flat.find(node => node.status !== false)

    const unavailable = flat.find(node => node.url === settings.urlValue && node.status === false)
    if (unavailable) {
      lastNodeSwitchReason.value = `节点「${unavailable.title}」当前不可用`
    }

    settings.urlValue = first ? first.url : ''
  }

  return { rawNodes, customNodes, customNodeGroups, lastNodeSwitchReason, groupedNodes, addCustomNodeGroup, removeCustomNodeGroup, addCustomNode, addCustomNodes, updateCustomNode, removeCustomNode, getCustomNodeByUrl, fetchNodes, selectDefaultNode, waitForNodes }
})