Browse Source

1、增加分类

2、增加video模块
main
dark 4 months ago
parent
commit
73526f11a6
  1. 2
      src/components/switch/index.tsx
  2. 96
      src/components/tag-pro/TagPro.tsx
  3. 0
      src/components/tag-pro/index.ts
  4. 2
      src/locales/lang/pages/cms/video/zh-CN.ts
  5. 2
      src/locales/lang/pages/cms/videoCloud/zh-CN.ts
  6. 2
      src/locales/lang/pages/cms/videoMagnet/zh-CN.ts
  7. 53
      src/locales/lang/pages/videos/list/zh-CN.ts
  8. 4
      src/locales/lang/zh-CN.ts
  9. 67
      src/pages/cms/category/components/CategoryTree.tsx
  10. 59
      src/pages/cms/category/components/TreeNodeRender.tsx
  11. 191
      src/pages/cms/category/index.tsx
  12. 81
      src/pages/cms/category/style.ts
  13. 197
      src/pages/cms/video/index.tsx
  14. 67
      src/pages/videos/category/components/CategoryTree.tsx
  15. 59
      src/pages/videos/category/components/TreeNodeRender.tsx
  16. 191
      src/pages/videos/category/index.tsx
  17. 81
      src/pages/videos/category/style.ts
  18. 391
      src/pages/videos/list/index.tsx
  19. 2
      src/service/base.ts
  20. 3
      src/service/cms.ts
  21. 13
      src/service/videos.ts
  22. 108
      src/store/cms/category.ts
  23. 107
      src/store/videos/category.ts
  24. 97
      src/store/videos/video.ts
  25. 17
      src/types/cms/category.d.ts
  26. 32
      src/types/index.d.ts
  27. 17
      src/types/videos/category.d.ts
  28. 23
      src/types/videos/video.d.ts

2
src/components/switch/index.tsx

