1
0

feat(all): 初始化版本

This commit is contained in:
2025-08-30 10:10:11 +08:00
commit 7f3b61b854
55 changed files with 11289 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
import 'chart.js/auto'
import {MermaidDiagram} from '@lightenna/react-mermaid-diagram'
import EChartsReact from 'echarts-for-react'
import {trim} from 'licia'
import {Chart} from 'react-chartjs-2'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
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 {once} from "licia";
import React from "react";
import Markdown from "../Markdown.tsx";
import './MarkdownEnhance.scss'
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'

View File

@@ -0,0 +1,4 @@
// 改写一些amis中控制不到的全局CSS
button.btn-deleted:hover {
color: #dc2626 !important;
}

70
leopard-web/src/index.tsx Normal file
View File

@@ -0,0 +1,70 @@
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 StockList from "./pages/stock/StockList.tsx";
import StockDetail from './pages/stock/StockDetail.tsx';
import TaskList from "./pages/task/TaskList.tsx";
import TaskAdd from './pages/task/TaskAdd.tsx';
const routes: RouteObject[] = [
{
path: '/',
Component: Root,
children: [
{
index: true,
element: <Navigate to="/overview" replace/>,
},
{
path: 'overview',
Component: Overview,
},
{
path: 'stock',
children: [
{
index: true,
element: <Navigate to="/stock/list" replace/>,
},
{
path: 'list',
Component: StockList,
},
{
path: 'detail/:id',
Component: StockDetail,
}
]
},
{
path: 'task',
children: [
{
index: true,
element: <Navigate to="/task/list" replace/>,
},
{
path: 'list',
Component: TaskList,
},
{
path: 'add',
Component: TaskAdd,
},
]
},
{
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)

View File

@@ -0,0 +1,130 @@
import {
DeploymentUnitOutlined,
InfoCircleOutlined,
MoneyCollectOutlined,
UnorderedListOutlined
} from "@ant-design/icons";
import {type AppItemProps, ProLayout} from '@ant-design/pro-components'
import {ConfigProvider} from 'antd'
import {dateFormat} from 'licia'
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: '/stock',
name: '股票',
icon: <MoneyCollectOutlined/>,
}, {
path: '/task',
name: '任务',
icon: <UnorderedListOutlined/>,
},
{
path: '/test',
name: '测试',
icon: <DeploymentUnitOutlined/>,
},
],
},
],
}
const Root: React.FC = () => {
const location = useLocation()
const currentYear = useMemo(() => dateFormat(new Date(), 'yyyy'), [])
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: 'sub'}}
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="text-xs text-center" 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

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,39 @@
import React from 'react'
import {useParams} from 'react-router'
import {amisRender, commonInfo, remoteMappings} from '../../util/amis.tsx'
function StockDetail() {
const {id} = useParams()
return (
<div className="stock-detail">
{amisRender(
{
type: 'page',
title: '股票详情(${code} ${name}',
initApi: `get:${commonInfo.baseUrl}/stock/detail/${id}`,
body: [
{
type: 'property',
items: [
{label: '编码', content: '${code}'},
{label: '名称', content: '${name}'},
{label: '全名', content: '${fullname}'},
{
label: '市场',
content: {
...remoteMappings('stock_market', 'market'),
value: '${market}',
},
},
{label: '行业', content: '${industry}'},
],
},
{type: 'divider'},
],
},
)}
</div>
)
}
export default React.memo(StockDetail)

View File

@@ -0,0 +1,159 @@
import React from 'react'
import {
amisRender,
commonInfo,
crudCommonOptions,
paginationTemplate,
remoteMappings,
remoteOptions,
} from '../../util/amis.tsx'
import {useNavigate} from 'react-router'
function StockList() {
const navigate = useNavigate()
return (
<div className="stock-list">
{amisRender(
{
type: 'page',
title: '股票列表',
body: [
{
type: 'crud',
api: {
method: 'post',
url: `${commonInfo.baseUrl}/stock/list`,
data: {
query: {
contain: {
code: '${filter_code|default:undefined}',
name: '${filter_keyword|default:undefined}',
fullname: '${filter_keyword|default:undefined}',
},
inside: {
market: '${filter_market|default:undefined}',
industry: '${filter_industry|default:undefined}',
},
},
page: {
index: '${page}',
size: '${perPage}',
},
sort: [
{
column: 'code',
direction: 'ASC',
},
],
},
},
...crudCommonOptions(),
...paginationTemplate(15, undefined, ['filter-toggler']),
filterTogglable: true,
filterDefaultVisible: false,
filter: {
title: '快速搜索',
mode: 'default',
columnCount: 4,
body: [
{
type: 'input-text',
name: 'filter_code',
label: '编号',
placeholder: '请输入编号',
clearable: true,
},
{
type: 'input-text',
name: 'filter_keyword',
label: '关键字',
placeholder: '请输入关键字',
clearable: true,
},
{
name: 'filter_market',
label: '市场',
...remoteOptions('select', 'stock_market'),
multiple: true,
extractValue: true,
joinValues: false,
clearable: true,
checkAll: true,
checkAllBySearch: true,
defaultCheckAll: true,
},
{
name: 'filter_industry',
label: '行业',
...remoteOptions('select', 'stock_industry'),
searchable: true,
multiple: true,
extractValue: true,
joinValues: false,
clearable: true,
checkAll: true,
checkAllBySearch: true,
},
],
},
columns: [
{
name: 'code',
label: '编号',
width: 150,
},
{
name: 'name',
label: '简称',
width: 150,
},
{
name: 'fullname',
label: '全名',
},
{
name: 'market',
label: '市场',
width: 100,
...remoteMappings('stock_market', 'market'),
},
{
name: 'industry',
label: '行业',
width: 150,
},
{
type: 'operation',
label: '操作',
width: 100,
buttons: [
{
type: 'action',
label: '详情',
level: 'link',
onEvent: {
click: {
actions: [
{
actionType: 'custom',
// @ts-ignore
script: (context, action, event) => {
navigate(`/stock/detail/${context.props.data['id']}`)
},
},
],
},
},
},
],
},
],
},
],
},
)}
</div>
)
}
export default React.memo(StockList)

View File

@@ -0,0 +1,27 @@
import React from 'react'
import {amisRender, commonInfo} from '../../util/amis.tsx'
function TaskAdd() {
return (
<div className="task-add">
{amisRender(
{
type: 'page',
title: '任务添加',
body: [
{
debug: commonInfo.debug,
type: 'form',
wrapWithPanel: false,
mode: 'horizontal',
labelAlign: 'left',
body: [],
},
],
},
)}
</div>
)
}
export default React.memo(TaskAdd)

View File

@@ -0,0 +1,113 @@
import React from 'react'
import {amisRender, commonInfo, crudCommonOptions, paginationTemplate, remoteMappings} from '../../util/amis.tsx'
import {useNavigate} from 'react-router'
function TaskList() {
const navigate = useNavigate()
return (
<div className="task-list">
{amisRender(
{
type: 'page',
title: '任务列表',
body: [
{
type: 'crud',
api: {
method: 'post',
url: `${commonInfo.baseUrl}/task/list`,
data: {
page: {
index: '${page}',
size: '${perPage}',
},
},
},
...crudCommonOptions(),
...paginationTemplate(
15,
undefined,
[
{
type: 'action',
label: '',
icon: 'fa fa-plus',
tooltip: '添加任务',
tooltipPlacement: 'top',
onEvent: {
click: {
actions: [
{
actionType: 'custom',
// @ts-ignore
script: (context, action, event) => {
navigate('/task/add')
},
},
],
},
},
},
],
),
columns: [
{
name: 'name',
label: '简称',
width: 150,
},
{
name: 'description',
label: '描述',
},
{
name: 'status',
label: '状态',
width: 100,
...remoteMappings('task_status', 'status'),
},
{
name: 'launchedTime',
label: '启动时间',
width: 100,
},
{
name: 'finishedTime',
label: '结束时间',
width: 100,
},
{
type: 'operation',
label: '操作',
width: 100,
buttons: [
{
type: 'action',
label: '详情',
level: 'link',
onEvent: {
click: {
actions: [
{
actionType: 'custom',
// @ts-ignore
script: (context, action, event) => {
navigate(`/task/detail/${context.props.data['id']}`)
},
},
],
},
},
},
],
},
],
},
],
},
)}
</div>
)
}
export default React.memo(TaskList)

View File

@@ -0,0 +1,330 @@
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 'licia'
export const commonInfo = {
debug: isEqual(import.meta.env.MODE, 'development'),
baseUrl: 'http://localhost:9786',
}
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 {
type: 'tpl',
tpl: `\${IF(${field}, DATETOSTR(${field}, 'YYYY-MM-DD HH:mm:ss'), undefined)}`,
}
}
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
leopard-web/src/vite-env.d.ts vendored Normal file
View File

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