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 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 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 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