@ -3,7 +3,7 @@ import { Switch as AntSwitch, SwitchProps } from 'antd'
export const Switch = ({ value, ...props }: Omit<SwitchProps, 'value'> & {
value: any
value?: any
}) => {
return (
<AntSwitch {...props} value={convertToBool(value)}/>

96
src/components/tag-pro/TagPro.tsx

@ -0,0 +1,96 @@
import { Tag, Input, Flex, Spin } from 'antd'
import { useEffect, useState } from 'react'
export interface TagProProps {
tags?: any[],
onChange?: (value: any) => void
value?: any,
loading?: boolean,
[key: string]: any
}
function getSelectedByValue(value: string, tags?: any[]) {
if (!value || tags?.length === 0) return []
const arr = String(value)?.split(',')?.filter(Boolean)
const newArr: string[] = []
arr.forEach(item => {
if (tags?.includes(item)) {
newArr.push(item)
}
})
return newArr
}
const TagPro = ({ tags = [], loading, value, onChange, ...props }: TagProProps) => {
const [ innerValue, setValue ] = useState(() => value)
const [ selectedTags, setSelectedTags ] = useState(() => {
if (value) {
return getSelectedByValue(value as string, tags)
}
return []
})
useEffect(() => {
setValue(value)
setSelectedTags(getSelectedByValue(value as string, tags))
}, [ value, JSON.stringify(tags) ])
const handleChange = (checked: boolean, tag: string) => {
let nextTags: string[] = []
if (checked) {
nextTags = [ ...selectedTags, tag ]
} else {
nextTags = selectedTags.filter((t) => t !== tag)
}
const arr = (innerValue as string)?.split(',')
//arr中可能有不在tags中的值,不能丢弃
const newArr = arr?.filter((item) => {
return !tags?.includes(item)
}) || []
const val = [ ...newArr, ...nextTags ].filter(Boolean)
//去重
onChange?.([ ...new Set(val) ].join(','))
setSelectedTags(nextTags)
}
return (
<Spin spinning={loading} size={'small'}>
<Flex flex={1} gap={3}>
<Flex flex={2}>
<Input.TextArea
{...props}
value={innerValue}
onChange={e => {
setValue(e.target.value)
onChange?.(e.target.value)
}}/></Flex>
<Flex flex={1} wrap="wrap" align="flex-start">
{
tags?.map(item => {
return <Tag.CheckableTag
key={item}
checked={selectedTags.includes(item)}
onChange={(checked) => {
handleChange(checked, item)
}}
>
{item}
</Tag.CheckableTag>
})
}
</Flex>
</Flex>
</Spin>
)
}
export default TagPro

0
src/components/tag-pro/index.ts

2
src/locales/lang/pages/cms/video/zh-CN.ts

@ -17,7 +17,7 @@ export default {
collect_id: '站点id',
type_id: '类型',
title: '标题',
title_sub: '副标',
title_sub: '又名',
letter: '首字母',
tag: 'TAG',
lock: '锁定后显示',

2
src/locales/lang/pages/cms/videoCloud/zh-CN.ts

@ -16,7 +16,7 @@ export default {
collect_id: '站点id',
type_id: '类型',
title: '标题',
title_sub: '副标',
title_sub: '又名',
letter: '首字母',
tag: 'TAG',
lock: '锁定后显示',

2
src/locales/lang/pages/cms/videoMagnet/zh-CN.ts

@ -16,7 +16,7 @@ export default {
collect_id: '站点id',
type_id: '类型',
title: '标题',
title_sub: '副标',
title_sub: '又名',
letter: '首字母',
tag: 'TAG',
lock: '锁定后显示',

53
src/locales/lang/pages/videos/list/zh-CN.ts

@ -0,0 +1,53 @@
export default {
title: '视频管理',
description: '视频管理',
search: '搜索',
add: '新增',
edit: '编辑',
delete: '删除',
type_id: [
'','电影', '剧集', '动漫'
],
lock: [
'未锁', '锁定'
],
columns: {
id: 'ID',
source_url: '源站点地址',
collect_id: '站点id',
type_id: '类型',
title: '标题',
title_sub: '又名',
letter: '首字母',
tag: 'TAG',
lock: '锁定后显示',
copyright: '版权',
is_end: '完结',
status: '状态',
category_id: '分类',
pic: '图片',
pic_local: '图片本地路径或MD5',
pic_status: '图片状态',
actor: '演员',
director: '导演',
writer: '编剧',
remarks: '备注',
pubdate: '发布时间',
total: '总集数',
serial: '连载数',
duration: '视频时长',
area: '地区',
lang: '语言',
version: '资源版本',
year: '年份',
state: '资源类别',
douban_score: '豆瓣评分',
douban_id: '豆瓣ID',
imdb_score: 'imdb评分',
imdb_id: 'imdb的id',
content: '内容',
created_at: '创建时间',
updated_at: '更新时间'
}
}

4
src/locales/lang/zh-CN.ts

@ -6,6 +6,7 @@ import collect from './pages/cms/collect/zh-CN.ts'
import video from './pages/cms/video/zh-CN.ts'
import videoCloud from './pages/cms/videoCloud/zh-CN.ts'
import videoMagnet from './pages/cms/videoMagnet/zh-CN.ts'
import list from './pages/videos/list/zh-CN.ts'
export default {
...antdZh,
@ -53,6 +54,9 @@ export default {
cms: {
collect, video, videoCloud, videoMagnet,
},
videos:{
list,
},
actions: {
news: '新增',
add: '添加',

67
src/pages/cms/category/components/CategoryTree.tsx

@ -0,0 +1,67 @@
import { Empty, Spin, Tree } from 'antd'
import { useStyle } from '../style.ts'
import { useTranslation } from '@/i18n.ts'
import { useSetAtom } from 'jotai'
import { FormInstance } from 'antd/lib'
import { useAtomValue } from 'jotai'
import { TreeNodeRender } from './TreeNodeRender.tsx'
import { useEffect } from 'react'
import { categoriesAtom, categoryAtom } from '@/store/cms/category.ts'
import { Cms } from '@/types'
export const CategoryTree = ({ form }: { form: FormInstance }) => {
const { styles } = useStyle()
const { t } = useTranslation()
const setCurrent = useSetAtom(categoryAtom)
const { data, isLoading } = useAtomValue(categoriesAtom)
useEffect(() => {
return () => {
setCurrent({} as Cms.ICategory)
}
}, [])
const renderEmpty = () => {
if ((data?.rows ?? []).length > 0 || isLoading) return null
return <Empty description={t('message.emptyDataAdd', '暂无数据,点击添加')}/>
}
return (<>
<Spin spinning={isLoading} style={{ minHeight: 200 }}>
{
renderEmpty()
}
<Tree.DirectoryTree
className={styles.tree}
treeData={data?.rows ?? []}
defaultExpandAll={true}
// draggable={true}
titleRender={(node) => {
return (<TreeNodeRender node={node as any} form={form}/>)
}}
fieldNames={{
title: 'name',
key: 'id'
}}
onSelect={(item) => {
const current = data?.rows?.find((cate) => cate.id === item[0])
if (!current) return
const { extend, ...other } = current as Cms.ICategory
const cate = other as Cms.ICategory
if (extend) {
cate.extend = JSON.parse(extend)
}
setCurrent(cate)
form.setFieldsValue(cate)
}}
// checkable={true}
showIcon={false}
/>
</Spin>
</>
)
}
export default CategoryTree

59
src/pages/cms/category/components/TreeNodeRender.tsx

@ -0,0 +1,59 @@
import { memo } from 'react'
import { MenuItem } from '@/global'
import { Popconfirm, Space, TreeDataNode } from 'antd'
import { FormInstance } from 'antd/lib'
import { useTranslation } from '@/i18n.ts'
import { useStyle } from '../style.ts'
import { useAtomValue } from 'jotai'
import { DeleteAction } from '@/components/icon/action'
import { deleteCategoryAtom } from '@/store/cms/category.ts'
export const TreeNodeRender = memo(({ node }: {
node: MenuItem & TreeDataNode, form?: FormInstance
}) => {
const { name } = node
const { t } = useTranslation()
const { styles } = useStyle()
const { mutate } = useAtomValue(deleteCategoryAtom)
// const setCurrent = useSetAtom(categoryAtom)
return (
<div className={styles.treeNode}>
<span>{name as any}</span>
<span className={'actions'}>
<Space size={'middle'}>
{/*<ActionIcon
size={12}
icon={<PlusOutlined/>}
title={t('actions.add', '添加')}
onClick={(e) => {
// console.log('add')
e.stopPropagation()
e.preventDefault()
const data = {
id: 0,
parent_id: node.id,
}
setCurrent(data)
form.setFieldsValue(data)
}}/>*/}
<Popconfirm
title={t('message.deleteConfirm', '确定要删除吗?')}
onConfirm={() => {
mutate([ (node as any).id ])
}}
>
<DeleteAction
size={12}
onClick={(e) => {
e.stopPropagation()
e.stopPropagation()
}}/>
</Popconfirm>
</Space>
</span>
</div>
)
})

191
src/pages/cms/category/index.tsx

@ -0,0 +1,191 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from '@/i18n.ts'
import { useStyle } from './style.ts'
import {
Alert,
Button,
Divider,
Form,
Input,
InputNumber,
InputRef,
notification,
Flex
} from 'antd'
import { useAtom, useAtomValue } from 'jotai'
import TwoColPageLayout from '@/layout/TwoColPageLayout.tsx'
import { ProCard } from '@ant-design/pro-components'
import { PlusOutlined } from '@ant-design/icons'
import Glass from '@/components/glass'
import { categoryAtom, saveOrUpdateCategoryAtom } from '@/store/cms/category.ts'
import Switch from '@/components/switch'
import CategoryTree from './components/CategoryTree.tsx'
const Category = () => {
const { t } = useTranslation()
const { styles, cx } = useStyle()
const [ form ] = Form.useForm()
const inputRef = useRef<InputRef>()
const { mutate, isPending, isError, error } = useAtomValue(saveOrUpdateCategoryAtom)
const [ current, setCurrent ] = useAtom(categoryAtom)
useEffect(() => {
if (isError) {
notification.error({
message: t('message.error', '错误'),
description: (error as any).message ?? t('message.saveFail', '保存失败'),
})
}
}, [ isError ])
useEffect(() => {
if (current?.id === 0 && inputRef.current) {
inputRef.current.focus()
}
}, [ current ])
return (
<TwoColPageLayout
leftPanel={<>
<ProCard title={t('videos.category.title', '分类')}>
<CategoryTree form={form}/>
</ProCard>
<div className={styles.treeActions}>
<Divider style={{ flex: 1, margin: '8px 0' }}/>
<Button style={{ flex: 1 }} size={'small'}
block={true} type={'dashed'}
icon={<PlusOutlined/>}
onClick={() => {
const data = {
name: '',
extend: {
class: '',
area: '',
lang: '',
year: '',
tag: '',
state: '',
version: '',
},
sort: 0,
status: 1,
parent_id: 0,
id: 0,
}
setCurrent(data)
form.setFieldsValue(data)
}}
>{t('actions.news')}</Button>
</div>
</>}
>
<Glass
enabled={current?.id === undefined}
description={<>
<Alert
message={t('message.infoTitle', '提示')}
description={t('videos.category.form.empty', '请从左侧选择一行数据操作')}
type="info"
/>
</>}
>
<ProCard title={t('videos.category.setting', '编辑')}>
<Form form={form}
initialValues={current!}
labelAlign="right"
labelWrap
colon={false}
className={cx(styles.form, {
[styles.emptyForm]: current?.id === undefined
})}
>
<Form.Item hidden={true} label={'id'} name={'id'}>
<Input disabled={true}/>
</Form.Item>
<Form.Item
rules={[
{ required: true, message: t('rules.required') }
]}
label={t('videos.category.form.name', '名称')} name={'name'}>
<Input ref={inputRef as any}
placeholder={t('videos.category.form.name', '名称')}/>
</Form.Item>
<Form.Item label={t('videos.category.form.tag', 'Tag')}
name={[ 'extend', 'class' ]}
>
<Input/>
</Form.Item>
<Form.Item label={t('videos.category.form.area', '地区')}
name={[ 'extend', 'area' ]}
>
<Input/>
</Form.Item>
<Form.Item label={t('videos.category.form.area', '语言')}
name={[ 'extend', 'lang' ]}
>
<Input/>
</Form.Item>
<Form.Item label={t('videos.category.form.area', '年份')}
name={[ 'extend', 'year' ]}
>
<Input/>
</Form.Item>
<Form.Item label={t('videos.category.form.area', '资源')}
name={[ 'extend', 'state' ]}
>
<Input/>
</Form.Item>
<Form.Item label={t('videos.category.form.area', '版本')}
name={[ 'extend', 'version' ]}
>
<Input/>
</Form.Item>
<Flex flex={1}>
<Flex flex={1}>
<Form.Item label={t('videos.category.form.sort', '排序')} name={'sort'}>
<InputNumber/>
</Form.Item>
</Flex>
<Flex flex={1}>
<Form.Item label={t('videos.category.form.status', '状态')}
name={'status'}>
<Switch/>
</Form.Item>
</Flex>
</Flex>
<Form.Item label={' '}>
<Button type="primary"
htmlType={'submit'}
loading={isPending}
onClick={() => {
form.validateFields().then((values) => {
mutate({
...values,
status: values.status ? 1 : 0,
extend: JSON.stringify(values.extend),
})
})
}}
>
{t('videos.category.form.save', '保存')}
</Button>
</Form.Item>
</Form>
</ProCard>
</Glass>
</TwoColPageLayout>
)
}
export default Category

81
src/pages/cms/category/style.ts

@ -0,0 +1,81 @@
import { createStyles } from '@/theme'
export const useStyle = createStyles(({ token, css, cx, prefixCls }) => {
const prefix = `${prefixCls}-${token?.proPrefix}-category-page`
const tree = css`
.ant-tree {
overflow: auto;
height: 100%;
border-right: 1px solid ${token.colorBorder};
background: ${token.colorBgContainer};
}
.ant-tree-directory .ant-tree-treenode-selected::before {
background: ${token.colorBgTextHover};
}
.ant-tree-treenode:before {
border-radius: ${token.borderRadius}px;
}
`
const treeNode = css`
display: flex;
justify-content: space-between;
align-items: center;
.actions {
display: none;
padding: 0 10px;
}
&:hover .actions {
{
display: flex;
}
`
const treeActions = css`
padding: 0 24px 16px;
display: flex;
flex-direction: column;
position: sticky;
bottom: 0;
z-index: 10;
background: ${token.colorBgContainer};
`
const box = css`
flex: 1;
background: ${token.colorBgContainer};
`
const form = css`
//display: flex;
//flex-wrap: wrap;
min-width: 300px;
//max-width: 800px;
.ant-form-item-label{
width: 100px;
}
`
const emptyForm = css`
`
const authHeight = css`
min-height: calc(100vh - 122px);
background-color: ${token.colorBgContainer};
`
return {
container: cx(prefix),
authHeight,
box,
form,
emptyForm,
tree,
treeNode,
treeActions
}
})

197
src/pages/cms/video/index.tsx

@ -10,6 +10,9 @@ import Switch from '@/components/switch'
import Action from '@/components/action/Action.tsx'
import { BetaSchemaForm, ProColumns, ProFormColumnsType, ProTable } from '@ant-design/pro-components'
import ListPageLayout from '@/layout/ListPageLayout.tsx'
import TagPro from '@/components/tag-pro/TagPro.tsx'
import { useSetAtom } from 'jotai/index'
import { categoriesAtom, categoryByIdAtom, categoryIdAtom } from '@/store/cms/category.ts'
const i18nPrefix = 'cms.video'
@ -22,6 +25,9 @@ const Video = () => {
const [ search, setSearch ] = useAtom(videoSearchAtom)
const { data, isFetching, isLoading, refetch } = useAtomValue(videosAtom)
const { mutate: deleteVideo, isPending: isDeleting } = useAtomValue(deleteVideoAtom)
const setCategoryId = useSetAtom(categoryIdAtom)
const { data: categories, isLoading: isCateLoading } = useAtomValue(categoriesAtom)
const { data: category, isLoading: isCategoryFetching } = useAtomValue(categoryByIdAtom)
const [ open, setOpen ] = useState(false)
const columns = useMemo(() => {
@ -34,6 +40,15 @@ const Video = () => {
formItemProps: { hidden: true }
},
{
'title': t(`${i18nPrefix}.columns.collect_id`, 'CollectId'),
'dataIndex': 'collect_id',
hideInTable: true,
hideInSearch: true,
hideInSetting: true,
formItemProps: { hidden: true },
},
{
'title': t(`${i18nPrefix}.columns.title`, 'Title'),
'dataIndex': 'title',
onHeaderCell: () => {
@ -41,6 +56,9 @@ const Video = () => {
width: 200,
}
},
colProps: {
span: 8
}
},
{
'title': t(`${i18nPrefix}.columns.title_sub`, 'TitleSub'),
@ -50,25 +68,9 @@ const Video = () => {
width: 200,
}
},
},
{
'title': t(`${i18nPrefix}.columns.source_url`, 'SourceUrl'),
'dataIndex': 'source_url',
ellipsis: true,
copyable: true,
onHeaderCell: () => {
return {
width: 200,
}
},
},
{
'title': t(`${i18nPrefix}.columns.collect_id`, 'CollectId'),
'dataIndex': 'collect_id',
hideInTable: true,
hideInSearch: true,
hideInSetting: true,
formItemProps: { hidden: true },
colProps: {
span: 8
}
},
{
'title': t(`${i18nPrefix}.columns.type_id`, 'TypeId'),
@ -80,16 +82,16 @@ const Video = () => {
render: (_dom, record) => {
return t(`${i18nPrefix}.type_id.${record.type_id}`)
},
colProps: {
span: 8
}
},
{
'title': t(`${i18nPrefix}.columns.letter`, 'Letter'),
'dataIndex': 'letter'
},
{
'title': t(`${i18nPrefix}.columns.tag`, 'Tag'),
'dataIndex': 'tag',
valueType: 'textarea',
'title': t(`${i18nPrefix}.columns.source_url`, 'SourceUrl'),
'dataIndex': 'source_url',
ellipsis: true,
copyable: true,
onHeaderCell: () => {
return {
width: 200,
@ -97,12 +99,22 @@ const Video = () => {
},
},
{
'title': t(`${i18nPrefix}.columns.letter`, 'Letter'),
'dataIndex': 'letter',
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.lock`, 'Lock'),
'dataIndex': 'lock',
valueType: 'switch',
render: (_dom, record) => {
return <Switch value={record.lock} size={'small'}/>
},
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.copyright`, 'Copyright'),
@ -111,6 +123,9 @@ const Video = () => {
render: (_dom, record) => {
return <Switch value={record.lock} size={'small'}/>
},
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.is_end`, 'IsEnd'),
@ -119,6 +134,9 @@ const Video = () => {
render: (_dom, record) => {
return <Switch value={record.lock} size={'small'}/>
},
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.status`, 'Status'),
@ -127,21 +145,12 @@ const Video = () => {
render: (_dom, record) => {
return <Switch value={record.lock} size={'small'}/>
},
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.category_id`, 'CategoryId'),
'dataIndex': 'category_id',
},
{
'title': t(`${i18nPrefix}.columns.pic`, 'Pic'),
'dataIndex': 'pic',
render: (_dom, record) => {
return <Image src={record.pic} height={40}/>
},
},
{
'title': t(`${i18nPrefix}.columns.pic_local`, 'PicLocal'),
'dataIndex': 'pic_local',
hideInSearch: true,
@ -156,6 +165,32 @@ const Video = () => {
render: (_dom, record) => {
return <Switch value={record.lock} size={'small'}/>
},
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.pic`, 'Pic'),
'dataIndex': 'pic',
render: (_dom, record) => {
return <Image src={record.pic} height={40}/>
},
colProps: {
span: 20
}
},
{
'title': t(`${i18nPrefix}.columns.category_id`, 'CategoryId'),
'dataIndex': 'category_id',
valueType: 'select',
fieldProps: {
loading: isCategoryFetching,
options: categories?.rows ?? [],
fieldNames:{
label: 'name',
value: 'id'
}
},
},
{
'title': t(`${i18nPrefix}.columns.actor`, 'Actor'),
@ -183,20 +218,54 @@ const Video = () => {
{
'title': t(`${i18nPrefix}.columns.pubdate`, 'Pubdate'),
'dataIndex': 'pubdate',
valueType: 'dateTime'
valueType: 'dateTime',
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.total`, 'Total'),
'dataIndex': 'total',
valueType: 'digit'
valueType: 'digit',
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.serial`, 'Serial'),
'dataIndex': 'serial',
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.duration`, 'Duration'),
'dataIndex': 'duration',
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.year`, 'Year'),
'dataIndex': 'year',
valueType: 'dateYear',
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.tag`, 'Tag'),
'dataIndex': 'tag',
valueType: 'textarea',
ellipsis: true,
onHeaderCell: () => {
return {
width: 200,
}
},
renderFormItem: (schema, config) => {
return <TagPro loading={isCategoryFetching} tags={category?.extend?.class?.split(',') ?? []} {...config} {...schema.fieldProps} />
}
},
{
'title': t(`${i18nPrefix}.columns.area`, 'Area'),
@ -207,6 +276,9 @@ const Video = () => {
width: 200,
}
},
renderFormItem: (schema, config) => {
return <TagPro loading={isCategoryFetching} tags={category?.extend?.area?.split(',') ?? []} {...config} {...schema.fieldProps} />
}
},
{
'title': t(`${i18nPrefix}.columns.lang`, 'Lang'),
@ -217,23 +289,32 @@ const Video = () => {
width: 200,
}
},
renderFormItem: (schema, config) => {
return <TagPro loading={isCategoryFetching} tags={category?.extend?.lang?.split(',') ?? []} {...config} {...schema.fieldProps} />
}
},
{
'title': t(`${i18nPrefix}.columns.version`, 'Version'),
'dataIndex': 'version'
},
{
'title': t(`${i18nPrefix}.columns.year`, 'Year'),
'dataIndex': 'year',
valueType: 'dateYear'
'dataIndex': 'version',
renderFormItem: (schema, config) => {
return <TagPro loading={isCategoryFetching} tags={category?.extend?.version?.split(',') ?? []} {...config} {...schema.fieldProps} />
}
},
{
'title': t(`${i18nPrefix}.columns.state`, 'State'),
'dataIndex': 'state'
'dataIndex': 'state',
renderFormItem: (schema, config) => {
return <TagPro loading={isCategoryFetching} tags={category?.extend?.state?.split(',') ?? []} {...config} {...schema.fieldProps} />
}
},
{
'title': t(`${i18nPrefix}.columns.douban_score`, 'DoubanScore'),
'dataIndex': 'douban_score'
'dataIndex': 'douban_score',
hideInSearch: true,
hideInSetting: true,
formItemProps: { hidden: true },
hideInTable: true,
},
{
'title': t(`${i18nPrefix}.columns.douban_id`, 'DoubanId'),
@ -245,7 +326,11 @@ const Video = () => {
},
{
'title': t(`${i18nPrefix}.columns.imdb_score`, 'ImdbScore'),
'dataIndex': 'imdb_score'
'dataIndex': 'imdb_score',
hideInSearch: true,
hideInSetting: true,
formItemProps: { hidden: true },
hideInTable: true,
},
{
'title': t(`${i18nPrefix}.columns.imdb_id`, 'ImdbId'),
@ -274,6 +359,7 @@ const Video = () => {
<Action key="edit"
as={'a'}
onClick={() => {
setCategoryId(record.category_id)
form.setFieldsValue(record)
setOpen(true)
}}>{t('actions.edit')}</Action>,
@ -291,7 +377,7 @@ const Video = () => {
]
}
] as ProColumns[]
}, [ isDeleting ])
}, [ isDeleting, category, isCategoryFetching, categories, isCateLoading ])
useEffect(() => {
if (isSuccess) {
@ -353,18 +439,18 @@ const Video = () => {
}
})
},
}}
/>
<BetaSchemaForm
grid={true}
shouldUpdate={false}
width={600}
width={1000}
form={form}
layout={'vertical'}
scrollToFirstError={true}
title={t(`${i18nPrefix}.title_${form.getFieldValue('id') !== 0 ? 'edit' : 'add'}`, form.getFieldValue('id') !== 0 ? '视频编辑' : '视频添加')}
// colProps={{ span: 24 }}
labelCol={{ span: 6 }}
// labelCol={{ span: 6 }}
// wrapperCol={{ span: 14 }}
layoutType={'DrawerForm'}
open={open}
@ -375,6 +461,11 @@ const Video = () => {
setOpen(open)
}}
loading={isSubmitting}
onValuesChange={(values)=>{
if(values.category_id) {
setCategoryId(values.category_id)
}
}}
onFinish={async (values) => {
// console.log('values', values)
saveOrUpdate(values)

67
src/pages/videos/category/components/CategoryTree.tsx

@ -0,0 +1,67 @@
import { Empty, Spin, Tree } from 'antd'
import { useStyle } from '../style.ts'
import { useTranslation } from '@/i18n.ts'
import { useSetAtom } from 'jotai'
import { FormInstance } from 'antd/lib'
import { useAtomValue } from 'jotai'
import { TreeNodeRender } from './TreeNodeRender.tsx'
import { useEffect } from 'react'
import { categoriesAtom, categoryAtom } from '@/store/videos/category.ts'
import { Videos } from '@/types'
export const CategoryTree = ({ form }: { form: FormInstance }) => {
const { styles } = useStyle()
const { t } = useTranslation()
const setCurrent = useSetAtom(categoryAtom)
const { data, isLoading } = useAtomValue(categoriesAtom)
useEffect(() => {
return () => {
setCurrent({} as Videos.ICategory)
}
}, [])
const renderEmpty = () => {
if ((data?.rows ?? []).length > 0 || isLoading) return null
return <Empty description={t('message.emptyDataAdd', '暂无数据,点击添加')}/>
}
return (<>
<Spin spinning={isLoading} style={{ minHeight: 200 }}>
{
renderEmpty()
}
<Tree.DirectoryTree
className={styles.tree}
treeData={data?.rows ?? []}
defaultExpandAll={true}
// draggable={true}
titleRender={(node) => {
return (<TreeNodeRender node={node as any} form={form}/>)
}}
fieldNames={{
title: 'name',
key: 'id'
}}
onSelect={(item) => {
const current = data?.rows?.find((cate) => cate.id === item[0])
if (!current) return
const { extend, ...other } = current as Videos.ICategory
const cate = other as Videos.ICategory
if (extend) {
cate.extend = JSON.parse(extend)
}
setCurrent(cate)
form.setFieldsValue(cate)
}}
// checkable={true}
showIcon={false}
/>
</Spin>
</>
)
}
export default CategoryTree

59
src/pages/videos/category/components/TreeNodeRender.tsx

@ -0,0 +1,59 @@
import { memo } from 'react'
import { MenuItem } from '@/global'
import { Popconfirm, Space, TreeDataNode } from 'antd'
import { FormInstance } from 'antd/lib'
import { useTranslation } from '@/i18n.ts'
import { useStyle } from '../style.ts'
import { useAtomValue } from 'jotai'
import { DeleteAction } from '@/components/icon/action'
import { deleteCategoryAtom } from '@/store/videos/category.ts'
export const TreeNodeRender = memo(({ node }: {
node: MenuItem & TreeDataNode, form?: FormInstance
}) => {
const { name } = node
const { t } = useTranslation()
const { styles } = useStyle()
const { mutate } = useAtomValue(deleteCategoryAtom)
// const setCurrent = useSetAtom(categoryAtom)
return (
<div className={styles.treeNode}>
<span>{name as any}</span>
<span className={'actions'}>
<Space size={'middle'}>
{/*<ActionIcon
size={12}
icon={<PlusOutlined/>}
title={t('actions.add', '添加')}
onClick={(e) => {
// console.log('add')
e.stopPropagation()
e.preventDefault()
const data = {
id: 0,
parent_id: node.id,
}
setCurrent(data)
form.setFieldsValue(data)
}}/>*/}
<Popconfirm
title={t('message.deleteConfirm', '确定要删除吗?')}
onConfirm={() => {
mutate([ (node as any).id ])
}}
>
<DeleteAction
size={12}
onClick={(e) => {
e.stopPropagation()
e.stopPropagation()
}}/>
</Popconfirm>
</Space>
</span>
</div>
)
})

191
src/pages/videos/category/index.tsx

@ -0,0 +1,191 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from '@/i18n.ts'
import { useStyle } from './style.ts'
import {
Alert,
Button,
Divider,
Form,
Input,
InputNumber,
InputRef,
notification,
Flex
} from 'antd'
import { useAtom, useAtomValue } from 'jotai'
import TwoColPageLayout from '@/layout/TwoColPageLayout.tsx'
import { ProCard } from '@ant-design/pro-components'
import { PlusOutlined } from '@ant-design/icons'
import Glass from '@/components/glass'
import { categoryAtom, saveOrUpdateCategoryAtom } from '@/store/videos/category.ts'
import Switch from '@/components/switch'
import CategoryTree from './components/CategoryTree.tsx'
const Category = () => {
const { t } = useTranslation()
const { styles, cx } = useStyle()
const [ form ] = Form.useForm()
const inputRef = useRef<InputRef>()
const { mutate, isPending, isError, error } = useAtomValue(saveOrUpdateCategoryAtom)
const [ current, setCurrent ] = useAtom(categoryAtom)
useEffect(() => {
if (isError) {
notification.error({
message: t('message.error', '错误'),
description: (error as any).message ?? t('message.saveFail', '保存失败'),
})
}
}, [ isError ])
useEffect(() => {
if (current?.id === 0 && inputRef.current) {
inputRef.current.focus()
}
}, [ current ])
return (
<TwoColPageLayout
leftPanel={<>
<ProCard title={t('videos.category.title', '分类')}>
<CategoryTree form={form}/>
</ProCard>
<div className={styles.treeActions}>
<Divider style={{ flex: 1, margin: '8px 0' }}/>
<Button style={{ flex: 1 }} size={'small'}
block={true} type={'dashed'}
icon={<PlusOutlined/>}
onClick={() => {
const data = {
name: '',
extend: {
class: '',
area: '',
lang: '',
year: '',
tag: '',
state: '',
version: '',
},
sort: 0,
status: 1,
parent_id: 0,
id: 0,
}
setCurrent(data)
form.setFieldsValue(data)
}}
>{t('actions.news')}</Button>
</div>
</>}
>
<Glass
enabled={current?.id === undefined}
description={<>
<Alert
message={t('message.infoTitle', '提示')}
description={t('videos.category.form.empty', '请从左侧选择一行数据操作')}
type="info"
/>
</>}
>
<ProCard title={t('videos.category.setting', '编辑')}>
<Form form={form}
initialValues={current!}
labelAlign="right"
labelWrap
colon={false}
className={cx(styles.form, {
[styles.emptyForm]: current?.id === undefined
})}
>
<Form.Item hidden={true} label={'id'} name={'id'}>
<Input disabled={true}/>
</Form.Item>
<Form.Item
rules={[
{ required: true, message: t('rules.required') }
]}
label={t('videos.category.form.name', '名称')} name={'name'}>
<Input ref={inputRef as any}
placeholder={t('videos.category.form.name', '名称')}/>
</Form.Item>
<Form.Item label={t('videos.category.form.tag', 'Tag')}
name={[ 'extend', 'class' ]}
>
<Input/>
</Form.Item>
<Form.Item label={t('videos.category.form.area', '地区')}
name={[ 'extend', 'area' ]}
>
<Input/>
</Form.Item>
<Form.Item label={t('videos.category.form.lang', '语言')}
name={[ 'extend', 'lang' ]}
>
<Input/>
</Form.Item>
<Form.Item label={t('videos.category.form.year', '年份')}
name={[ 'extend', 'year' ]}
>
<Input/>
</Form.Item>
<Form.Item label={t('videos.category.form.state', '资源')}
name={[ 'extend', 'state' ]}
>
<Input/>
</Form.Item>
<Form.Item label={t('videos.category.form.version', '版本')}
name={[ 'extend', 'version' ]}
>
<Input/>
</Form.Item>
<Flex flex={1}>
<Flex flex={1}>
<Form.Item label={t('videos.category.form.sort', '排序')} name={'sort'}>
<InputNumber/>
</Form.Item>
</Flex>
<Flex flex={1}>
<Form.Item label={t('videos.category.form.status', '状态')}
name={'status'}>
<Switch/>
</Form.Item>
</Flex>
</Flex>
<Form.Item label={' '}>
<Button type="primary"
htmlType={'submit'}
loading={isPending}
onClick={() => {
form.validateFields().then((values) => {
mutate({
...values,
status: values.status ? 1 : 0,
extend: JSON.stringify(values.extend),
})
})
}}
>
{t('videos.category.form.save', '保存')}
</Button>
</Form.Item>
</Form>
</ProCard>
</Glass>
</TwoColPageLayout>
)
}
export default Category

81
src/pages/videos/category/style.ts

@ -0,0 +1,81 @@
import { createStyles } from '@/theme'
export const useStyle = createStyles(({ token, css, cx, prefixCls }) => {
const prefix = `${prefixCls}-${token?.proPrefix}-category-page`
const tree = css`
.ant-tree {
overflow: auto;
height: 100%;
border-right: 1px solid ${token.colorBorder};
background: ${token.colorBgContainer};
}
.ant-tree-directory .ant-tree-treenode-selected::before {
background: ${token.colorBgTextHover};
}
.ant-tree-treenode:before {
border-radius: ${token.borderRadius}px;
}
`
const treeNode = css`
display: flex;
justify-content: space-between;
align-items: center;
.actions {
display: none;
padding: 0 10px;
}
&:hover .actions {
{
display: flex;
}
`
const treeActions = css`
padding: 0 24px 16px;
display: flex;
flex-direction: column;
position: sticky;
bottom: 0;
z-index: 10;
background: ${token.colorBgContainer};
`
const box = css`
flex: 1;
background: ${token.colorBgContainer};
`
const form = css`
//display: flex;
//flex-wrap: wrap;
min-width: 300px;
//max-width: 800px;
.ant-form-item-label{
width: 100px;
}
`
const emptyForm = css`
`
const authHeight = css`
min-height: calc(100vh - 122px);
background-color: ${token.colorBgContainer};
`
return {
container: cx(prefix),
authHeight,
box,
form,
emptyForm,
tree,
treeNode,
treeActions
}
})

391
src/pages/videos/list/index.tsx

@ -0,0 +1,391 @@
import { useTranslation } from '@/i18n.ts'
import { Button, Form, Image, Popconfirm } from 'antd'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import {
deleteVideoAtom, getTypeName,
saveOrUpdateVideoAtom, videosAtom, videoSearchAtom, videoTypes
} from '@/store/videos/video.ts'
import { useEffect, useMemo, useState } from 'react'
import Switch from '@/components/switch'
import Action from '@/components/action/Action.tsx'
import { BetaSchemaForm, ProColumns, ProFormColumnsType, ProTable } from '@ant-design/pro-components'
import ListPageLayout from '@/layout/ListPageLayout.tsx'
import { categoryByIdAtom, categoryIdAtom } from '@/store/videos/category.ts'
import TagPro from '@/components/tag-pro/TagPro.tsx'
const i18nPrefix = 'videos.list'
const Video = () => {
// const { styles } = useStyle()
const { t } = useTranslation()
const [ form ] = Form.useForm()
const { mutate: saveOrUpdate, isPending: isSubmitting, isSuccess } = useAtomValue(saveOrUpdateVideoAtom)
const [ search, setSearch ] = useAtom(videoSearchAtom)
const { data, isFetching, isLoading, refetch } = useAtomValue(videosAtom)
const { mutate: deleteVideo, isPending: isDeleting } = useAtomValue(deleteVideoAtom)
const setCategoryId = useSetAtom(categoryIdAtom)
const { data: category, isLoading: isCategoryFetching } = useAtomValue(categoryByIdAtom)
const [ open, setOpen ] = useState(false)
const columns = useMemo(() => {
return [
{
title: 'ID',
dataIndex: 'id',
hideInTable: true,
hideInSearch: true,
formItemProps: { hidden: true }
},
{
'title': t(`${i18nPrefix}.columns.video_id`, 'VideoId'),
'dataIndex': 'video_id',
hideInTable: true,
hideInSearch: true,
hideInSetting: true,
formItemProps: { hidden: true },
},
{
'title': t(`${i18nPrefix}.columns.title`, 'Title'),
'dataIndex': 'title',
onHeaderCell: () => {
return {
width: 200,
}
},
colProps: {
span: 8
}
},
{
'title': t(`${i18nPrefix}.columns.title_sub`, 'TitleSub'),
'dataIndex': 'title_sub',
onHeaderCell: () => {
return {
width: 200,
}
},
colProps: {
span: 8
}
},
{
'title': t(`${i18nPrefix}.columns.type_id`, 'TypeId'),
'dataIndex': 'type_id',
valueType: 'select',
fieldProps: {
options: videoTypes,
},
render: (_dom, record) => {
return t(`${i18nPrefix}.type_id.${record.type_id}`)
},
colProps: {
span: 8
}
},
{
'title': t(`${i18nPrefix}.columns.source_url`, 'SourceUrl'),
'dataIndex': 'source_url',
ellipsis: true,
copyable: true,
onHeaderCell: () => {
return {
width: 200,
}
},
},
{
'title': t(`${i18nPrefix}.columns.category_id`, 'CategoryId'),
'dataIndex': 'class_name',
},
{
'title': t(`${i18nPrefix}.columns.actor`, 'Actor'),
'dataIndex': 'actor',
ellipsis: true,
onHeaderCell: () => {
return {
width: 200,
}
},
},
{
'title': t(`${i18nPrefix}.columns.director`, 'Director'),
'dataIndex': 'director'
},
{
'title': t(`${i18nPrefix}.columns.content`, 'Content'),
'dataIndex': 'content',
valueType: 'textarea',
ellipsis: true,
onHeaderCell: () => ({
width: 200,
}),
},
{
'title': t(`${i18nPrefix}.columns.writer`, 'Writer'),
'dataIndex': 'writer'
},
{
'title': t(`${i18nPrefix}.columns.remarks`, 'Remarks'),
'dataIndex': 'remarks'
},
{
'title': t(`${i18nPrefix}.columns.pubdate`, 'Pubdate'),
'dataIndex': 'pubdate',
valueType: 'dateTime',
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.total`, 'Total'),
'dataIndex': 'total',
valueType: 'digit',
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.serial`, 'Serial'),
'dataIndex': 'serial',
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.duration`, 'Duration'),
'dataIndex': 'duration',
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.year`, 'Year'),
'dataIndex': 'year',
valueType: 'dateYear',
colProps: {
span: 4
}
},
{
'title': t(`${i18nPrefix}.columns.tag`, 'Tag'),
'dataIndex': 'tag',
valueType: 'textarea',
ellipsis: true,
onHeaderCell: () => {
return {
width: 200,
}
},
renderFormItem: (schema, config) => {
return <TagPro loading={isCategoryFetching} tags={category?.extend?.class?.split(',') ?? []} {...config} {...schema.fieldProps} />
}
},
{
'title': t(`${i18nPrefix}.columns.area`, 'Area'),
'dataIndex': 'area',
ellipsis: true,
onHeaderCell: () => {
return {
width: 200,
}
},
renderFormItem: (schema, config) => {
return <TagPro loading={isCategoryFetching} tags={category?.extend?.area?.split(',') ?? []} {...config} {...schema.fieldProps} />
}
},
{
'title': t(`${i18nPrefix}.columns.lang`, 'Lang'),
'dataIndex': 'lang',
ellipsis: true,
onHeaderCell: () => {
return {
width: 200,
}
},
renderFormItem: (schema, config) => {
return <TagPro loading={isCategoryFetching} tags={category?.extend?.lang?.split(',') ?? []} {...config} {...schema.fieldProps} />
}
},
{
'title': t(`${i18nPrefix}.columns.version`, 'Version'),
'dataIndex': 'version',
renderFormItem: (schema, config) => {
return <TagPro loading={isCategoryFetching} tags={category?.extend?.version?.split(',') ?? []} {...config} {...schema.fieldProps} />
}
},
{
'title': t(`${i18nPrefix}.columns.state`, 'State'),
'dataIndex': 'state',
renderFormItem: (schema, config) => {
return <TagPro loading={isCategoryFetching} tags={category?.extend?.state?.split(',') ?? []} {...config} {...schema.fieldProps} />
}
},
{
'title': t(`${i18nPrefix}.columns.douban_score`, 'DoubanScore'),
'dataIndex': 'douban_score',
hideInSearch: true,
hideInSetting: true,
formItemProps: { hidden: true },
hideInTable: true,
},
{
'title': t(`${i18nPrefix}.columns.douban_id`, 'DoubanId'),
'dataIndex': 'douban_id',
hideInSearch: true,
hideInSetting: true,
formItemProps: { hidden: true },
hideInTable: true,
},
{
'title': t(`${i18nPrefix}.columns.imdb_score`, 'ImdbScore'),
'dataIndex': 'imdb_score',
hideInSearch: true,
hideInSetting: true,
formItemProps: { hidden: true },
hideInTable: true,
},
{
'title': t(`${i18nPrefix}.columns.imdb_id`, 'ImdbId'),
'dataIndex': 'imdb_id',
hideInSearch: true,
hideInSetting: true,
formItemProps: { hidden: true },
hideInTable: true,
},
{
title: t(`${i18nPrefix}.columns.option`, '操作'),
key: 'option',
valueType: 'option',
fixed: 'right',
render: (_, record) => [
<Action key="edit"
as={'a'}
onClick={() => {
setCategoryId(record.type_id)
form.setFieldsValue(record)
setOpen(true)
}}>{t('actions.edit')}</Action>,
<Popconfirm
key={'del_confirm'}
disabled={isDeleting}
onConfirm={() => {
deleteVideo([ record.id ])
}}
title={t('message.deleteConfirm')}>
<a key="del">
{t('actions.delete', '删除')}
</a>
</Popconfirm>
]
}
] as ProColumns[]
}, [ isDeleting, category, isCategoryFetching ])
useEffect(() => {
if (isSuccess) {
setOpen(false)
}
}, [ isSuccess ])
return (
<ListPageLayout>
<ProTable
rowKey="id"
headerTitle={t(`${i18nPrefix}.title`, '视频管理')}
toolbar={{
search: {
loading: isFetching && !!search.key,
onSearch: (value: string) => {
setSearch(prev => ({
...prev,
key: value
}))
},
allowClear: true,
placeholder: t(`${i18nPrefix}.placeholder`, '输入视频名称')
},
actions: [
<Button
onClick={() => {
form.resetFields()
form.setFieldsValue({
id: 0,
})
setOpen(true)
}}
type={'primary'}>{t(`${i18nPrefix}.add`, '添加')}</Button>
]
}}
scroll={{
x: 3500,
}}
loading={isLoading || isFetching}
dataSource={data?.rows ?? []}
columns={columns}
search={false}
options={{
reload: () => {
refetch()
},
}}
pagination={{
total: data?.total,
pageSize: search.pageSize,
current: search.page,
onChange: (current, pageSize) => {
setSearch(prev => {
return {
...prev,
page: current,
pageSize: pageSize,
}
})
},
}}
/>
<BetaSchemaForm
grid={true}
shouldUpdate={false}
width={1000}
form={form}
layout={'vertical'}
scrollToFirstError={true}
title={t(`${i18nPrefix}.title_${form.getFieldValue('id') !== 0 ? 'edit' : 'add'}`, form.getFieldValue('id') !== 0 ? '视频编辑' : '视频添加')}
// colProps={{ span: 24 }}
// labelCol={{ span: 8 }}
// wrapperCol={{ span: 14 }}
layoutType={'DrawerForm'}
open={open}
drawerProps={{
maskClosable: false,
}}
onOpenChange={(open) => {
setOpen(open)
}}
loading={isSubmitting}
onValuesChange={(values) => {
if(values.type_id) {
setCategoryId(values.type_id)
const typeName = getTypeName(values.type_id)
form.setFieldsValue({
class_name: typeName,
})
}
}}
onFinish={async (values) => {
// console.log('values', values)
saveOrUpdate(values)
}}
columns={columns as ProFormColumnsType[]}/>
</ListPageLayout>
)
}
export default Video

2
src/service/base.ts

@ -21,7 +21,7 @@ export const createCURD = <TParams, TResult>(api: string, options?: AxiosRequest
return request.post<TResult>(`${api}/deletes`, { ids }, options)
},
info: (id: number) => {
return request.get<TResult>(`${api}/${id}`, options)
return request.post<TResult>(`${api}/get`, { id }, options)
}
}

3
src/service/cms.ts

@ -14,6 +14,9 @@ const cmsServ = {
videoMagnet: {
...createCURD<any, Cms.IVideoMagnet>('/cms/video_magnet')
},
category: {
...createCURD<any, Cms.ICategory>('/cms/category')
},
}
export default cmsServ

13
src/service/videos.ts

@ -0,0 +1,13 @@
import { createCURD } from '@/service/base.ts'
import { Videos } from '@/types'
const videoServ = {
category: {
...createCURD<any, Videos.ICategory>('/videos/category')
},
video: {
...createCURD<any, Videos.IVideo>('/videos/video')
}
}
export default videoServ

108
src/store/cms/category.ts

@ -0,0 +1,108 @@
import { atom } from 'jotai'
import { IApiResult, IPage } from '@/global'
import { atomWithMutation, atomWithQuery, queryClientAtom } from 'jotai-tanstack-query'
import { message } from 'antd'
import { t } from 'i18next'
import { Cms } from '@/types'
import cmsServ from '@/service/cms.ts'
type SearchParams = IPage & {
key?: string
}
export const categoryIdAtom = atom(0)
export const categoryIdsAtom = atom<number[]>([])
export const categoryAtom = atom<Cms.ICategory>(undefined as unknown as Cms.ICategory)
export const categorySearchAtom = atom<SearchParams>({
key: ''
} as SearchParams)
export const categoryPageAtom = atom<IPage>({
pageSize: 10,
page: 1,
})
export const categoriesAtom = atomWithQuery((get) => {
return {
queryKey: [ 'categories', get(categorySearchAtom) ],
queryFn: async ({ queryKey: [ , params ] }) => {
return await cmsServ.category.list(params as SearchParams)
},
select: res => {
const data = res.data
data.rows = data.rows?.map(row => {
return {
...row,
//status: convertToBool(row.status)
}
})
return data
}
}
})
//saveOrUpdateAtom
export const saveOrUpdateCategoryAtom = atomWithMutation<IApiResult, Cms.ICategory>((get) => {
return {
mutationKey: [ 'updateCategory' ],
mutationFn: async (data) => {
//data.status = data.status ? '1' : '0'
if (data.id === 0) {
return await cmsServ.category.add(data)
}
return await cmsServ.category.update(data)
},
onSuccess: (res) => {
const isAdd = !!res.data?.id
message.success(t(isAdd ? 'message.saveSuccess' : 'message.editSuccess', '保存成功'))
//更新列表
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore fix
get(queryClientAtom).invalidateQueries({ queryKey: [ 'categories', get(categorySearchAtom) ] })
return res
}
}
})
export const deleteCategoryAtom = atomWithMutation((get) => {
return {
mutationKey: [ 'deleteCategory' ],
mutationFn: async (ids: number[]) => {
return await cmsServ.category.batchDelete(ids ?? get(categoryIdsAtom))
},
onSuccess: (res) => {
message.success('message.deleteSuccess')
//更新列表
get(queryClientAtom).invalidateQueries({ queryKey: [ 'categories', get(categorySearchAtom) ] })
return res
}
}
})
//getById
export const categoryByIdAtom = atomWithQuery((get) => {
return {
enabled: !!get(categoryIdAtom),
queryKey: [ 'category', get(categoryIdAtom) ],
queryFn: async ({ queryKey: [ , id ] }) => {
const res = await cmsServ.category.info(id as number)
//res.data.status = convertToBool(res.data.status)
return res
},
select: (res) => {
const data = res.data
if (data.extend) {
data.extend = JSON.parse(data.extend)
}
return data
}
}
})

107
src/store/videos/category.ts

@ -0,0 +1,107 @@
import { atom } from 'jotai'
import { IApiResult, IPage } from '@/global'
import { atomWithMutation, atomWithQuery, queryClientAtom } from 'jotai-tanstack-query'
import { message } from 'antd'
import { t } from 'i18next'
import { Videos } from '@/types'
import videoServ from '@/service/videos.ts'
type SearchParams = IPage & {
key?: string
}
export const categoryIdAtom = atom(0)
export const categoryIdsAtom = atom<number[]>([])
export const categoryAtom = atom<Videos.ICategory>(undefined as unknown as Videos.ICategory)
export const categorySearchAtom = atom<SearchParams>({
key: ''
} as SearchParams)
export const categoryPageAtom = atom<IPage>({
pageSize: 10,
page: 1,
})
export const categoriesAtom = atomWithQuery((get) => {
return {
queryKey: [ 'categories', get(categorySearchAtom) ],
queryFn: async ({ queryKey: [ , params ] }) => {
return await videoServ.category.list(params as SearchParams)
},
select: res => {
const data = res.data
data.rows = data.rows?.map(row => {
return {
...row,
//status: convertToBool(row.status)
}
})
return data
}
}
})
//saveOrUpdateAtom
export const saveOrUpdateCategoryAtom = atomWithMutation<IApiResult, Videos.ICategory>((get) => {
return {
mutationKey: [ 'updateCategory' ],
mutationFn: async (data) => {
//data.status = data.status ? '1' : '0'
if (data.id === 0) {
return await videoServ.category.add(data)
}
return await videoServ.category.update(data)
},
onSuccess: (res) => {
const isAdd = !!res.data?.id
message.success(t(isAdd ? 'message.saveSuccess' : 'message.editSuccess', '保存成功'))
//更新列表
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore fix
get(queryClientAtom).invalidateQueries({ queryKey: [ 'categories', get(categorySearchAtom) ] })
return res
}
}
})
export const deleteCategoryAtom = atomWithMutation((get) => {
return {
mutationKey: [ 'deleteCategory' ],
mutationFn: async (ids: number[]) => {
return await videoServ.category.batchDelete(ids ?? get(categoryIdsAtom))
},
onSuccess: (res) => {
message.success('message.deleteSuccess')
//更新列表
get(queryClientAtom).invalidateQueries({ queryKey: [ 'categories', get(categorySearchAtom) ] })
return res
}
}
})
//getById
export const categoryByIdAtom = atomWithQuery((get) => {
return {
enabled: !!get(categoryIdAtom),
queryKey: [ 'category', get(categoryIdAtom) ],
queryFn: async ({ queryKey: [ , id ] }) => {
const res = await videoServ.category.info(id as number)
//res.data.status = convertToBool(res.data.status)
return res
},
select: (res) => {
const data = res.data
if (data.extend) {
data.extend = JSON.parse(data.extend)
}
return data
}
}
})

97
src/store/videos/video.ts

@ -0,0 +1,97 @@
import { atom } from 'jotai'
import { IApiResult, IPage } from '@/global'
import { atomWithMutation, atomWithQuery, queryClientAtom } from 'jotai-tanstack-query'
import { message } from 'antd'
import { t } from 'i18next'
import { Videos } from '@/types'
import videoServ from '@/service/videos.ts'
const i18nPrefix = 'videos.list'
export const videoTypes = [
{ label: t(`${i18nPrefix}.type_id.1`), value: 1 },
{ label: t(`${i18nPrefix}.type_id.2`), value: 2 },
{ label: t(`${i18nPrefix}.type_id.3`), value: 3 },
]
export const getTypeName = (typeId: number) => {
return videoTypes.find(item => item.value === typeId)?.label
}
type SearchParams = IPage & {
key?: string
}
export const videoIdAtom = atom(0)
export const videoIdsAtom = atom<number[]>([])
export const videoAtom = atom<Videos.IVideo>(undefined as unknown as Videos.IVideo)
export const videoSearchAtom = atom<SearchParams>({
key: ''
} as SearchParams)
export const videoPageAtom = atom<IPage>({
pageSize: 10,
page: 1,
})
export const videosAtom = atomWithQuery((get) => {
return {
queryKey: [ 'videos', get(videoSearchAtom) ],
queryFn: async ({ queryKey: [ , params ] }) => {
return await videoServ.video.list(params as SearchParams)
},
select: res => {
const data = res.data
data.rows = data.rows?.map(row => {
return {
...row,
//status: convertToBool(row.status)
}
})
return data
}
}
})
//saveOrUpdateAtom
export const saveOrUpdateVideoAtom = atomWithMutation<IApiResult, Videos.IVideo>((get) => {
return {
mutationKey: [ 'updateVideo' ],
mutationFn: async (data) => {
//data.status = data.status ? '1' : '0'
if (data.id === 0) {
return await videoServ.video.add(data)
}
return await videoServ.video.update(data)
},
onSuccess: (res) => {
const isAdd = !!res.data?.id
message.success(t(isAdd ? 'message.saveSuccess' : 'message.editSuccess', '保存成功'))
//更新列表
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore fix
get(queryClientAtom).invalidateQueries({ queryKey: [ 'videos', get(videoSearchAtom) ] })
return res
}
}
})
export const deleteVideoAtom = atomWithMutation((get) => {
return {
mutationKey: [ 'deleteVideo' ],
mutationFn: async (ids: number[]) => {
return await videoServ.video.batchDelete(ids ?? get(videoIdsAtom))
},
onSuccess: (res) => {
message.success('message.deleteSuccess')
//更新列表
get(queryClientAtom).invalidateQueries({ queryKey: [ 'videos', get(videoSearchAtom) ] })
return res
}
}
})

17
src/types/cms/category.d.ts

@ -0,0 +1,17 @@
export interface ICategory {
id: number;
parent_id: number;
name: string;
union: string;
sort: number;
status: number;
seo_title: string;
seo_key: string;
seo_des: string;
tpl_index: string;
tpl_list: string;
tpl_detail: string;
tpl_down: string;
tpl_play: string;
extend: string;
}

32
src/types/index.d.ts

@ -1,21 +1,27 @@
export namespace System {
export { IDepartment } from './system/department'
export { IUser, IUserInfo } from './system/user'
export { LoginRequest, LoginResponse } from './system/login'
export { IRole } from './system/roles'
export { IMenu } from './system/menus'
export { IDepartment } from './system/department'
export { IUser, IUserInfo } from './system/user'
export { LoginRequest, LoginResponse } from './system/login'
export { IRole } from './system/roles'
export { IMenu } from './system/menus'
}
export namespace WebSite {
export { IAcmeAccount } from './website/acme'
export { ICA, ISSLObtainByCA } from './website/ca'
export { IDnsAccount } from './website/dns'
export { ISSL, ProviderType, SSLSearchParam, SSLUploadDto } from './website/ssl'
export { IAcmeAccount } from './website/acme'
export { ICA, ISSLObtainByCA } from './website/ca'
export { IDnsAccount } from './website/dns'
export { ISSL, ProviderType, SSLSearchParam, SSLUploadDto } from './website/ssl'
}
export namespace Cms {
export { ICollect } from './cms/collect'
export { IVideo } from './cms/video'
export { IVideoMagnet } from './cms/video_magnet'
export { IVideoCloud } from './cms/video_cloud'
export { ICollect } from './cms/collect'
export { IVideo } from './cms/video'
export { IVideoMagnet } from './cms/video_magnet'
export { IVideoCloud } from './cms/video_cloud'
export { ICategory } from './videos/category'
}
export namespace Videos {
export { ICategory } from './videos/category'
export { IVideo } from './videos/video'
}

17
src/types/videos/category.d.ts

@ -0,0 +1,17 @@
export interface ICategory {
id: number;
parent_id: number;
name: string;
union: string;
sort: number;
status: number;
seo_title: string;
seo_key: string;
seo_des: string;
tpl_index: string;
tpl_list: string;
tpl_detail: string;
tpl_down: string;
tpl_play: string;
extend: string;
}

23
src/types/videos/video.d.ts

@ -0,0 +1,23 @@
export interface IVideo {
id: number;
video_id: string;
type_id: number;
title: string;
title_sub: string;
name: string;
class_name: string;
tag: string;
actor: string;
director: string;
content: string;
duration: string;
area: string;
lang: string;
year: number;
douban_id: string;
imdb_id: string;
rt_id: string;
mal_id: string;
updated_at: any;
}
Loading…
Cancel
Save