1
0

feat: 初始化提交

This commit is contained in:
2025-09-28 18:55:24 +08:00
commit 0a93e1d7ad
53 changed files with 4688 additions and 0 deletions

1931
client/bun.lock Normal file

File diff suppressed because it is too large Load Diff

32
client/index.html Normal file
View 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
View 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"
}
}

Binary file not shown.

BIN
client/public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View 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

View 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;
}
}

View 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()

View File

@@ -0,0 +1 @@
import './MarkdownEnhance.tsx'

9
client/src/index.scss Normal file
View 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
View 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)}/>,
)

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

@@ -0,0 +1,10 @@
import React from 'react'
function Test() {
return (
<div className="test">
</div>
)
}
export default React.memo(Test)

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

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

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

@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string

31
client/tsconfig.json Normal file
View 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
View 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
})