feat: 初始化提交
This commit is contained in:
1931
client/bun.lock
Normal file
1931
client/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
32
client/index.html
Normal file
32
client/index.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!--suppress CssUnknownTarget, HtmlUnknownTarget -->
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<link href="icon.png" rel="icon"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>我的书架</title>
|
||||
<style>
|
||||
html, body, #root {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'LXGWWenKai';
|
||||
src: url('fonts/LXGWNeoXiHei.ttf') format('truetype');
|
||||
}
|
||||
|
||||
*:not(.fa,.fas) {
|
||||
font-family: LXGWWenKai, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', serif !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
51
client/package.json
Normal file
51
client/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "lepoard-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.2",
|
||||
"@ant-design/pro-components": "^2.8.10",
|
||||
"@ant-design/x": "^1.6.1",
|
||||
"@echofly/fetch-event-source": "^3.0.2",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@lightenna/react-mermaid-diagram": "^1.0.21",
|
||||
"ahooks": "^3.9.5",
|
||||
"amis": "^6.13.0",
|
||||
"amis-core": "^6.13.0",
|
||||
"antd": "^5.27.3",
|
||||
"axios": "1.11.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"es-toolkit": "^1.39.10",
|
||||
"mermaid": "^11.11.0",
|
||||
"react": "^18.3.1",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.9.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"styled-components": "^6.1.19",
|
||||
"yocto-queue": "^1.2.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.24",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||
"globals": "^16.4.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"sass": "^1.92.1",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.5",
|
||||
"vite-plugin-javascript-obfuscator": "^3.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
BIN
client/public/fonts/LXGWNeoXiHei.ttf
Normal file
BIN
client/public/fonts/LXGWNeoXiHei.ttf
Normal file
Binary file not shown.
BIN
client/public/icon.png
Normal file
BIN
client/public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
54
client/src/components/Markdown.tsx
Normal file
54
client/src/components/Markdown.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'chart.js/auto'
|
||||
import {MermaidDiagram} from '@lightenna/react-mermaid-diagram'
|
||||
import EChartsReact from 'echarts-for-react'
|
||||
import {Chart} from 'react-chartjs-2'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import {trim} from 'es-toolkit'
|
||||
|
||||
type MarkdownOptions = {
|
||||
content: string
|
||||
}
|
||||
|
||||
function MarkdownRender(options: MarkdownOptions) {
|
||||
return (
|
||||
<Markdown
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
]}
|
||||
children={options.content}
|
||||
components={{
|
||||
code: ({children, className, node, ...rest}) => {
|
||||
switch (trim(className || '')) {
|
||||
case 'language-mermaid':
|
||||
return (
|
||||
<MermaidDiagram
|
||||
children={children as string}
|
||||
/>
|
||||
)
|
||||
case 'language-chartjs':
|
||||
let chartjsData = eval(`(${children as string})`)
|
||||
return (
|
||||
<Chart
|
||||
{...chartjsData}
|
||||
/>
|
||||
)
|
||||
case 'language-echart':
|
||||
let echartData = eval(`(${children as string})`)
|
||||
return (
|
||||
<EChartsReact option={echartData}/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<code {...rest} className={className}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default MarkdownRender
|
||||
15
client/src/components/amis/MarkdownEnhance.scss
Normal file
15
client/src/components/amis/MarkdownEnhance.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
.markdown-enhance {
|
||||
tr {
|
||||
border-top: 1px solid #c6cbd1;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #dfe2e5;
|
||||
}
|
||||
|
||||
table tr:nth-child(2n) {
|
||||
background: #f6f8fa;
|
||||
}
|
||||
}
|
||||
22
client/src/components/amis/MarkdownEnhance.tsx
Normal file
22
client/src/components/amis/MarkdownEnhance.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import {Renderer, type RendererProps} from 'amis'
|
||||
import React from 'react'
|
||||
import Markdown from '../Markdown.tsx'
|
||||
import './MarkdownEnhance.scss'
|
||||
import {once} from 'es-toolkit'
|
||||
|
||||
const MarkdownEnhance: React.FC<RendererProps> = props => {
|
||||
return (
|
||||
<div className="markdown-enhance">
|
||||
<Markdown content={props.content}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const register = once(() => {
|
||||
Renderer({
|
||||
type: 'markdown-enhance',
|
||||
autoVar: true,
|
||||
})(React.memo(MarkdownEnhance))
|
||||
})
|
||||
|
||||
register()
|
||||
1
client/src/components/amis/Registry.ts
Normal file
1
client/src/components/amis/Registry.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './MarkdownEnhance.tsx'
|
||||
9
client/src/index.scss
Normal file
9
client/src/index.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
.copyright {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
// 改写一些amis中控制不到的全局CSS
|
||||
button.btn-deleted:hover {
|
||||
color: #dc2626 !important;
|
||||
}
|
||||
52
client/src/index.tsx
Normal file
52
client/src/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import {createHashRouter, Navigate, type RouteObject, RouterProvider} from 'react-router'
|
||||
import './index.scss'
|
||||
import './components/amis/Registry.ts'
|
||||
import Overview from './pages/Overview.tsx'
|
||||
import Root from './pages/Root.tsx'
|
||||
import Test from './pages/Test.tsx'
|
||||
import Bookshelf from './pages/book/Bookshelf.tsx'
|
||||
import Book from './pages/book/Book.tsx'
|
||||
import Chapter from './pages/book/Chapter.tsx'
|
||||
|
||||
const routes: RouteObject[] = [
|
||||
{
|
||||
path: '/',
|
||||
Component: Root,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/overview" replace/>,
|
||||
},
|
||||
{
|
||||
path: 'overview',
|
||||
Component: Overview,
|
||||
},
|
||||
{
|
||||
path: 'bookshelf',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
Component: Bookshelf,
|
||||
},
|
||||
{
|
||||
path: 'book/:id',
|
||||
Component: Book,
|
||||
},
|
||||
{
|
||||
path: 'chapter/:id',
|
||||
Component: Chapter,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'test',
|
||||
Component: Test,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<RouterProvider router={createHashRouter(routes)}/>,
|
||||
)
|
||||
9
client/src/pages/Overview.tsx
Normal file
9
client/src/pages/Overview.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
function Overview() {
|
||||
return (
|
||||
<div className="overview"></div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Overview)
|
||||
120
client/src/pages/Root.tsx
Normal file
120
client/src/pages/Root.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import {BookOutlined, DeploymentUnitOutlined, InfoCircleOutlined} from '@ant-design/icons'
|
||||
import {type AppItemProps, ProLayout} from '@ant-design/pro-components'
|
||||
import {ConfigProvider} from 'antd'
|
||||
import React, {useMemo} from 'react'
|
||||
import {NavLink, Outlet, useLocation} from 'react-router'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const ProLayoutDiv = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
.ant-menu-sub > .ant-menu-item {
|
||||
//padding-left: 28px !important;
|
||||
}
|
||||
`
|
||||
const apps: AppItemProps[] = []
|
||||
|
||||
const menus = {
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: '概览',
|
||||
icon: <InfoCircleOutlined/>,
|
||||
routes: [
|
||||
{
|
||||
path: '/overview',
|
||||
name: '概览',
|
||||
icon: <InfoCircleOutlined/>,
|
||||
},
|
||||
{
|
||||
path: '/bookshelf',
|
||||
name: '书架',
|
||||
icon: <BookOutlined/>,
|
||||
},
|
||||
{
|
||||
path: '/test',
|
||||
name: '测试',
|
||||
icon: <DeploymentUnitOutlined/>,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const Root: React.FC = () => {
|
||||
const location = useLocation()
|
||||
const currentYear = useMemo(() => new Date().getFullYear(), [])
|
||||
return (
|
||||
<ProLayoutDiv>
|
||||
<ProLayout
|
||||
collapsed={false}
|
||||
collapsedButtonRender={() => <></>}
|
||||
siderWidth={180}
|
||||
token={{
|
||||
colorTextAppListIcon: '#dfdfdf',
|
||||
colorTextAppListIconHover: '#ffffff',
|
||||
header: {
|
||||
colorBgHeader: '#292f33',
|
||||
colorHeaderTitle: '#ffffff',
|
||||
colorTextMenu: '#dfdfdf',
|
||||
colorTextMenuSecondary: '#dfdfdf',
|
||||
colorTextMenuSelected: '#ffffff',
|
||||
colorTextMenuActive: '#ffffff',
|
||||
colorBgMenuItemSelected: '#22272b',
|
||||
colorTextRightActionsItem: '#dfdfdf',
|
||||
},
|
||||
pageContainer: {
|
||||
paddingBlockPageContainerContent: 0,
|
||||
paddingInlinePageContainerContent: 0,
|
||||
marginBlockPageContainerContent: 0,
|
||||
marginInlinePageContainerContent: 0,
|
||||
},
|
||||
}}
|
||||
appList={apps}
|
||||
breakpoint={false}
|
||||
disableMobile={true}
|
||||
logo={<img src="icon.png" alt="logo"/>}
|
||||
title="吉普莉尔"
|
||||
route={menus}
|
||||
location={{pathname: location.pathname}}
|
||||
menu={{type: 'group'}}
|
||||
menuItemRender={(item, defaultDom) =>
|
||||
<NavLink to={item.path || '/'}>{defaultDom}</NavLink>
|
||||
}
|
||||
fixSiderbar={true}
|
||||
layout="side"
|
||||
splitMenus={true}
|
||||
style={{minHeight: '100vh'}}
|
||||
contentStyle={{backgroundColor: 'white', padding: '10px 10px 10px 20px'}}
|
||||
menuFooterRender={props => {
|
||||
return (
|
||||
<div className="copyright" style={{userSelect: 'none', msUserSelect: 'none'}}>
|
||||
{props?.collapsed
|
||||
? undefined
|
||||
: <div>© 2023-{currentYear} 兰缘小妖</div>}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Card: {
|
||||
bodyPadding: 0,
|
||||
bodyPaddingSM: 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Outlet/>
|
||||
</ConfigProvider>
|
||||
</ProLayout>
|
||||
</ProLayoutDiv>
|
||||
)
|
||||
}
|
||||
|
||||
export default Root
|
||||
10
client/src/pages/Test.tsx
Normal file
10
client/src/pages/Test.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
|
||||
function Test() {
|
||||
return (
|
||||
<div className="test">
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Test)
|
||||
225
client/src/pages/book/Book.tsx
Normal file
225
client/src/pages/book/Book.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import React from 'react'
|
||||
import {useNavigate, useParams} from 'react-router'
|
||||
import {
|
||||
amisRender,
|
||||
commonInfo,
|
||||
crudCommonOptions,
|
||||
horizontalFormOptions,
|
||||
paginationTemplate,
|
||||
time,
|
||||
} from '../../util/amis.tsx'
|
||||
|
||||
function Book() {
|
||||
const navigate = useNavigate()
|
||||
const {id} = useParams()
|
||||
return (
|
||||
<div className="book">
|
||||
{amisRender(
|
||||
{
|
||||
type: 'page',
|
||||
title: '书籍详情',
|
||||
initApi: `${commonInfo.baseUrl}/book/detail/${id}`,
|
||||
body: [
|
||||
{
|
||||
type: 'property',
|
||||
title: '${name}',
|
||||
items: [
|
||||
{label: '作者', content: '${author}'},
|
||||
{label: '来源', content: '${source}'},
|
||||
{label: '标签', content: '${tags}'},
|
||||
{label: '描述', content: '${description}', span: 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'crud',
|
||||
api: {
|
||||
method: 'post',
|
||||
url: `${commonInfo.baseUrl}/chapter/list`,
|
||||
convertKeyToPath: false,
|
||||
data: {
|
||||
query: {
|
||||
equal: {
|
||||
'book.id': id,
|
||||
},
|
||||
},
|
||||
page: {
|
||||
index: '${page}',
|
||||
size: '${perPage}',
|
||||
},
|
||||
sort: [
|
||||
{
|
||||
column: 'sequence',
|
||||
direction: 'ASC',
|
||||
},
|
||||
{
|
||||
column: 'modifiedTime',
|
||||
direction: 'DESC',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
...crudCommonOptions(),
|
||||
...paginationTemplate(
|
||||
undefined,
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
type: 'action',
|
||||
label: '',
|
||||
icon: 'fa fa-rotate-right',
|
||||
actionType: 'ajax',
|
||||
tooltip: '序号重排',
|
||||
tooltipPlacement: 'top',
|
||||
api: `get:${commonInfo.baseUrl}/chapter/generate_sequence`
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
label: '',
|
||||
icon: 'fa fa-upload',
|
||||
actionType: 'dialog',
|
||||
dialog: {
|
||||
title: '导入章节',
|
||||
size: 'md',
|
||||
body: {
|
||||
debug: commonInfo.debug,
|
||||
type: 'form',
|
||||
api: `post:${commonInfo.baseUrl}/chapter/save_with_content`,
|
||||
...horizontalFormOptions(),
|
||||
canAccessSuperData: false,
|
||||
body: [
|
||||
{
|
||||
type: 'hidden',
|
||||
name: 'bookId',
|
||||
value: id,
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
name: 'mode',
|
||||
label: '导入方式',
|
||||
selectFirst: true,
|
||||
options: [
|
||||
{
|
||||
label: '新增',
|
||||
value: 'CREATE',
|
||||
},
|
||||
{
|
||||
label: '覆盖',
|
||||
value: 'OVERRIDE',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'input-text',
|
||||
name: 'name',
|
||||
label: '章节名称',
|
||||
required: true,
|
||||
clearable: true,
|
||||
},
|
||||
{
|
||||
type: 'input-text',
|
||||
name: 'description',
|
||||
label: '章节描述',
|
||||
clearable: true,
|
||||
},
|
||||
{
|
||||
type: 'editor',
|
||||
name: 'content',
|
||||
label: '章节内容',
|
||||
required: true,
|
||||
clearable: true,
|
||||
options: {
|
||||
wordWrap: 'on',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
/*{
|
||||
type: 'action',
|
||||
label: '',
|
||||
icon: 'fa fa-plus',
|
||||
actionType: 'dialog',
|
||||
dialog: detailDialog(),
|
||||
},*/
|
||||
]
|
||||
),
|
||||
columns: [
|
||||
{
|
||||
name: 'sequence',
|
||||
label: '序号',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '章节名称',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: '描述',
|
||||
width: 250,
|
||||
},
|
||||
{
|
||||
label: '创建时间',
|
||||
...time('createdTime'),
|
||||
},
|
||||
{
|
||||
label: '更新时间',
|
||||
...time('modifiedTime'),
|
||||
},
|
||||
{
|
||||
type: 'operation',
|
||||
label: '操作',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
buttons: [
|
||||
{
|
||||
type: 'action',
|
||||
label: '详情',
|
||||
level: 'link',
|
||||
onEvent: {
|
||||
click: {
|
||||
actions: [
|
||||
{
|
||||
actionType: 'custom',
|
||||
// @ts-ignore
|
||||
script: (context, action, event) => {
|
||||
navigate(`/bookshelf/chapter/${context.props.data['id']}`)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
/*{
|
||||
type: 'action',
|
||||
label: '修改',
|
||||
level: 'link',
|
||||
size: 'sm',
|
||||
actionType: 'dialog',
|
||||
dialog: detailDialog(),
|
||||
},*/
|
||||
{
|
||||
className: 'text-danger btn-deleted',
|
||||
type: 'action',
|
||||
label: '删除',
|
||||
level: 'link',
|
||||
actionType: 'ajax',
|
||||
api: `get:${commonInfo.baseUrl}/chapter/remove/\${id}`,
|
||||
confirmText: '确认删除章节<span class="text-lg font-bold mx-2">${name}</span>?',
|
||||
confirmTitle: '删除',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Book)
|
||||
183
client/src/pages/book/Bookshelf.tsx
Normal file
183
client/src/pages/book/Bookshelf.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
amisRender,
|
||||
commonInfo,
|
||||
crudCommonOptions,
|
||||
horizontalFormOptions,
|
||||
paginationTemplate,
|
||||
time,
|
||||
} from '../../util/amis.tsx'
|
||||
import {useNavigate} from 'react-router'
|
||||
|
||||
const detailDialog = () => {
|
||||
return {
|
||||
title: '添加书架',
|
||||
size: 'md',
|
||||
body: {
|
||||
debug: commonInfo.debug,
|
||||
type: 'form',
|
||||
api: `${commonInfo.baseUrl}/book/save`,
|
||||
initApi: `${commonInfo.baseUrl}/book/detail/\${id}`,
|
||||
initFetchOn: '${id}',
|
||||
...horizontalFormOptions(),
|
||||
canAccessSuperData: false,
|
||||
body: [
|
||||
{
|
||||
type: 'input-text',
|
||||
name: 'name',
|
||||
label: '书名',
|
||||
required: true,
|
||||
clearable: true,
|
||||
},
|
||||
{
|
||||
type: 'input-text',
|
||||
name: 'author',
|
||||
label: '作者',
|
||||
clearable: true,
|
||||
},
|
||||
{
|
||||
type: 'textarea',
|
||||
name: 'description',
|
||||
label: '描述',
|
||||
clearable: true,
|
||||
},
|
||||
{
|
||||
type: 'input-text',
|
||||
name: 'source',
|
||||
label: '来源',
|
||||
clearable: true,
|
||||
validations: {
|
||||
isUrl: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input-tag',
|
||||
name: 'tags',
|
||||
label: '标签',
|
||||
placeholder: '',
|
||||
clearable: true,
|
||||
source: `${commonInfo.baseUrl}/book/tags`,
|
||||
max: 5,
|
||||
joinValues: false,
|
||||
extractValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function Bookshelf() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className="bookshelf">
|
||||
{amisRender(
|
||||
{
|
||||
type: 'page',
|
||||
title: '书架',
|
||||
body: [
|
||||
{
|
||||
type: 'crud',
|
||||
api: `${commonInfo.baseUrl}/book/list`,
|
||||
...crudCommonOptions(),
|
||||
...paginationTemplate(
|
||||
undefined,
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
type: 'action',
|
||||
label: '',
|
||||
icon: 'fa fa-plus',
|
||||
actionType: 'dialog',
|
||||
dialog: detailDialog(),
|
||||
},
|
||||
],
|
||||
),
|
||||
columns: [
|
||||
{
|
||||
name: 'name',
|
||||
label: '书名',
|
||||
width: 150,
|
||||
fixed: 'left',
|
||||
},
|
||||
{
|
||||
name: 'author',
|
||||
label: '作者',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: '描述',
|
||||
width: 250,
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
label: '来源',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
label: '标签',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
label: '创建时间',
|
||||
...time('createdTime'),
|
||||
},
|
||||
{
|
||||
label: '更新时间',
|
||||
...time('modifiedTime'),
|
||||
},
|
||||
{
|
||||
type: 'operation',
|
||||
label: '操作',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
buttons: [
|
||||
{
|
||||
type: 'action',
|
||||
label: '详情',
|
||||
level: 'link',
|
||||
onEvent: {
|
||||
click: {
|
||||
actions: [
|
||||
{
|
||||
actionType: 'custom',
|
||||
// @ts-ignore
|
||||
script: (context, action, event) => {
|
||||
navigate(`/bookshelf/book/${context.props.data['id']}`)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
label: '修改',
|
||||
level: 'link',
|
||||
size: 'sm',
|
||||
actionType: 'dialog',
|
||||
dialog: detailDialog(),
|
||||
},
|
||||
{
|
||||
className: 'text-danger btn-deleted',
|
||||
type: 'action',
|
||||
label: '删除',
|
||||
level: 'link',
|
||||
actionType: 'ajax',
|
||||
api: `get:${commonInfo.baseUrl}/book/remove/\${id}`,
|
||||
confirmText: '确认删除书籍<span class="text-lg font-bold mx-2">${name}</span>?',
|
||||
confirmTitle: '删除',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Bookshelf)
|
||||
144
client/src/pages/book/Chapter.tsx
Normal file
144
client/src/pages/book/Chapter.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React from 'react'
|
||||
import {useParams} from 'react-router'
|
||||
import {amisRender, commonInfo, crudCommonOptions, horizontalFormOptions, paginationTemplate} from '../../util/amis.tsx'
|
||||
|
||||
function Chapter() {
|
||||
// const navigate = useNavigate()
|
||||
const {id} = useParams()
|
||||
return (
|
||||
<div className="chapter">
|
||||
{amisRender(
|
||||
{
|
||||
type: 'page',
|
||||
title: '章节详情',
|
||||
initApi: `${commonInfo.baseUrl}/chapter/detail/${id}`,
|
||||
body: [
|
||||
{
|
||||
type: 'property',
|
||||
title: '${name}',
|
||||
items: [
|
||||
{label: '序号', content: '${sequence}'},
|
||||
{label: '名称', content: '${name}', span: 2},
|
||||
{label: '描述', content: '${description}', span: 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'crud',
|
||||
api: {
|
||||
method: 'post',
|
||||
url: `${commonInfo.baseUrl}/line/list`,
|
||||
convertKeyToPath: false,
|
||||
data: {
|
||||
query: {
|
||||
equal: {
|
||||
'chapter.id': id,
|
||||
},
|
||||
},
|
||||
page: {
|
||||
index: '${page}',
|
||||
size: '${perPage}',
|
||||
},
|
||||
sort: [
|
||||
{
|
||||
column: 'sequence',
|
||||
direction: 'ASC',
|
||||
},
|
||||
{
|
||||
column: 'modifiedTime',
|
||||
direction: 'DESC',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
...crudCommonOptions(),
|
||||
...paginationTemplate(
|
||||
undefined,
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
type: 'action',
|
||||
label: '',
|
||||
icon: 'fa fa-rotate-right',
|
||||
actionType: 'ajax',
|
||||
tooltip: '序号重排',
|
||||
tooltipPlacement: 'top',
|
||||
api: `get:${commonInfo.baseUrl}/line/generate_sequence`
|
||||
}
|
||||
]
|
||||
),
|
||||
columns: [
|
||||
{
|
||||
name: 'sequence',
|
||||
label: '序号',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
label: '章节名称',
|
||||
},
|
||||
{
|
||||
type: 'operation',
|
||||
label: '操作',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
buttons: [
|
||||
{
|
||||
type: 'action',
|
||||
label: '修改',
|
||||
level: 'link',
|
||||
size: 'sm',
|
||||
actionType: 'dialog',
|
||||
dialog: {
|
||||
title: '修改',
|
||||
size: 'md',
|
||||
body: {
|
||||
type: 'form',
|
||||
...horizontalFormOptions(),
|
||||
api: `post:${commonInfo.baseUrl}/line/save`,
|
||||
body: [
|
||||
{
|
||||
type: 'hidden',
|
||||
name: 'id',
|
||||
},
|
||||
{
|
||||
type: 'hidden',
|
||||
name: 'chapterId',
|
||||
value: id,
|
||||
},
|
||||
{
|
||||
type: 'editor',
|
||||
name: 'text',
|
||||
label: '章节内容',
|
||||
required: true,
|
||||
clearable: true,
|
||||
options: {
|
||||
wordWrap: 'on',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
className: 'text-danger btn-deleted',
|
||||
type: 'action',
|
||||
label: '删除',
|
||||
level: 'link',
|
||||
actionType: 'ajax',
|
||||
api: `get:${commonInfo.baseUrl}/line/remove/\${id}`,
|
||||
confirmText: '确认删除行?',
|
||||
confirmTitle: '删除',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Chapter)
|
||||
340
client/src/util/amis.tsx
Normal file
340
client/src/util/amis.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import {AlertComponent, attachmentAdpator, makeTranslator, render, type Schema, ToastComponent} from 'amis'
|
||||
import 'amis/lib/themes/antd.css'
|
||||
import 'amis/lib/helper.css'
|
||||
import 'amis/sdk/iconfont.css'
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||
import axios from 'axios'
|
||||
import {isEqual} from 'es-toolkit'
|
||||
|
||||
export const commonInfo = {
|
||||
debug: isEqual(import.meta.env.MODE, 'development'),
|
||||
baseUrl: isEqual(import.meta.env.MODE, 'development') ? 'http://localhost:27891' : '',
|
||||
}
|
||||
|
||||
const __ = makeTranslator('zh')
|
||||
|
||||
const responseAdaptor = () => (response: any) => {
|
||||
let payload = response.data || {} // blob 下可能会返回内容为空?
|
||||
if (payload.hasOwnProperty('errno')) {
|
||||
payload.status = payload.errno
|
||||
payload.msg = payload.errmsg
|
||||
} else if (payload.hasOwnProperty('no')) {
|
||||
payload.status = payload.no
|
||||
payload.msg = payload.error
|
||||
}
|
||||
return {
|
||||
...response,
|
||||
data: payload,
|
||||
}
|
||||
}
|
||||
|
||||
export const amisRender = (schema: Schema, data: Record<any, any> = {}) => {
|
||||
const theme = 'antd'
|
||||
const locale = 'zh-CN'
|
||||
return (
|
||||
<>
|
||||
<ToastComponent
|
||||
theme={theme}
|
||||
key="toast"
|
||||
position={'top-right'}
|
||||
locale={locale}
|
||||
/>
|
||||
<AlertComponent theme={theme} key="alert" locale={locale}/>
|
||||
{render(
|
||||
schema,
|
||||
{
|
||||
data: {
|
||||
...commonInfo,
|
||||
...data,
|
||||
},
|
||||
theme: theme,
|
||||
},
|
||||
{
|
||||
enableAMISDebug: commonInfo.debug,
|
||||
fetcher: async (api: any) => {
|
||||
let {url, method, data, responseType, config, headers} = api
|
||||
config = config || {}
|
||||
config.url = url
|
||||
config.withCredentials = true
|
||||
responseType && (config.responseType = responseType)
|
||||
|
||||
if (config.cancelExecutor) {
|
||||
config.cancelToken = new (axios as any).CancelToken(
|
||||
config.cancelExecutor,
|
||||
)
|
||||
}
|
||||
|
||||
config.headers = headers || {}
|
||||
config.method = method
|
||||
config.data = data
|
||||
|
||||
if (method === 'get' && data) {
|
||||
config.params = data
|
||||
} else if (data && data instanceof FormData) {
|
||||
// config.headers['Content-Type'] = 'multipart/form-data';
|
||||
} else if (
|
||||
data &&
|
||||
typeof data !== 'string' &&
|
||||
!(data instanceof Blob) &&
|
||||
!(data instanceof ArrayBuffer)
|
||||
) {
|
||||
data = JSON.stringify(data)
|
||||
config.headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
// 支持返回各种报错信息
|
||||
config.validateStatus = function () {
|
||||
return true
|
||||
}
|
||||
|
||||
let response = await axios(config)
|
||||
response = await attachmentAdpator(response, __, api)
|
||||
response = responseAdaptor()(response)
|
||||
|
||||
if (response.status >= 400) {
|
||||
if (response.data) {
|
||||
// 主要用于 raw: 模式下,后端自己校验登录,
|
||||
if (
|
||||
response.status === 401 &&
|
||||
response.data.location &&
|
||||
response.data.location.startsWith('http')
|
||||
) {
|
||||
location.href = response.data.location.replace(
|
||||
'{{redirect}}',
|
||||
encodeURIComponent(location.href),
|
||||
)
|
||||
return new Promise(() => {
|
||||
})
|
||||
} else if (response.data.msg) {
|
||||
throw new Error(response.data.msg)
|
||||
} else {
|
||||
throw new Error(
|
||||
'System.requestError' + JSON.stringify(response.data, null, 2),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`${'System.requestErrorStatus'} ${response.status}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
},
|
||||
isCancel: (value: any) => (axios as any).isCancel(value),
|
||||
},
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function horizontalFormOptions() {
|
||||
return {
|
||||
mode: 'horizontal',
|
||||
horizontal: {
|
||||
leftFixed: 'sm',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function crudCommonOptions() {
|
||||
return {
|
||||
affixHeader: false,
|
||||
stopAutoRefreshWhenModalIsOpen: true,
|
||||
resizable: false,
|
||||
syncLocation: false,
|
||||
silentPolling: true,
|
||||
columnsTogglable: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function readOnlyDialogOptions() {
|
||||
return {
|
||||
actions: [],
|
||||
showCloseButton: false,
|
||||
closeOnEsc: true,
|
||||
closeOnOutside: true,
|
||||
disabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function paginationCommonOptions(perPage = true, maxButtons = 5) {
|
||||
let option = {
|
||||
type: 'pagination',
|
||||
layout: [
|
||||
'pager',
|
||||
],
|
||||
maxButtons: maxButtons,
|
||||
showPageInput: false,
|
||||
perPageAvailable: [10, 15, 20, 50, 100, 200],
|
||||
}
|
||||
if (perPage) {
|
||||
option.layout.push('perPage')
|
||||
}
|
||||
return option
|
||||
}
|
||||
|
||||
export function paginationTemplate(perPage = 20, maxButtons = 5, extraHeaders: Array<Schema | string> = [], extraFooters: Array<Schema | string> = []) {
|
||||
return {
|
||||
perPage: perPage,
|
||||
headerToolbar: [
|
||||
'reload',
|
||||
paginationCommonOptions(true, maxButtons),
|
||||
...extraHeaders,
|
||||
],
|
||||
footerToolbar: [
|
||||
'statistics',
|
||||
paginationCommonOptions(true, maxButtons),
|
||||
...extraFooters,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function copyField(field: string, tips = '复制', ignoreLength = 0) {
|
||||
let tpl = ignoreLength === 0 ? `\${${field}}` : `\${TRUNCATE(${field}, ${ignoreLength})}`
|
||||
return {
|
||||
type: 'wrapper',
|
||||
size: 'none',
|
||||
body: [
|
||||
{
|
||||
type: 'tpl',
|
||||
className: 'mr-1',
|
||||
tpl: tpl,
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
level: 'link',
|
||||
label: '',
|
||||
icon: 'fa fa-copy',
|
||||
size: 'xs',
|
||||
actionType: 'copy',
|
||||
content: `\$${field}`,
|
||||
tooltip: `${tips}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function mappingItem(label: string, value: string, color = 'bg-info') {
|
||||
return {
|
||||
label: label,
|
||||
value: value,
|
||||
color: color,
|
||||
}
|
||||
}
|
||||
|
||||
export function mappingField(field: string, mapping: Array<Record<string, string>>) {
|
||||
let mapData: Record<string, string> = {
|
||||
'*': `<span class='label bg-gray-300'>\${${field}}</span>`,
|
||||
}
|
||||
mapping.forEach(item => {
|
||||
mapData[item['value']] = `<span class='label ${item['color']}'>${item['label']}</span>`
|
||||
})
|
||||
return {
|
||||
type: 'mapping',
|
||||
value: `\${${field}}`,
|
||||
map: mapData,
|
||||
}
|
||||
}
|
||||
|
||||
export function filterableField(mapping: Array<Record<string, any>>, multiple = false) {
|
||||
return {
|
||||
multiple: multiple,
|
||||
options: [
|
||||
...mapping,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function time(field: string) {
|
||||
return {
|
||||
width: 150,
|
||||
align: 'center',
|
||||
type: 'tpl',
|
||||
tpl: `\${IF(${field}, DATETOSTR(${field}, 'YYYY-MM-DD HH:mm:ss'), '/')}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function date(field: string) {
|
||||
return {
|
||||
width: 150,
|
||||
align: 'center',
|
||||
type: 'tpl',
|
||||
tpl: `\${IF(${field}, DATETOSTR(${field}, 'YYYY-MM-DD'), '/')}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function pictureFromIds(field: string) {
|
||||
return `\${ARRAYMAP(${field},id => '${commonInfo.baseUrl}/upload/download/' + id)}`
|
||||
}
|
||||
|
||||
export const formInputFileStaticColumns = [
|
||||
{
|
||||
name: 'filename',
|
||||
label: '文件名',
|
||||
},
|
||||
{
|
||||
type: 'operation',
|
||||
label: '操作',
|
||||
width: 140,
|
||||
buttons: [
|
||||
{
|
||||
type: 'action',
|
||||
label: '预览',
|
||||
level: 'link',
|
||||
icon: 'fas fa-eye',
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
label: '下载',
|
||||
level: 'link',
|
||||
icon: 'fa fa-download',
|
||||
actionType: 'ajax',
|
||||
// api: {
|
||||
// ...apiGet('${base}/upload/download/${id}'),
|
||||
// responseType: 'blob',
|
||||
// }
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function formInputSingleFileStatic(field: string, label: string) {
|
||||
return {
|
||||
visibleOn: '${static}',
|
||||
type: 'control',
|
||||
label: label,
|
||||
required: true,
|
||||
body: {
|
||||
type: 'table',
|
||||
source: `\${${field}|asArray}`,
|
||||
columns: formInputFileStaticColumns,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function formInputMultiFileStatic(field: string, label: string) {
|
||||
return {
|
||||
visibleOn: '${static}',
|
||||
type: 'input-table',
|
||||
label: label,
|
||||
name: field,
|
||||
required: true,
|
||||
resizable: false,
|
||||
columns: formInputFileStaticColumns,
|
||||
}
|
||||
}
|
||||
|
||||
export function remoteOptions(type: string = 'select', name: string) {
|
||||
return {
|
||||
type: type,
|
||||
source: `get:${commonInfo.baseUrl}/constants/options/${name}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function remoteMappings(name: string, field: string) {
|
||||
return {
|
||||
type: 'mapping',
|
||||
source: `get:${commonInfo.baseUrl}/constants/mappings/${name}/${field}`,
|
||||
}
|
||||
}
|
||||
3
client/src/vite-env.d.ts
vendored
Normal file
3
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
31
client/tsconfig.json
Normal file
31
client/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
39
client/vite.config.ts
Normal file
39
client/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import {defineConfig, type UserConfig} from 'vite'
|
||||
import obfuscatorPlugin from 'vite-plugin-javascript-obfuscator'
|
||||
// @ts-ignore
|
||||
import packageJson from './package.json'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({mode}) => {
|
||||
let config: UserConfig = {
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(packageJson.version) ?? '0.0.0',
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
obfuscatorPlugin({
|
||||
apply: config => config['mode'] === 'production',
|
||||
options: {
|
||||
compact: true,
|
||||
controlFlowFlattening: true,
|
||||
controlFlowFlatteningThreshold: 0.75,
|
||||
deadCodeInjection: true,
|
||||
deadCodeInjectionThreshold: 0.4,
|
||||
debugProtection: false,
|
||||
disableConsoleOutput: true,
|
||||
identifierNamesGenerator: 'hexadecimal',
|
||||
renameGlobals: false,
|
||||
stringArrayRotate: true,
|
||||
selfDefending: true,
|
||||
stringArray: true,
|
||||
stringArrayEncoding: ['base64'],
|
||||
stringArrayThreshold: 0.75,
|
||||
transformObjectKeys: true,
|
||||
unicodeEscapeSequence: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
return config
|
||||
})
|
||||
Reference in New Issue
Block a user