11"use client" ;
22
3- import * as React from "react" ;
43import * as Popover from "@radix-ui/react-popover" ;
54import { AlertCircle , Check , HelpCircle , Loader2 , Maximize2 , Plus , Server , X , Menu , ChevronUp , ChevronDown } from "lucide-react" ;
65import { Badge } from "@subboost/ui/components/ui/badge" ;
76import { Button } from "@subboost/ui/components/ui/button" ;
87import { Input } from "@subboost/ui/components/ui/input" ;
98import { Textarea } from "@subboost/ui/components/ui/textarea" ;
10- import { toast } from "@subboost/ui/components/ui/toaster" ;
11- import { normalizeSubscriptionImportErrorInfo } from "@subboost/core/subscription/import-error" ;
12- import type { ParsedNode } from "@subboost/core/types/node" ;
13- import { DEFAULT_NODE_NAME_TEMPLATE , formatNodeNameFromTemplate } from "@subboost/core/node-name-template" ;
149import { cn } from "@subboost/ui/lib/utils" ;
15- import { getNodeSourceIds , useConfigStore , type SubscriptionSource , type SourceType } from "@subboost/ui/store/config-store" ;
16- import { useUserStore } from "@subboost/ui/store/user-store" ;
10+ import type { SourceType } from "@subboost/ui/store/config-store" ;
1711import { getSubscriptionUserInfoDisplay } from "@subboost/ui/product/subscription/subscription-userinfo-display" ;
18- import { markSourceAsPendingImport } from "@subboost/ui/product/subscription/source-import-state" ;
19- import { moveSubscriptionSource } from "@subboost/ui/product/subscription/source-order" ;
2012import { buildSourceDisplayLabel } from "@subboost/ui/product/converter/source-display-label" ;
21- import {
22- useProductInteractionAdapter ,
23- type ProductInteractionResult ,
24- } from "@subboost/ui/product/interactions" ;
13+ import { useSubscriptionSourcesController } from "@subboost/ui/product/converter/use-subscription-sources-controller" ;
2514import { sourceTypeInfo } from "../constants" ;
2615import { SectionHeader } from "../section-header" ;
2716import { SubscriptionImportErrorBadge } from "@subboost/ui/product/converter/subscription-import-error" ;
@@ -34,243 +23,25 @@ export function InputSection({
3423 isExpanded : boolean ;
3524 onToggle : ( ) => void ;
3625} ) {
37- const { nodes, parseErrors, sources, setSources, parseSingleSource } = useConfigStore ( ) ;
38- const { user } = useUserStore ( ) ;
39- const interactions = useProductInteractionAdapter ( ) ;
40-
41- const maxSourcesPerType = React . useMemo ( ( ) => {
42- if ( user ?. isAdmin ) return Number . POSITIVE_INFINITY ;
43- if ( ! user ) return 2 ;
44- const raw = user . quota ?. maxImportSourcesPerType ;
45- return typeof raw === "number" && Number . isFinite ( raw ) && raw >= 0 ? Math . floor ( raw ) : 5 ;
46- } , [ user ] ) ;
47-
26+ const {
27+ addSource,
28+ closeExpandedSourceEditor,
29+ error : globalError ,
30+ expandedSource,
31+ expandedSourcePreviewName,
32+ handleImportSource,
33+ moveSource,
34+ nodesBySourceId,
35+ removeSource,
36+ setExpandedSourceId,
37+ setShowAddMenu,
38+ showAddMenu,
39+ sources,
40+ updateSource,
41+ updateSourceMeta,
42+ updateSourceType,
43+ } = useSubscriptionSourcesController ( { mode : "advanced" } ) ;
4844 const sourceCount = sources . length ;
49- const globalError = React . useMemo (
50- ( ) => normalizeSubscriptionImportErrorInfo ( parseErrors [ 0 ] ?? null ) ?. message ?? null ,
51- [ parseErrors ]
52- ) ;
53- const nodesBySourceId = React . useMemo ( ( ) => {
54- const grouped = new Map < string , ParsedNode [ ] > ( ) ;
55- for ( const node of nodes ) {
56- for ( const sourceId of getNodeSourceIds ( node ) ) {
57- const current = grouped . get ( sourceId ) ;
58- if ( current ) {
59- current . push ( node ) ;
60- } else {
61- grouped . set ( sourceId , [ node ] ) ;
62- }
63- }
64- }
65- return grouped ;
66- } , [ nodes ] ) ;
67-
68- const [ showAddMenu , setShowAddMenu ] = React . useState ( false ) ;
69- const [ expandedSourceId , setExpandedSourceId ] = React . useState < string | null > ( null ) ;
70- const expandedSource = React . useMemo (
71- ( ) => sources . find ( ( s ) => s . id === expandedSourceId ) ?? null ,
72- [ expandedSourceId , sources ]
73- ) ;
74- const [ expandedSourceSnapshot , setExpandedSourceSnapshot ] = React . useState < {
75- id : string ;
76- content : string ;
77- tag : string ;
78- nameTemplate : string ;
79- useProxyProviders : boolean ;
80- userinfoUrl : string ;
81- userinfoUserAgent : string ;
82- } | null > ( null ) ;
83-
84- React . useEffect ( ( ) => {
85- if ( ! expandedSource ) {
86- setExpandedSourceSnapshot ( null ) ;
87- return ;
88- }
89- setExpandedSourceSnapshot ( ( prev ) => {
90- if ( prev ?. id === expandedSource . id ) return prev ;
91- return {
92- id : expandedSource . id ,
93- content : expandedSource . content ,
94- tag : ( expandedSource . tag ?? "" ) . trim ( ) ,
95- nameTemplate : ( expandedSource . nameTemplate ?? "" ) . trim ( ) ,
96- useProxyProviders : Boolean ( expandedSource . useProxyProviders ) ,
97- userinfoUrl : ( expandedSource . userinfoUrl ?? "" ) . trim ( ) ,
98- userinfoUserAgent : ( expandedSource . userinfoUserAgent ?? "" ) . trim ( ) ,
99- } ;
100- } ) ;
101- } , [ expandedSource ] ) ;
102-
103- const expandedSourcePreviewName = React . useMemo ( ( ) => {
104- if ( ! expandedSource ) return "" ;
105- return formatNodeNameFromTemplate ( {
106- originName : "节点名称" ,
107- tag : expandedSource . tag ,
108- template : expandedSource . nameTemplate ,
109- } ) ;
110- } , [ expandedSource ] ) ;
111-
112- const closeExpandedSourceEditor = React . useCallback ( ( ) => {
113- if ( expandedSource && expandedSourceSnapshot ?. id === expandedSource . id && ! expandedSource . parsing ) {
114- const next = {
115- content : expandedSource . content ,
116- tag : ( expandedSource . tag ?? "" ) . trim ( ) ,
117- nameTemplate : ( expandedSource . nameTemplate ?? "" ) . trim ( ) ,
118- useProxyProviders : Boolean ( expandedSource . useProxyProviders ) ,
119- userinfoUrl : ( expandedSource . userinfoUrl ?? "" ) . trim ( ) ,
120- userinfoUserAgent : ( expandedSource . userinfoUserAgent ?? "" ) . trim ( ) ,
121- } ;
122- const changed =
123- next . content !== expandedSourceSnapshot . content ||
124- next . tag !== expandedSourceSnapshot . tag ||
125- next . nameTemplate !== expandedSourceSnapshot . nameTemplate ||
126- next . useProxyProviders !== expandedSourceSnapshot . useProxyProviders ||
127- next . userinfoUrl !== expandedSourceSnapshot . userinfoUrl ||
128- next . userinfoUserAgent !== expandedSourceSnapshot . userinfoUserAgent ;
129- if ( changed ) void parseSingleSource ( expandedSource . id ) ;
130- }
131- setExpandedSourceId ( null ) ;
132- } , [ expandedSource , expandedSourceSnapshot , parseSingleSource ] ) ;
133-
134- // Initialize with one source if empty
135- React . useEffect ( ( ) => {
136- if ( sources . length === 0 ) {
137- setSources ( [ { id : "1" , type : "url" , content : "" , nameTemplate : DEFAULT_NODE_NAME_TEMPLATE } ] ) ;
138- }
139- } , [ sources . length , setSources ] ) ;
140-
141- const addSource = ( type : SourceType = "url" ) => {
142- const used = sources . filter ( ( s ) => s . type === type ) . length ;
143- if ( used >= maxSourcesPerType ) {
144- toast ( {
145- title : user
146- ? `每种导入方式最多 ${ maxSourcesPerType } 个`
147- : "未登录用户每种导入方式最多 2 个(登录后可提升)" ,
148- variant : "warning" ,
149- } ) ;
150- setShowAddMenu ( false ) ;
151- return ;
152- }
153- const newSource : SubscriptionSource = {
154- id : Date . now ( ) . toString ( ) ,
155- type,
156- content : "" ,
157- nameTemplate : DEFAULT_NODE_NAME_TEMPLATE ,
158- } ;
159- setSources ( [ ...sources , newSource ] ) ;
160- interactions . sourceAdded ?.( {
161- mode : "advanced" ,
162- sourceType : type ,
163- sourceCount : sources . length + 1 ,
164- } ) ;
165- setShowAddMenu ( false ) ;
166- } ;
167-
168- const removeSource = ( id : string ) => {
169- if ( sources . length > 1 ) {
170- setSources ( sources . filter ( ( s ) => s . id !== id ) ) ;
171- }
172- } ;
173-
174- const updateSource = ( id : string , content : string ) => {
175- setSources (
176- sources . map ( ( s ) => {
177- if ( s . id !== id ) return s ;
178- if ( s . content === content ) return s ;
179- return markSourceAsPendingImport ( { ...s , content } ) ;
180- } )
181- ) ;
182- } ;
183-
184- const updateSourceMeta = ( id : string , patch : Partial < SubscriptionSource > ) => {
185- setSources (
186- sources . map ( ( s ) => {
187- if ( s . id !== id ) return s ;
188-
189- const next = { ...s , ...patch } ;
190- const changed = ( Object . keys ( patch ) as Array < keyof SubscriptionSource > ) . some ( ( key ) => s [ key ] !== next [ key ] ) ;
191- if ( ! changed ) return s ;
192-
193- const needsReimport =
194- Object . prototype . hasOwnProperty . call ( patch , "tag" ) ||
195- Object . prototype . hasOwnProperty . call ( patch , "nameTemplate" ) ||
196- Object . prototype . hasOwnProperty . call ( patch , "useProxyProviders" ) ||
197- Object . prototype . hasOwnProperty . call ( patch , "userinfoUrl" ) ||
198- Object . prototype . hasOwnProperty . call ( patch , "userinfoUserAgent" ) ;
199-
200- return needsReimport ? markSourceAsPendingImport ( next ) : next ;
201- } )
202- ) ;
203- } ;
204-
205- const moveSource = React . useCallback (
206- ( sourceId : string , direction : "up" | "down" ) => {
207- const nextSources = moveSubscriptionSource ( sources , sourceId , direction ) ;
208- if ( nextSources === sources ) return ;
209- setSources ( nextSources ) ;
210- } ,
211- [ setSources , sources ]
212- ) ;
213-
214- const updateSourceType = ( id : string , type : SourceType ) => {
215- const current = sources . find ( ( s ) => s . id === id ) ;
216- if ( ! current ) return ;
217- if ( current . type === type ) return ;
218-
219- const used = sources . filter ( ( s ) => s . type === type ) . length ;
220- if ( used >= maxSourcesPerType ) {
221- toast ( {
222- title : user
223- ? `每种导入方式最多 ${ maxSourcesPerType } 个`
224- : "未登录用户每种导入方式最多 2 个(登录后可提升)" ,
225- variant : "warning" ,
226- } ) ;
227- return ;
228- }
229-
230- setSources (
231- sources . map ( ( s ) => {
232- if ( s . id !== id ) return s ;
233-
234- return markSourceAsPendingImport ( {
235- ...s ,
236- type,
237- content : "" ,
238- useProxyProviders : type === "url" ? Boolean ( s . useProxyProviders ) : undefined ,
239- userinfoUrl : type === "url" ? s . userinfoUrl : undefined ,
240- userinfoUserAgent : type === "url" ? s . userinfoUserAgent : undefined ,
241- lastParsedContent : undefined ,
242- lastParsedTag : undefined ,
243- lastParsedNameTemplate : undefined ,
244- } ) ;
245- } )
246- ) ;
247- } ;
248-
249- const handleImportSource = React . useCallback (
250- async ( sourceId : string ) => {
251- const source = sources . find ( ( item ) => item . id === sourceId ) ;
252- if ( ! source || ! source . content . trim ( ) || source . parsing ) return ;
253-
254- await parseSingleSource ( sourceId ) ;
255-
256- const latestState = useConfigStore . getState ( ) ;
257- const latestSource = latestState . sources . find ( ( item ) => item . id === sourceId ) ?? source ;
258- const result : ProductInteractionResult = latestSource . parsed
259- ? "success"
260- : latestSource . error || latestSource . errorInfo
261- ? "runtimeError"
262- : "validationError" ;
263- interactions . sourceImported ?.( {
264- mode : "advanced" ,
265- sourceType : latestSource . type ,
266- result,
267- sourceCount : latestState . sources . filter ( ( item ) => item . content . trim ( ) ) . length ,
268- nodeCount : latestSource . nodeCount ?? 0 ,
269- usesProxyProvider : Boolean ( latestSource . useProxyProviders ) ,
270- } ) ;
271- } ,
272- [ interactions , parseSingleSource , sources ] ,
273- ) ;
27445
27546 return (
27647 < div >
0 commit comments