dark
6 months ago
28 changed files with 1893 additions and 71 deletions
-
2src/components/switch/index.tsx
-
96src/components/tag-pro/TagPro.tsx
-
0src/components/tag-pro/index.ts
-
2src/locales/lang/pages/cms/video/zh-CN.ts
-
2src/locales/lang/pages/cms/videoCloud/zh-CN.ts
-
2src/locales/lang/pages/cms/videoMagnet/zh-CN.ts
-
53src/locales/lang/pages/videos/list/zh-CN.ts
-
4src/locales/lang/zh-CN.ts
-
67src/pages/cms/category/components/CategoryTree.tsx
-
59src/pages/cms/category/components/TreeNodeRender.tsx
-
191src/pages/cms/category/index.tsx
-
81src/pages/cms/category/style.ts
-
197src/pages/cms/video/index.tsx
-
67src/pages/videos/category/components/CategoryTree.tsx
-
59src/pages/videos/category/components/TreeNodeRender.tsx
-
191src/pages/videos/category/index.tsx
-
81src/pages/videos/category/style.ts
-
391src/pages/videos/list/index.tsx
-
2src/service/base.ts
-
3src/service/cms.ts
-
13src/service/videos.ts
-
108src/store/cms/category.ts
-
107src/store/videos/category.ts
-
97src/store/videos/video.ts
-
17src/types/cms/category.d.ts
-
32src/types/index.d.ts
-
17src/types/videos/category.d.ts
-
23src/types/videos/video.d.ts
@ -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,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: '更新时间' |
|||
|
|||
} |
|||
} |
@ -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 |
@ -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> |
|||
) |
|||
}) |
@ -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 |
@ -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 |
|||
} |
|||
}) |
@ -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 |
@ -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> |
|||
) |
|||
}) |
@ -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 |
@ -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 |
|||
} |
|||
}) |
@ -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 |
@ -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 |
@ -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 |
|||
} |
|||
} |
|||
}) |
@ -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 |
|||
} |
|||
} |
|||
}) |
@ -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 |
|||
} |
|||
} |
|||
}) |
@ -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; |
|||
} |
@ -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' |
|||
} |
@ -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; |
|||
} |
@ -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; |
|||
|
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue