初始化应用

This commit is contained in:
熊潇 2025-06-20 02:33:53 +08:00
parent e56eaab69a
commit f756082399
31 changed files with 9369 additions and 782 deletions

3
.npmrc
View File

@ -1,3 +1,4 @@
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
ignore-workspace-root-check=true
ignore-workspace-root-check=true
auto-approve-builds = true

View File

@ -1 +1,30 @@
# vite-react-template
## env
安装 node 环境
安装后安装一些基本运行库
```sh
npm i -g bun pnpm
```
## build
打包程序
```sh
pnpm install
pnpm build
```
## backend server
启动服务
```sh
pnpm server
```
## 访问地址
`http://localhost:3004/root/tickets/`

13
backend/.cnb.yml Normal file
View File

@ -0,0 +1,13 @@
# .cnb.yml
$:
vscode:
- docker:
image: docker.cnb.cool/kevisual/dev-env:latest
services:
- vscode
- docker
imports: https://cnb.cool/kevisual/env/-/blob/main/env.yml
# 开发环境启动后会执行的任务
stages:
- name: pnpm install
script: pnpm install

70
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,70 @@
node_modules
# mac
.DS_Store
.env*
!.env*example
dist
build
logs
.turbo
pack-dist
# astro
.astro
# next
.next
# nuxt
.nuxt
# vercel
.vercel
# vuepress
.vuepress/dist
# coverage
coverage/
# typescript
*.tsbuildinfo
# debug logs
*.log
*.tmp
# vscode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# idea
.idea
# system
Thumbs.db
ehthumbs.db
Desktop.ini
# temp files
*.tmp
*.temp
# local development
*.local
public/r
.pnpm-store
storage/
pages

3
backend/.npmrc Normal file
View File

@ -0,0 +1,3 @@
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
ignore-workspace-root-check=true

25
backend/bun.config.mjs Normal file
View File

@ -0,0 +1,25 @@
// @ts-check
import { resolvePath } from '@kevisual/use-config/env';
import { execSync } from 'node:child_process';
const entry = 'src/index.ts';
const naming = 'app';
const external = ['sequelize', 'pg', 'sqlite3', 'minio', '@kevisual/router', 'pm2'];
/**
* @type {import('bun').BuildConfig}
*/
await Bun.build({
target: 'node',
format: 'esm',
entrypoints: [resolvePath(entry, { meta: import.meta })],
outdir: resolvePath('./dist', { meta: import.meta }),
naming: {
entry: `${naming}.js`,
},
external: external,
env: 'KEVISUAL_*',
});
// const cmd = `dts -i src/index.ts -o app.d.ts`;
const cmd = `dts -i ${entry} -o ${naming}.d.ts`;
execSync(cmd, { stdio: 'inherit' });

22
backend/kevisual.json Normal file
View File

@ -0,0 +1,22 @@
{
"sync": {
".gitignore": {
"url": "https://kevisual.xiongxiao.me/root/ai/code/config/gitignore/node.txt"
},
".npmrc": {
"url": "https://kevisual.xiongxiao.me/root/ai/code/config/npm/.npmrc"
},
"tsconfig.json": {
"url": "https://kevisual.xiongxiao.me/root/ai/code/config/ts/backend.json"
},
"bun.config.mjs": {
"url": "https://kevisual.xiongxiao.me/root/ai/code/config/bun/bun.config.mjs"
},
".cnb.yml": {
"url": "https://kevisual.xiongxiao.me/root/ai/code/config/cnb/dev.yml"
},
"package.json": {
"url": "https://kevisual.xiongxiao.me/root/ai/code/config/npm/back-01-base/package.json"
}
}
}

64
backend/package.json Normal file
View File

@ -0,0 +1,64 @@
{
"name": "ticket-services",
"version": "0.0.1",
"description": "",
"main": "index.js",
"basename": "/root/ticket-services",
"app": {
"key": "ticket-services",
"entry": "dist/app.js",
"type": "system-app"
},
"files": [
"dist"
],
"scripts": {
"dev": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 bun --watch src/dev.ts ",
"build": "rimraf dist && bun run bun.config.mjs",
"test": "tsx test/**/*.ts",
"clean": "rm -rf dist",
"pub": "npm run build && envision pack -p -u",
"cmd": "tsx cmd/index.ts "
},
"keywords": [],
"author": "abearxiong <xiongxiao@xiongxiao.me>",
"license": "MIT",
"type": "module",
"types": "types/index.d.ts",
"publishConfig": {
"access": "public"
},
"dependencies": {
"@kevisual/code-center-module": "0.0.23",
"@kevisual/router": "0.0.23",
"@kevisual/use-config": "^1.0.19",
"cookie": "^1.0.2",
"dayjs": "^1.11.13",
"formidable": "^3.5.4",
"lodash-es": "^4.17.21"
},
"devDependencies": {
"@kevisual/context": "^0.0.3",
"@kevisual/local-proxy": "^0.0.6",
"@kevisual/types": "^0.0.10",
"@kevisual/use-config": "^1.0.19",
"@types/bun": "^1.2.16",
"@types/crypto-js": "^4.2.2",
"@types/formidable": "^3.4.5",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.0.3",
"commander": "^14.0.0",
"concurrently": "^9.1.2",
"cross-env": "^7.0.3",
"inquire": "^0.4.8",
"ioredis": "^5.6.1",
"nodemon": "^3.1.10",
"pg": "^8.16.1",
"rimraf": "^6.0.1",
"sequelize": "^6.37.7",
"sqlite3": "^5.1.7",
"tape": "^5.9.0",
"typescript": "^5.8.3"
},
"packageManager": "pnpm@10.12.1"
}

3572
backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
onlyBuiltDependencies:
- sqlite3

7
backend/src/app.ts Normal file
View File

@ -0,0 +1,7 @@
import { App } from '@kevisual/router';
import { useContextKey } from '@kevisual/context';
export const app = useContextKey<App>('app', () => {
return new App();
});

1
backend/src/dev.ts Normal file
View File

@ -0,0 +1 @@
import './index.ts';

15
backend/src/index.ts Normal file
View File

@ -0,0 +1,15 @@
import { proxyRoute, initProxy } from '@kevisual/local-proxy/proxy.ts';
initProxy({
pagesDir: './pages',
watch: true,
home: '/root/tickets',
});
import { app } from './app.ts';
import './routes/ticket/list.ts';
app.listen(3004, () => {
console.log('Server is running on http://localhost:3004');
});
app.onServerRequest(proxyRoute);

View File

@ -0,0 +1,9 @@
import { Sequelize } from 'sequelize';
import path from 'node:path';
const sqlitePath = path.join(process.cwd(), 'storage', 'ticket', 'db.sqlite');
export const sequelize = new Sequelize({
dialect: 'sqlite',
storage: sqlitePath,
logging: false,
});

View File

@ -0,0 +1,126 @@
import { app } from '@/app.ts';
// import mockTickets from './mock/data.ts';
import { TicketModel } from './model.ts';
import { Sequelize, Op, WhereOptions } from 'sequelize';
// 定义搜索参数类型
type SearchTicketsParams = {
current?: number;
pageSize?: number;
search?: string;
status?: string;
type?: string;
sort?: string;
order?: 'asc' | 'desc';
uid?: string;
startDate?: string;
endDate?: string;
};
app
.route({
path: 'ticket',
key: 'list',
})
.define(async (ctx) => {
const searchForm = ctx.query?.data || {} as SearchTicketsParams;
const {
current = 1,
pageSize = 10,
search,
status,
type,
sort = 'createdAt',
order = 'desc',
uid,
startDate,
endDate
} = searchForm;
// 构建查询条件
let where: any = {};
// 如果提供了搜索关键词,同时搜索标题和描述
if (search) {
where[Op.or] = [
{ title: { [Op.like]: `%${search}%` } },
{ description: { [Op.like]: `%${search}%` } }
];
}
// 添加其他过滤条件
if (status) where.status = status;
if (type) where.type = type;
if (uid) where.uid = uid;
// 日期范围过滤
if (startDate || endDate) {
const dateFilter: any = {};
if (startDate) dateFilter[Op.gte] = new Date(startDate);
if (endDate) dateFilter[Op.lte] = new Date(endDate);
where.createdAt = dateFilter;
}
// 验证排序字段是否有效防止SQL注入
const validSortFields = ['id', 'title', 'status', 'type', 'price', 'createdAt', 'updatedAt'];
const sortField = validSortFields.includes(sort) ? sort : 'createdAt';
const sortOrder = order?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const { rows, count } = await TicketModel.findAndCountAll({
where,
offset: (current - 1) * pageSize,
limit: pageSize,
order: [[sortField, sortOrder]],
});
ctx.body = {
list: rows,
pagination: {
total: count,
current: Number(current),
pageSize: Number(pageSize),
totalPages: Math.ceil(count / Number(pageSize)),
},
};
})
.addTo(app);
app
.route({
path: 'ticket',
key: 'update',
})
.define(async (ctx) => {
const data = ctx.query?.data || {};
const { id, ...updateData } = data;
if (!id) {
ctx.throw(400, 'ID is required for update');
}
const ticket = await TicketModel.findByPk(id);
if (!ticket) {
ctx.throw(404, 'Ticket not found');
}
await ticket.update(updateData);
ctx.body = ticket;
})
.addTo(app);
app
.route({
path: 'ticket',
key: 'delete',
})
.define(async (ctx) => {
const data = ctx.query?.data || {};
const { id } = data;
if (!id) {
ctx.throw(400, 'ID is required for deletion');
}
const ticket = await TicketModel.findByPk(id);
if (!ticket) {
ctx.throw(404, 'Ticket not found');
}
await ticket.destroy();
ctx.body = { message: 'Ticket deleted successfully' };
})
.addTo(app);

View File

@ -0,0 +1,57 @@
const ticketStatus = ['rule', 'people'] as const;
export type Ticket = {
id: string;
type: string;
title: string;
description: string;
status: (typeof ticketStatus)[number];
price: string;
createdAt: string;
updatedAt: string;
uid: string;
};
// 票务类型列表
const ticketTypes = ['演唱会', '话剧', '电影', '展览', '体育赛事'];
// 生成随机日期字符串过去30天内
const generateRandomDate = () => {
const now = new Date();
const pastDays = Math.floor(Math.random() * 30);
const date = new Date(now.getTime() - pastDays * 24 * 60 * 60 * 1000);
return date.toISOString();
};
// 生成随机价格100-2000元
const generateRandomPrice = () => {
return `¥${Math.floor(Math.random() * 1900 + 100)}`;
};
// 生成随机ID
const generateRandomId = () => {
return Math.random().toString(36).substring(2, 10);
};
// 生成20条模拟数据
export const mockTickets: Ticket[] = Array.from({ length: 20 }, (_, index) => {
const createdAt = generateRandomDate();
const updatedAt = new Date(new Date(createdAt).getTime() + Math.random() * 24 * 60 * 60 * 1000).toISOString();
const type = ticketTypes[Math.floor(Math.random() * ticketTypes.length)];
const status = ticketStatus[Math.floor(Math.random() * ticketStatus.length)];
return {
id: `ticket-${generateRandomId()}`,
type,
title: `${type}票务-${index + 1}`,
description: `这是一张${type}的门票,编号为${index + 1},提供优质观演体验。`,
status,
price: generateRandomPrice(),
createdAt,
updatedAt,
uid: `user-${generateRandomId()}`,
};
});
// 导出默认数据
export default mockTickets;

View File

@ -0,0 +1,80 @@
import { DataTypes, Model, UUIDV4 } from 'sequelize';
import { sequelize } from '@/modules/sequelize.ts';
// 定义票据状态类型
const ticketStatus = ['rule', 'people'] as const;
export type Ticket = {
id: string;
type: string;
title: string;
description: string;
status: (typeof ticketStatus)[number];
price: string;
createdAt: string;
updatedAt: string;
uid: string;
};
// 创建Ticket模型类
export class TicketModel extends Model<Ticket> implements Ticket {
declare id: string;
declare type: string;
declare title: string;
declare description: string;
declare status: (typeof ticketStatus)[number];
declare price: string;
declare createdAt: string;
declare updatedAt: string;
declare uid: string;
}
// 初始化模型
TicketModel.init(
{
id: {
type: DataTypes.UUID,
defaultValue: UUIDV4,
primaryKey: true,
},
type: {
type: DataTypes.STRING,
allowNull: false,
},
title: {
type: DataTypes.STRING,
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
status: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'rule',
},
price: {
type: DataTypes.STRING,
allowNull: false,
},
uid: {
type: DataTypes.STRING,
allowNull: false,
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
},
},
{
sequelize,
tableName: 'tickets',
timestamps: true,
},
);
await TicketModel.sync({ alter: true })

View File

@ -0,0 +1,18 @@
import { mockTickets } from './../routes/ticket/mock/data.ts';
import { TicketModel } from '@/routes/ticket/model.ts';
export const main = async () => {
// Clear existing data
// await TicketModel.destroy({ where: {}, truncate: true });
// Insert mock tickets
for (const ticket of mockTickets) {
delete ticket.id; // Remove id to let Sequelize generate it
await TicketModel.create(ticket);
}
console.log('Mock tickets inserted successfully.');
};
main()

18
backend/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"extends": "@kevisual/types/json/backend.json",
"compilerOptions": {
"baseUrl": ".",
"typeRoots": [
"./node_modules/@types",
"./node_modules/@kevisual"
],
"paths": {
"@/*": [
"src/*"
]
},
},
"include": [
"src/**/*",
],
}

View File

@ -3,15 +3,16 @@
"private": true,
"version": "0.0.1",
"type": "module",
"basename": "/",
"basename": "/root/tickets",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:css": "tailwindcss -i ./src/index.css -o ./dist/render.css --minify",
"postbuild2": "pnpm build:css",
"postbuild": "rsync -av --delete ./dist/* ./backend/pages/root/tickets",
"preview": "vite preview",
"pub": "envision deploy ./dist -k vite-react -v 0.0.1",
"dev:lib": "turbo dev"
"serve": "cd backend && bun --watch src/dev.ts"
},
"files": [
"dist"
@ -19,33 +20,37 @@
"author": "abearxiong <xiongxiao@xiongxiao.me>",
"license": "MIT",
"dependencies": {
"@kevisual/router": "0.0.9",
"@ant-design/icons": "^6.0.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@kevisual/router": "0.0.22",
"antd": "^5.26.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"lodash-es": "^4.17.21",
"lucide-react": "^0.487.0",
"lucide-react": "^0.518.0",
"nanoid": "^5.1.5",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-toastify": "^11.0.5",
"zustand": "^5.0.3"
"zustand": "^5.0.5"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@kevisual/query": "0.0.15",
"@kevisual/types": "^0.0.6",
"@tailwindcss/vite": "^4.1.1",
"@types/node": "^22.13.17",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.1",
"@kevisual/query": "0.0.29",
"@kevisual/types": "^0.0.10",
"@tailwindcss/vite": "^4.1.10",
"@types/node": "^24.0.3",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-basic-ssl": "^2.0.0",
"@vitejs/plugin-react": "^4.3.4",
"commander": "^13.1.0",
"tailwindcss": "^4.1.1",
"typescript": "^5.8.2",
"vite": "^6.2.4"
"@vitejs/plugin-react": "^4.5.2",
"commander": "^14.0.0",
"tailwindcss": "^4.1.10",
"typescript": "^5.8.3",
"vite": "^6.3.5"
},
"packageManager": "pnpm@10.7.1"
"packageManager": "pnpm@10.12.1",
"pnpm": {}
}

5300
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

7
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,7 @@
packages:
- backend
onlyBuiltDependencies:
- sqlite3
- "@tailwindcss/oxide"
- esbuild

15
prompts/ticket.md Normal file
View File

@ -0,0 +1,15 @@
# ticket
### 整体的开发大纲
前端
- 前端的工单页面
- 表格页面
- 弹窗表单
- 数据获取
后端
- 后端的工单列表
- 工单的增删改查

View File

@ -1,6 +1,5 @@
import { createRoot } from 'react-dom/client';
import { App } from './pages/App.tsx';
import { App } from './pages/App';
// import './index.css';
createRoot(document.getElementById('root')!).render(<App />);

View File

@ -1,3 +1,3 @@
import { QueryClient } from '@kevisual/query';
import { Query } from '@kevisual/query';
export const query = new QueryClient();
export const query = new Query();

View File

@ -1,87 +1,13 @@
import { useState } from 'react';
import { Box, Button, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton } from '@mui/material';
import { Edit, Delete } from 'lucide-react';
import { ModalForm } from './ModalForm';
// 定义工单类型
interface Ticket {
id: number;
title: string;
status: string;
priority: string;
createTime: string;
}
import { ToastContainer } from 'react-toastify';
import { ConfigProvider } from 'antd';
import '@ant-design/v5-patch-for-react-19';
import { Ticket } from './ticket/Ticket';
export const App = () => {
const [tickets, setTickets] = useState<Ticket[]>([]);
const [open, setOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const handleCreate = () => {
setSelectedTicket(null);
setOpen(true);
};
const handleEdit = (ticket: Ticket) => {
setSelectedTicket(ticket);
setOpen(true);
};
const handleDelete = (id: number) => {
setTickets(tickets.filter((ticket) => ticket.id !== id));
};
const handleSave = (data: Ticket) => {
if (selectedTicket) {
setTickets(tickets.map((t) => (t.id === selectedTicket.id ? data : t)));
} else {
setTickets([...tickets, { ...data, id: Date.now() }]);
}
setOpen(false);
};
return (
<Box className='p-4'>
<Box className='mb-4 flex justify-between'>
<h1 className='text-2xl font-bold'></h1>
<Button variant='contained' onClick={handleCreate}>
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{tickets.map((ticket) => (
<TableRow key={ticket.id}>
<TableCell>{ticket.title}</TableCell>
<TableCell>{ticket.status}</TableCell>
<TableCell>{ticket.priority}</TableCell>
<TableCell>{ticket.createTime}</TableCell>
<TableCell>
<IconButton onClick={() => handleEdit(ticket)}>
<Edit className='w-4 h-4' />
</IconButton>
<IconButton onClick={() => handleDelete(ticket.id)}>
<Delete className='w-4 h-4' />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<ModalForm open={open} onClose={() => setOpen(false)} onSave={handleSave} ticket={selectedTicket} />
</Box>
<ConfigProvider>
<Ticket />
<ToastContainer />
</ConfigProvider>
);
};

View File

@ -1,121 +0,0 @@
import { useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
MenuItem,
Box
} from '@mui/material';
interface Ticket {
id: number;
title: string;
status: string;
priority: string;
createTime: string;
}
interface ModalFormProps {
open: boolean;
onClose: () => void;
onSave: (data: Ticket) => void;
ticket: Ticket | null;
}
export const ModalForm = ({ open, onClose, onSave, ticket }: ModalFormProps) => {
const { control, handleSubmit, reset } = useForm<Ticket>({
defaultValues: {
title: '',
status: '待处理',
priority: '中',
createTime: new Date().toLocaleString()
}
});
useEffect(() => {
if (ticket) {
reset(ticket);
} else {
reset({
title: '',
status: '待处理',
priority: '中',
createTime: new Date().toLocaleString()
});
}
}, [ticket, reset]);
const onSubmit = (data: Ticket) => {
onSave(data);
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{ticket ? '编辑工单' : '创建工单'}</DialogTitle>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogContent>
<Box className="space-y-4">
<Controller
name="title"
control={control}
rules={{ required: '请输入标题' }}
render={({ field, fieldState }) => (
<TextField
{...field}
label="标题"
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
<Controller
name="status"
control={control}
render={({ field }) => (
<TextField
{...field}
select
label="状态"
fullWidth
>
<MenuItem value="待处理"></MenuItem>
<MenuItem value="处理中"></MenuItem>
<MenuItem value="已完成"></MenuItem>
</TextField>
)}
/>
<Controller
name="priority"
control={control}
render={({ field }) => (
<TextField
{...field}
select
label="优先级"
fullWidth
>
<MenuItem value="高"></MenuItem>
<MenuItem value="中"></MenuItem>
<MenuItem value="低"></MenuItem>
</TextField>
)}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}></Button>
<Button type="submit" variant="contained">
</Button>
</DialogActions>
</form>
</Dialog>
);
};

12
src/pages/define/type.ts Normal file
View File

@ -0,0 +1,12 @@
const ticketStatus = ['rule', 'people'] as const;
export type Ticket = {
id: string;
type: string;
title: string;
description: string;
status: (typeof ticketStatus)[number];
price: string;
createdAt: string;
updatedAt: string;
uid: string;
};

307
src/pages/ticket/Ticket.tsx Normal file
View File

@ -0,0 +1,307 @@
import { useEffect, useState } from 'react';
import { Modal, Table, Form, Input, Button, Space, Select, DatePicker, message, Popconfirm } from 'antd';
import { SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { useTicketStore } from '@/store';
import type { Ticket as TicketType } from '@/pages/define/type';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
export const Ticket = () => {
const [searchForm] = Form.useForm();
const store = useTicketStore();
useEffect(() => {
fetchTickets();
}, [store.searchForm]);
const fetchTickets = async () => {
try {
store.setLoading(true);
await store.getTickets(store.searchForm);
} catch (error) {
message.error('获取票据列表失败');
} finally {
store.setLoading(false);
}
};
const handleSearch = async (values: any) => {
store.setSearchForm({
...store.searchForm,
...values,
current: 1,
startDate: values.dateRange?.[0]?.format('YYYY-MM-DD'),
endDate: values.dateRange?.[1]?.format('YYYY-MM-DD'),
});
};
const handleReset = () => {
searchForm.resetFields();
store.setSearchForm({
current: 1,
pageSize: 10,
});
};
const handleAdd = () => {
store.setFormData(null);
store.setShowEdit(true);
};
const handleEdit = (record: TicketType) => {
store.setFormData(record);
store.setShowEdit(true);
};
const handleDelete = async (id: string) => {
try {
store.setLoading(true);
await store.deleteTicket(id);
} catch (error) {
} finally {
store.setLoading(false);
}
};
const columns: ColumnsType<TicketType> = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 320,
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 120,
},
{
title: '标题',
dataIndex: 'title',
key: 'title',
width: 200,
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
},
{
title: '价格',
dataIndex: 'price',
key: 'price',
width: 100,
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (text) => (text ? dayjs(text).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (text) => (text ? dayjs(text).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '操作',
key: 'action',
width: 150,
render: (_, record) => (
<Space size='middle'>
<Button type='text' icon={<EditOutlined />} onClick={() => handleEdit(record)} />
<Popconfirm title='确定要删除这条记录吗?' onConfirm={() => handleDelete(record.id)} okText='确定' cancelText='取消'>
<Button type='text' danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div className='ticket-container p-4 h-full overflow-auto scrollbar'>
<h1></h1>
{/* 搜索表单 */}
<div className='search-form-container' style={{ marginBottom: 16, padding: 16, background: '#f9f9f9', borderRadius: 4 }}>
<Form form={searchForm} layout='inline' onFinish={handleSearch}>
<Form.Item name='search' label='关键词'>
<Input placeholder='标题/描述' allowClear />
</Form.Item>
<Form.Item name='type' label='类型'>
<Select
placeholder='选择类型'
allowClear
style={{ width: 120 }}
options={[
{ value: 'type1', label: '类型1' },
{ value: 'type2', label: '类型2' },
]}
/>
</Form.Item>
<Form.Item name='status' label='状态'>
<Select
placeholder='选择状态'
allowClear
style={{ width: 120 }}
options={[
{ value: 'rule', label: '规则' },
{ value: 'people', label: '人工' },
]}
/>
</Form.Item>
<Form.Item name='dateRange' label='日期范围'>
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type='primary' htmlType='submit' icon={<SearchOutlined />}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</Form.Item>
</Form>
</div>
{/* 操作按钮 */}
<div style={{ marginBottom: 16 }}>
<Button type='primary' icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
</div>
{/* 表格 */}
<Table
columns={columns}
dataSource={store.tickets}
rowKey='id'
loading={store.loading}
pagination={{
...store.pagination,
current: store.searchForm.current,
pageSize: store.searchForm.pageSize,
onChange: (page, pageSize) => {
store.setSearchForm({
...store.searchForm,
current: page,
pageSize,
});
},
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
}}
/>
{/* 引入弹窗表单组件 */}
<TicketModelForm />
</div>
);
};
export const TicketModelForm = () => {
const [form] = Form.useForm();
const store = useTicketStore();
const isEdit = !!store.formData?.id;
useEffect(() => {
if (!store.showEdit) {
return;
}
if (store.formData) {
form.setFieldsValue(store.formData);
} else {
// form.resetFields();
form.setFieldsValue({ title: '', type: '', status: '', price: '', description: '' });
}
}, [store.showEdit, store.formData, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
store.setLoading(true);
if (isEdit) {
// 编辑模式
await store.updateTicket({
...store.formData,
...values,
});
} else {
// 新增模式 - 假设后端会生成ID这里模拟API调用
const newTicket = {
...values,
};
// 这里应该是一个创建票据的API调用
// 暂时使用更新方法模拟
await store.updateTicket(newTicket as TicketType);
}
// 关闭弹窗并刷新列表
store.setShowEdit(false);
store.getTickets(store.searchForm);
} catch (error) {
console.error('表单提交错误:', error);
message.error('操作失败,请检查表单');
} finally {
store.setLoading(false);
}
};
return (
<Modal
title={isEdit ? '编辑票据' : '新增票据'}
open={store.showEdit}
onCancel={() => store.setShowEdit(false)}
onOk={handleSubmit}
confirmLoading={store.loading}
maskClosable={false}>
<Form form={form} layout='vertical' initialValues={store.formData || {}}>
<Form.Item name='title' label='标题' rules={[{ required: true, message: '请输入标题' }]}>
<Input placeholder='请输入标题' />
</Form.Item>
<Form.Item name='type' label='类型' rules={[{ required: true, message: '请选择类型' }]}>
<Select
placeholder='请选择类型'
options={[
{ value: 'type1', label: '类型1' },
{ value: 'type2', label: '类型2' },
]}
/>
</Form.Item>
<Form.Item name='status' label='状态' rules={[{ required: true, message: '请选择状态' }]}>
<Select
placeholder='请选择状态'
options={[
{ value: 'rule', label: '规则' },
{ value: 'people', label: '人工' },
]}
/>
</Form.Item>
<Form.Item name='price' label='价格' rules={[{ required: true, message: '请输入价格' }]}>
<Input placeholder='请输入价格' />
</Form.Item>
<Form.Item name='description' label='描述' rules={[{ required: true, message: '请输入描述' }]}>
<Input.TextArea rows={4} placeholder='请输入描述' />
</Form.Item>
</Form>
</Modal>
);
};

85
src/store/index.ts Normal file
View File

@ -0,0 +1,85 @@
import { Ticket } from '@/pages/define/type';
import { create } from 'zustand';
import { query } from '@/modules/query';
import { toast } from 'react-toastify';
type SearchTicketsParams = {
current?: number;
pageSize?: number;
search?: string;
status?: string;
type?: string;
sort?: string;
order?: 'asc' | 'desc';
uid?: string;
startDate?: string;
endDate?: string;
};
type TicketStore = {
tickets: Ticket[];
getTickets: (opts: SearchTicketsParams) => Promise<void>;
updateTicket: (ticket: Ticket) => Promise<void>;
deleteTicket: (id: string) => Promise<void>;
showEdit: boolean;
setShowEdit: (show: boolean) => void;
formData: Ticket | null;
setFormData: (data: Ticket | null) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
searchForm: Partial<SearchTicketsParams>;
setSearchForm: (form: Partial<SearchTicketsParams>) => void;
pagination: {
current?: number;
total?: number;
};
setPagination: (pagination: { current?: number; total?: number }) => void;
};
export const useTicketStore = create<TicketStore>((set, get) => {
return {
tickets: [],
searchForm: { current: 1, pageSize: 10 },
setSearchForm: (form) => set({ searchForm: form }),
pagination: { current: 1, total: 0 },
setPagination: (pagination) => set({ pagination }),
getTickets: async (data) => {
const res = await query.post({ path: 'ticket', key: 'list', data });
if (res.code === 200) {
const pagination = res.data.pagination || {};
set({ tickets: res.data.list, pagination });
}
},
updateTicket: async (ticket: Ticket) => {
const res = await query.post({
path: 'ticket',
key: 'update',
data: ticket,
});
if (res.code === 200) {
toast.success('Ticket updated successfully');
// get().getTickets(get().searchForm);
} else {
toast.error('Failed to update ticket');
}
},
deleteTicket: async (id: string) => {
const res = await query.post({
path: 'ticket',
key: 'delete',
data: { id },
});
if (res.code === 200) {
toast.success('删除成功');
// Refresh the ticket list after deletion
get().getTickets(get().searchForm);
} else {
toast.error('删除失败');
}
},
showEdit: false,
setShowEdit: (show) => set({ showEdit: show }),
formData: null,
setFormData: (data) => set({ formData: data }),
loading: false,
setLoading: (loading) => set({ loading }),
};
});

View File

@ -8,36 +8,26 @@ const version = pkgs.version || '0.0.1';
const isDev = process.env.NODE_ENV === 'development';
const basename = isDev ? '/' : pkgs?.basename || '/';
const checkJsh = () => {
return process.env.SHELL === '/bin/jsh';
};
const isJsh = checkJsh();
const plugins = [react(), ];
const plugins = [react(), tailwindcss() ];
if (!isJsh) {
const basicSsl = await import('@vitejs/plugin-basic-ssl');
const tailwindcss = await import('@tailwindcss/vite');
const defaultPlugin = basicSsl.default;
const defaultCssPlugin = tailwindcss.default;
plugins.push(defaultCssPlugin(),defaultPlugin());
}
let target = 'https://kevisual.xiongxiao.me';
if (isDev) {
target = 'https://kevisual.xiongxiao.me';
} else {
target = 'https://kevisual.cn';
}
// let target = 'https://kevisual.xiongxiao.me';
// if (isDev) {
// target = 'https://kevisual.xiongxiao.me';
// } else {
// target = 'https://kevisual.cn';
// }
let target = 'http://localhost:3004';
let proxy = {
'/root/system-lib/': {
target: `https://${target}/root/system-lib/`,
target: `${target}/root/system-lib/`,
},
'/user/login/': {
target: `https://${target}/user/login/`,
target: `${target}/user/login/`,
},
'/api': {
target: `https://${target}`,
target: `${target}`,
changeOrigin: true,
ws: true,
rewriteWsOrigin: true,