mirror of https://github.com/langgenius/dify.git
fix(trigger): show subscription url & add readme in trigger plugin node
This commit is contained in:
parent
bc3421add8
commit
db2c6678e4
|
|
@ -3,48 +3,11 @@
|
|||
* Extracted from the main markdown renderer for modularity.
|
||||
* Uses the ImageGallery component to display images.
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React from 'react'
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
import { getMarkdownImageURL } from './utils'
|
||||
import { usePluginReadmeAsset } from '@/service/use-plugins'
|
||||
import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper'
|
||||
|
||||
type ImgProps = {
|
||||
src: string
|
||||
pluginInfo?: SimplePluginInfo
|
||||
}
|
||||
|
||||
const Img: React.FC<ImgProps> = ({ src, pluginInfo }) => {
|
||||
const { plugin_unique_identifier, plugin_id } = pluginInfo || {}
|
||||
const { data: assetData } = usePluginReadmeAsset({ plugin_unique_identifier, file_name: src })
|
||||
const [blobUrl, setBlobUrl] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetData) {
|
||||
setBlobUrl(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(assetData)
|
||||
setBlobUrl(objectUrl)
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}, [assetData])
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (blobUrl)
|
||||
return blobUrl
|
||||
|
||||
return getMarkdownImageURL(src, plugin_id)
|
||||
}, [blobUrl, plugin_id, src])
|
||||
|
||||
return (
|
||||
<div className="markdown-img-wrapper">
|
||||
<ImageGallery srcs={[imageUrl]} />
|
||||
</div>
|
||||
)
|
||||
const Img = ({ src }: any) => {
|
||||
return <div className="markdown-img-wrapper"><ImageGallery srcs={[src]} /></div>
|
||||
}
|
||||
|
||||
export default Img
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@
|
|||
|
||||
export { default as AudioBlock } from './audio-block'
|
||||
export { default as CodeBlock } from './code-block'
|
||||
export * from './plugin-img'
|
||||
export * from './plugin-paragraph'
|
||||
export { default as Img } from './img'
|
||||
export { default as Link } from './link'
|
||||
export { default as Paragraph } from './paragraph'
|
||||
export { default as Link } from './link'
|
||||
export { default as PreCode } from './pre-code'
|
||||
export { default as ScriptBlock } from './script-block'
|
||||
export { default as VideoBlock } from './video-block'
|
||||
|
|
|
|||
|
|
@ -3,69 +3,25 @@
|
|||
* Extracted from the main markdown renderer for modularity.
|
||||
* Handles special rendering for paragraphs that directly contain an image.
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React from 'react'
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
import { getMarkdownImageURL } from './utils'
|
||||
import { usePluginReadmeAsset } from '@/service/use-plugins'
|
||||
import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper'
|
||||
|
||||
type ParagraphProps = {
|
||||
pluginInfo?: SimplePluginInfo
|
||||
node?: any
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const Paragraph: React.FC<ParagraphProps> = ({ pluginInfo, node, children }) => {
|
||||
const { plugin_unique_identifier, plugin_id } = pluginInfo || {}
|
||||
const childrenNode = node?.children as Array<any> | undefined
|
||||
const firstChild = childrenNode?.[0]
|
||||
const isImageParagraph = firstChild?.tagName === 'img'
|
||||
const imageSrc = isImageParagraph ? firstChild?.properties?.src : undefined
|
||||
|
||||
const { data: assetData } = usePluginReadmeAsset({
|
||||
plugin_unique_identifier,
|
||||
file_name: isImageParagraph && imageSrc ? imageSrc : '',
|
||||
})
|
||||
|
||||
const [blobUrl, setBlobUrl] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetData) {
|
||||
setBlobUrl(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(assetData)
|
||||
setBlobUrl(objectUrl)
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}, [assetData])
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (blobUrl)
|
||||
return blobUrl
|
||||
|
||||
if (isImageParagraph && imageSrc)
|
||||
return getMarkdownImageURL(imageSrc, plugin_id)
|
||||
|
||||
return ''
|
||||
}, [blobUrl, imageSrc, isImageParagraph, plugin_id])
|
||||
|
||||
if (isImageParagraph) {
|
||||
const remainingChildren = Array.isArray(children) && children.length > 1 ? children.slice(1) : undefined
|
||||
|
||||
const Paragraph = (paragraph: any) => {
|
||||
const { node }: any = paragraph
|
||||
const children_node = node.children
|
||||
if (children_node && children_node[0] && 'tagName' in children_node[0] && children_node[0].tagName === 'img') {
|
||||
return (
|
||||
<div className="markdown-img-wrapper">
|
||||
<ImageGallery srcs={[imageUrl]} />
|
||||
{remainingChildren && (
|
||||
<div className="mt-2">{remainingChildren}</div>
|
||||
)}
|
||||
<ImageGallery srcs={[children_node[0].properties.src]} />
|
||||
{
|
||||
Array.isArray(paragraph.children) && paragraph.children.length > 1 && (
|
||||
<div className="mt-2">{paragraph.children.slice(1)}</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <p>{children}</p>
|
||||
return <p>{paragraph.children}</p>
|
||||
}
|
||||
|
||||
export default Paragraph
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* @fileoverview Img component for rendering <img> tags in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Uses the ImageGallery component to display images.
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
import { getMarkdownImageURL } from './utils'
|
||||
import { usePluginReadmeAsset } from '@/service/use-plugins'
|
||||
import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper'
|
||||
|
||||
type ImgProps = {
|
||||
src: string
|
||||
pluginInfo?: SimplePluginInfo
|
||||
}
|
||||
|
||||
export const PluginImg: React.FC<ImgProps> = ({ src, pluginInfo }) => {
|
||||
const { pluginUniqueIdentifier, pluginId } = pluginInfo || {}
|
||||
const { data: assetData } = usePluginReadmeAsset({ plugin_unique_identifier: pluginUniqueIdentifier, file_name: src })
|
||||
const [blobUrl, setBlobUrl] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetData) {
|
||||
setBlobUrl(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(assetData)
|
||||
setBlobUrl(objectUrl)
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}, [assetData])
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (blobUrl)
|
||||
return blobUrl
|
||||
|
||||
return getMarkdownImageURL(src, pluginId)
|
||||
}, [blobUrl, pluginId, src])
|
||||
|
||||
return (
|
||||
<div className="markdown-img-wrapper">
|
||||
<ImageGallery srcs={[imageUrl]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* @fileoverview Paragraph component for rendering <p> tags in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Handles special rendering for paragraphs that directly contain an image.
|
||||
*/
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
import { usePluginReadmeAsset } from '@/service/use-plugins'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper'
|
||||
import { getMarkdownImageURL } from './utils'
|
||||
|
||||
type PluginParagraphProps = {
|
||||
pluginInfo?: SimplePluginInfo
|
||||
node?: any
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const PluginParagraph: React.FC<PluginParagraphProps> = ({ pluginInfo, node, children }) => {
|
||||
const { pluginUniqueIdentifier, pluginId } = pluginInfo || {}
|
||||
const childrenNode = node?.children as Array<any> | undefined
|
||||
const firstChild = childrenNode?.[0]
|
||||
const isImageParagraph = firstChild?.tagName === 'img'
|
||||
const imageSrc = isImageParagraph ? firstChild?.properties?.src : undefined
|
||||
|
||||
const { data: assetData } = usePluginReadmeAsset({
|
||||
plugin_unique_identifier: pluginUniqueIdentifier,
|
||||
file_name: isImageParagraph && imageSrc ? imageSrc : '',
|
||||
})
|
||||
|
||||
const [blobUrl, setBlobUrl] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetData) {
|
||||
setBlobUrl(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(assetData)
|
||||
setBlobUrl(objectUrl)
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}, [assetData])
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (blobUrl)
|
||||
return blobUrl
|
||||
|
||||
if (isImageParagraph && imageSrc)
|
||||
return getMarkdownImageURL(imageSrc, pluginId)
|
||||
|
||||
return ''
|
||||
}, [blobUrl, imageSrc, isImageParagraph, pluginId])
|
||||
|
||||
if (isImageParagraph) {
|
||||
const remainingChildren = Array.isArray(children) && children.length > 1 ? children.slice(1) : undefined
|
||||
|
||||
return (
|
||||
<div className="markdown-img-wrapper">
|
||||
<ImageGallery srcs={[imageUrl]} />
|
||||
{remainingChildren && (
|
||||
<div className="mt-2">{remainingChildren}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <p>{children}</p>
|
||||
}
|
||||
|
|
@ -1,30 +1,20 @@
|
|||
import ReactMarkdown from 'react-markdown'
|
||||
import RemarkMath from 'remark-math'
|
||||
import RemarkBreaks from 'remark-breaks'
|
||||
import RehypeKatex from 'rehype-katex'
|
||||
import RemarkGfm from 'remark-gfm'
|
||||
import RehypeRaw from 'rehype-raw'
|
||||
import { AudioBlock, Img, Link, MarkdownButton, MarkdownForm, Paragraph, PluginImg, PluginParagraph, ScriptBlock, ThinkBlock, VideoBlock } from '@/app/components/base/markdown-blocks'
|
||||
import { ENABLE_SINGLE_DOLLAR_LATEX } from '@/config'
|
||||
import AudioBlock from '@/app/components/base/markdown-blocks/audio-block'
|
||||
import Img from '@/app/components/base/markdown-blocks/img'
|
||||
import Link from '@/app/components/base/markdown-blocks/link'
|
||||
import MarkdownButton from '@/app/components/base/markdown-blocks/button'
|
||||
import MarkdownForm from '@/app/components/base/markdown-blocks/form'
|
||||
import Paragraph from '@/app/components/base/markdown-blocks/paragraph'
|
||||
import ScriptBlock from '@/app/components/base/markdown-blocks/script-block'
|
||||
import ThinkBlock from '@/app/components/base/markdown-blocks/think-block'
|
||||
import VideoBlock from '@/app/components/base/markdown-blocks/video-block'
|
||||
import { customUrlTransform } from './markdown-utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { FC } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import RehypeKatex from 'rehype-katex'
|
||||
import RehypeRaw from 'rehype-raw'
|
||||
import RemarkBreaks from 'remark-breaks'
|
||||
import RemarkGfm from 'remark-gfm'
|
||||
import RemarkMath from 'remark-math'
|
||||
import { customUrlTransform } from './markdown-utils'
|
||||
|
||||
const CodeBlock = dynamic(() => import('@/app/components/base/markdown-blocks/code-block'), { ssr: false })
|
||||
|
||||
export type SimplePluginInfo = {
|
||||
pluginUniqueIdentifier: string
|
||||
plugin_id: string
|
||||
pluginId: string
|
||||
}
|
||||
|
||||
export type ReactMarkdownWrapperProps = {
|
||||
|
|
@ -70,11 +60,11 @@ export const ReactMarkdownWrapper: FC<ReactMarkdownWrapperProps> = (props) => {
|
|||
disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]}
|
||||
components={{
|
||||
code: CodeBlock,
|
||||
img: (props: any) => <Img {...props} pluginInfo={pluginInfo} />,
|
||||
img: (props: any) => pluginInfo ? <PluginImg {...props} pluginInfo={pluginInfo} /> : <Img {...props} />,
|
||||
video: VideoBlock,
|
||||
audio: AudioBlock,
|
||||
a: Link,
|
||||
p: (props: any) => <Paragraph {...props} pluginInfo={pluginInfo} />,
|
||||
p: (props: any) => pluginInfo ? <PluginParagraph {...props} pluginInfo={pluginInfo} /> : <Paragraph {...props} />,
|
||||
button: MarkdownButton,
|
||||
form: MarkdownForm,
|
||||
script: ScriptBlock as any,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
|
|
@ -48,9 +49,19 @@ const SubscriptionCard = ({ data }: Props) => {
|
|||
</div>
|
||||
|
||||
<div className='mt-1 flex items-center justify-between'>
|
||||
<div className='system-xs-regular flex-1 truncate text-text-tertiary'>
|
||||
{data.endpoint}
|
||||
</div>
|
||||
<Tooltip
|
||||
disabled={!data.endpoint}
|
||||
popupContent={data.endpoint && (
|
||||
<div className='max-w-[320px] break-all'>
|
||||
{data.endpoint}
|
||||
</div>
|
||||
)}
|
||||
position='left'
|
||||
>
|
||||
<div className='system-xs-regular flex-1 truncate text-text-tertiary'>
|
||||
{data.endpoint}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="mx-2 text-xs text-text-tertiary opacity-30">·</div>
|
||||
<div className='system-xs-regular shrink-0 text-text-tertiary'>
|
||||
{data.workflows_in_use > 0 ? t('pluginTrigger.subscription.list.item.usedByNum', { num: data.workflows_in_use }) : t('pluginTrigger.subscription.list.item.noUsed')}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@ import ActionButton from '@/app/components/base/action-button'
|
|||
import Loading from '@/app/components/base/loading'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Drawer from '@/app/components/base/drawer'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { usePluginReadme } from '@/service/use-plugins'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiBookReadLine, RiCloseLine } from '@remixicon/react'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DetailHeader from '../plugin-detail-panel/detail-header'
|
||||
import { ReadmeShowType, useReadmePanelStore } from './store'
|
||||
|
|
@ -34,7 +33,7 @@ const ReadmePanel: FC = () => {
|
|||
|
||||
const children = (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="bg-background-body px-4 py-4">
|
||||
<div className="rounded-t-xl bg-background-body px-4 py-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<RiBookReadLine className="h-3 w-3 text-text-tertiary" />
|
||||
|
|
@ -71,7 +70,7 @@ const ReadmePanel: FC = () => {
|
|||
return (
|
||||
<Markdown
|
||||
content={readmeData.readme}
|
||||
pluginInfo={{ plugin_unique_identifier: pluginUniqueIdentifier, plugin_id: detail.plugin_id }}
|
||||
pluginInfo={{ pluginUniqueIdentifier, pluginId: detail.plugin_id }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -86,38 +85,29 @@ const ReadmePanel: FC = () => {
|
|||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
showType === ReadmeShowType.drawer ? (
|
||||
<Drawer
|
||||
isOpen={!!detail}
|
||||
onClose={onClose}
|
||||
footer={null}
|
||||
positionCenter={false}
|
||||
showClose={false}
|
||||
panelClassName={cn(
|
||||
'!pointer-events-auto mb-2 ml-2 mt-16 !w-[600px] !max-w-[600px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl',
|
||||
'!z-[9999]',
|
||||
return showType === ReadmeShowType.drawer ? createPortal(
|
||||
<div className='pointer-events-none fixed inset-0 z-[9997] flex justify-start'>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-auto mb-2 ml-2 mr-2 mt-16 w-[600px] max-w-[600px] justify-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl',
|
||||
)}
|
||||
dialogClassName={cn('!pointer-events-none')}
|
||||
containerClassName='!justify-start'
|
||||
noOverlay
|
||||
clickOutsideNotOpen={true}
|
||||
>
|
||||
{children}
|
||||
</Drawer>
|
||||
) : (
|
||||
<Modal
|
||||
isShow={!!detail}
|
||||
onClose={onClose}
|
||||
overlayOpacity={true}
|
||||
className='h-[calc(100vh-16px)] max-w-[800px] p-0'
|
||||
wrapperClassName='!z-[102]'
|
||||
containerClassName='p-2'
|
||||
clickOutsideNotClose={true}
|
||||
>
|
||||
{children}
|
||||
</Modal>
|
||||
)
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
) : (
|
||||
<Modal
|
||||
isShow={!!detail}
|
||||
onClose={onClose}
|
||||
overlayOpacity={true}
|
||||
className='h-[calc(100vh-16px)] max-w-[800px] p-0'
|
||||
wrapperClassName='!z-[102]'
|
||||
containerClassName='p-2'
|
||||
clickOutsideNotClose={true}
|
||||
>
|
||||
{children}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -342,6 +342,25 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||
)
|
||||
}, [handleNodeDataUpdateWithSyncDraft, id])
|
||||
|
||||
const readmeEntranceComponent = useMemo(() => {
|
||||
let pluginDetail
|
||||
switch (data.type) {
|
||||
case BlockEnum.Tool:
|
||||
pluginDetail = currToolCollection
|
||||
break
|
||||
case BlockEnum.DataSource:
|
||||
pluginDetail = currentDataSource
|
||||
break
|
||||
case BlockEnum.TriggerPlugin:
|
||||
pluginDetail = currentTriggerProvider
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
return !pluginDetail ? null : <ReadmeEntrance pluginDetail={pluginDetail as any} className='mt-auto' />
|
||||
}, [data.type, currToolCollection, currentDataSource, currentTriggerProvider])
|
||||
|
||||
if (logParams.showSpecialResultPanel) {
|
||||
return (
|
||||
<div className={cn(
|
||||
|
|
@ -564,7 +583,7 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||
<Split />
|
||||
</div>
|
||||
{tabType === TabType.settings && (
|
||||
<div className='flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-1 flex-col overflow-y-auto'>
|
||||
<div>
|
||||
{cloneElement(children as any, {
|
||||
id,
|
||||
|
|
@ -609,6 +628,7 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||
</div>
|
||||
)
|
||||
}
|
||||
{readmeEntranceComponent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -628,8 +648,6 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{data.type === BlockEnum.Tool && <ReadmeEntrance pluginDetail={currToolCollection as any} className='mt-auto' />}
|
||||
{data.type === BlockEnum.DataSource && <ReadmeEntrance pluginDetail={currentDataSource as any} className='mt-auto' />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import React from 'react'
|
|||
import useSWR, { useSWRConfig } from 'swr'
|
||||
import { createApp, fetchAppDetail, fetchAppList, getAppDailyConversations, getAppDailyEndUsers, updateAppApiStatus, updateAppModelConfig, updateAppRateLimit, updateAppSiteAccessToken, updateAppSiteConfig, updateAppSiteStatus } from '../apps'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const Service: FC = () => {
|
||||
const { data: appList, error: appListError } = useSWR({ url: '/apps', params: { page: 1 } }, fetchAppList)
|
||||
const { data: firstApp, error: appDetailError } = useSWR({ url: '/apps', id: '1' }, fetchAppDetail)
|
||||
|
|
@ -21,7 +23,7 @@ const Service: FC = () => {
|
|||
const handleCreateApp = async () => {
|
||||
await createApp({
|
||||
name: `new app${Math.round(Math.random() * 100)}`,
|
||||
mode: 'chat',
|
||||
mode: AppModeEnum.CHAT,
|
||||
})
|
||||
// reload app list
|
||||
mutate({ url: '/apps', params: { page: 1 } })
|
||||
|
|
|
|||
Loading…
Reference in New Issue