This commit is contained in:
熊潇 2025-04-08 01:28:58 +08:00
parent 8e41c38dde
commit 4a9727dd71
13 changed files with 1657 additions and 12 deletions

View File

@ -21,10 +21,12 @@
"author": "abearxiong <xiongxiao@xiongxiao.me>", "author": "abearxiong <xiongxiao@xiongxiao.me>",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@kevisual/router": "0.0.10", "@kevisual/router": "0.0.10",
"@mui/material": "^7.0.1", "@mui/material": "^7.0.1",
"antd": "^5.24.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",

910
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import '@kevisual/components/theme/wind-theme.css';
.ant-modal {
z-index: 10;
}

View File

@ -1,10 +1,18 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { App, AppRoute } from './pages/App.tsx'; import { App, AppRoute } from './pages/App.tsx';
import { CustomThemeProvider } from '@kevisual/components/theme/index.tsx'; import { ConfigProvider } from 'antd';
import { ToastContainer } from 'react-toastify';
console.log('cu',) // import 'react-toastify/dist/ReactToastify.css';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<CustomThemeProvider> <ConfigProvider
theme={{
components: {
Modal: {
// zIndex: 1000,
},
},
}}>
<AppRoute /> <AppRoute />
</CustomThemeProvider>, <ToastContainer />
</ConfigProvider>,
); );

View File

@ -1,7 +1,9 @@
import { basename } from '../modules/basename'; import { basename } from '../modules/basename';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
console.log('basename', basename); console.log('basename', basename);
import { App as AppDemo } from './app-demo'; import { App as AppVip } from './vip';
import '@ant-design/v5-patch-for-react-19';
export const App = () => { export const App = () => {
return <div className='bg-slate-200 w-full h-full border'>123</div>; return <div className='bg-slate-200 w-full h-full border'>123</div>;
}; };
@ -10,8 +12,7 @@ export const AppRoute = () => {
return ( return (
<Router> <Router>
<Routes> <Routes>
<Route path='/' element={<Navigate to='/app-demo/list' />} /> <Route path='*' element={<AppVip />} />
<Route path='/app-demo/*' element={<AppDemo />} />
</Routes> </Routes>
</Router> </Router>
); );

View File

@ -0,0 +1,66 @@
export const vipFeatureList = [
{
level: 'free',
levelNumber: 0,
title: '免费',
description: '满足简单的部署应用需求',
features: [
{
title: '部署10个以内的前端应用',
description: '可以部署10个以内的应用包括网页应用、小程序、H5等',
},
{
title: '资源存储不超过50MB',
},
{
title: '应用下载',
},
],
},
{
level: 'love',
levelNumber: 1,
title: '支持会员',
description: '支持一下',
features: [
{
title: '部署30个以内的前端应用',
description: '可以部署30个以内的应用包括网页应用、小程序、H5等',
},
{
title: '资源存储不超过200MB',
},
{
title: '专属支持',
description: '专属支持,包括专属客服、专属技术支持等',
},
],
},
{
level: 'vip',
levelNumber: 5,
title: '高级会员',
description: '应用定制和专属支持',
features: [
{
title: '无限部署应用',
description: '可以部署无限个应用包括网页应用、小程序、H5等',
},
{
title: '资源存储不超过500MB',
},
{
title: '优先支持',
description: '专属支持,包括专属客服、专属技术支持等',
},
{
title: '专属域名',
description: '可以申请专属域名,并使用专属域名进行访问',
},
{
title: '私有化部署',
description: '可以私有化部署,代理运维。',
},
],
},
];

12
src/pages/vip/index.tsx Normal file
View File

@ -0,0 +1,12 @@
import { Routes, Route } from 'react-router';
import { List } from './pages/List';
import { VipInfo } from './pages/VipInfo';
export const App = () => {
return (
<Routes>
<Route index element={<VipInfo />} />
<Route path='/list' element={<List />} />
</Routes>
);
};

View File

@ -0,0 +1,239 @@
import { useEffect } from 'react';
import { useDemoStore } from '../store';
import { useShallow } from 'zustand/react/shallow';
import { Modal, Space } from 'antd';
import { vipLevel, VipCategory } from '../query.ts';
import dayjs from 'dayjs';
import { Table, Button, Select, DatePicker, Input, Form } from 'antd';
import { ColumnType } from 'antd/es/table/interface';
const defaultValues = {
title: '',
userId: '',
level: 'free',
category: 'center',
startDate: dayjs(),
endDate: dayjs().add(1, 'month'),
};
export const EditDialog = () => {
const [form] = Form.useForm();
const store = useDemoStore(
useShallow((state) => ({
formData: state.formData,
setFormData: state.setFormData,
showEdit: state.showEdit,
setShowEdit: state.setShowEdit,
updateData: state.updateData,
})),
);
useEffect(() => {
if (store.showEdit) {
if (store.formData) {
form.setFieldsValue({
...store.formData,
startDate: dayjs(store.formData.startDate),
endDate: dayjs(store.formData.endDate),
});
} else {
form.setFieldsValue(defaultValues);
}
}
return () => {
form.setFieldsValue(defaultValues);
};
}, [store.formData, store.showEdit]);
const onSubmit = async (data: any) => {
// 将dayjs对象转换为时间字符串
const formattedData = {
...data,
id: store.formData?.id,
startDate: data.startDate ? data.startDate.format('YYYY-MM-DD') : undefined,
endDate: data.endDate ? data.endDate.format('YYYY-MM-DD') : undefined,
};
const res = await store.updateData(formattedData, { refresh: true });
if (res.code === 200) {
store.setShowEdit(false);
store.setFormData(undefined);
}
};
const onCancel = () => {
store.setShowEdit(false);
store.setFormData(undefined);
};
const hasId = !!store.formData?.id;
return (
<Modal
title={hasId ? '编辑' : '添加'}
footer={null}
open={store.showEdit}
onCancel={onCancel}
style={{
top: 40,
}}>
<Form
form={form}
labelCol={{
span: 6,
}}
wrapperCol={{
span: 18,
}}
className='flex flex-col gap-4 pt-4 min-w-[400px]'
onFinish={onSubmit}>
<Form.Item name='id' hidden>
<Input />
</Form.Item>
<Form.Item name='title' label='标题' rules={[{ required: true, message: '请输入标题' }]}>
<Input placeholder='请输入标题' />
</Form.Item>
<Form.Item name='userId' label='用户ID' rules={[{ required: true, message: '请输入用户ID' }]}>
<Input placeholder='请输入用户ID' />
</Form.Item>
<Form.Item name='level' label='等级' rules={[{ required: true, message: '请选择等级' }]}>
<Select options={vipLevel} />
</Form.Item>
<Form.Item name='category' label='分类' rules={[{ required: true, message: '请选择分类' }]}>
<Select options={VipCategory} />
</Form.Item>
<Form.Item name='startDate' label='开始日期' rules={[{ required: true, message: '请选择开始日期' }]}>
<DatePicker format={'YYYY-MM-DD'} type='date' />
</Form.Item>
<Form.Item name='endDate' label='结束日期' rules={[{ required: true, message: '请选择结束日期' }]}>
<DatePicker format={'YYYY-MM-DD'} type='date' />
</Form.Item>
<Form.Item label=' ' colon={false}>
<Button htmlType='submit'></Button>
</Form.Item>
</Form>
</Modal>
);
};
export const List = () => {
const store = useDemoStore(
useShallow((state) => ({
list: state.list,
pagination: state.pagination,
init: state.init,
setShowEdit: state.setShowEdit,
deleteData: state.deleteData,
setFormData: state.setFormData,
getList: state.getList,
})),
);
useEffect(() => {
store.init();
}, []);
const columns: ColumnType<any>[] = [
{
title: '标题',
dataIndex: 'title',
align: 'center',
},
{
title: '用户ID',
dataIndex: 'userId',
align: 'center',
},
{
title: '等级',
dataIndex: 'level',
align: 'center',
},
{
title: '分类',
dataIndex: 'category',
align: 'center',
},
{
title: '开始日期',
dataIndex: 'startDate',
align: 'center',
render: (_, record) => {
return record.startDate ? dayjs(record.startDate).format('YYYY-MM-DD') : '';
},
},
{
title: '结束日期',
dataIndex: 'endDate',
align: 'center',
render: (_, record) => {
return record.endDate ? dayjs(record.endDate).format('YYYY-MM-DD') : '';
},
},
{
title: '操作',
dataIndex: 'action',
align: 'center',
render: (_, record) => (
<Space>
<Button
type='link'
onClick={() => {
store.setShowEdit(true);
store.setFormData(record);
}}>
</Button>
<Button type='link' onClick={() => store.deleteData(record.id)}>
</Button>
</Space>
),
},
];
const [formSearch] = Form.useForm();
const onSearch = (data: any) => {
console.log('onSearch', data);
store.getList(data);
};
return (
<div className='w-full h-full flex flex-col gap-2 bg-gray-100 p-4'>
<div
className='overflow-auto scrollbar relative'
style={{
height: 'calc(100% - 50px)',
overflow: 'auto',
}}>
<Form form={formSearch} onFinish={onSearch} layout='inline' className='sticky top-0 left-0 z-10 flex gap-2 flex-wrap'>
<Button type='primary' onClick={() => store.setShowEdit(true)}>
</Button>
<Form.Item className='w-[200px]' name='search' label='标题'>
<Input />
</Form.Item>
<Form.Item className='w-[200px]' name='level' label='等级'>
<Select options={vipLevel} />
</Form.Item>
<Form.Item className='w-[22s0px]' name='userId' label='用户ID'>
<Input />
</Form.Item>
<Form.Item className='w-[200px]' name='category' label='分类'>
<Select options={VipCategory} />
</Form.Item>
<Form.Item>
<Button type='primary' htmlType='submit'>
</Button>
</Form.Item>
</Form>
<Table
className='mt-2'
columns={columns}
rowKey={(record) => record.id}
dataSource={store.list}
pagination={{
pageSize: store.pagination.pageSize,
total: store.pagination.total,
current: store.pagination.page,
onChange: (page, pageSize) => {
store.getList({ page, pageSize });
console.log('onChange', page, pageSize);
},
}}
/>
</div>
<EditDialog />
</div>
);
};

View File

@ -0,0 +1,161 @@
import { useEffect } from 'react';
import { queryApi } from '../store';
import { create } from 'zustand';
import { toast } from 'react-toastify';
import { vipFeatureList } from '../constants';
import { usePayModalStore, PayModal } from '../pay-modal/PayModal';
import { useShallow } from 'zustand/react/shallow';
interface VipStore {
vipList: any[];
setVipList: (vipList: any[]) => void;
init: () => Promise<void>;
vipInfo?: {
level: string;
startDate: string;
endDate: string;
category: string;
};
setVipInfo: (vipInfo: any) => void;
}
export const useVipStore = create<VipStore>((set) => ({
vipList: [],
setVipList: (vipList: any[]) => set({ vipList }),
vipInfo: {
level: '',
startDate: '',
endDate: '',
category: '',
},
setVipInfo: (vipInfo: any) => set({ vipInfo }),
init: async () => {
const res = await queryApi.getMeVipList();
if (res.code === 200) {
const list = res.data.list || [];
set({ vipList: list });
const vipCenterInfo = list.find((item: any) => item.category === 'center');
set({ vipInfo: vipCenterInfo });
} else {
toast.error(res.message || '获取VIP列表失败');
}
},
}));
export const VipInfo = () => {
const store = useVipStore();
const payModalStore = usePayModalStore(
useShallow((state) => {
return { setOpen: state.setOpen, setMoney: state.setMoney };
}),
);
useEffect(() => {
store.init();
}, []);
const vipPlans = [
{
type: '普通方案',
level: 'free',
background: 'bg-white',
textColor: 'text-gray-800',
description: '满足简单的部署应用需求',
price: '免费',
buttonText: '进入部署中心',
buttonClass: 'bg-white border border-blue-500 text-blue-500 hover:bg-blue-50',
},
{
type: '支持会员',
background: 'bg-blue-50',
level: 'love',
textColor: 'text-gray-800',
description: '可以部署更多应用和更多增值服务',
price: '1.0',
buttonText: '立即开通',
buttonClass: 'bg-blue-500 text-white hover:bg-blue-600',
},
{
type: '高级会员',
background: 'bg-gray-800',
level: 'vip',
textColor: 'text-amber-200',
description: '应用定制和专属支持',
price: '5.0',
buttonText: '立即开通',
buttonClass: 'bg-amber-100 text-gray-800 hover:bg-amber-200',
},
];
const onBuyVip = (clickLevel: string) => {
const currentLevel = store.vipInfo?.level || 'free';
const currentLevelNumber = vipFeatureList.find((item) => item.level === currentLevel)?.levelNumber || 0;
const clickLevelNumber = vipFeatureList.find((item) => item.level === clickLevel)?.levelNumber || 0;
if (clickLevel === 'free') {
window.location.href = '/root/center/';
return;
}
if (clickLevelNumber <= currentLevelNumber) {
toast.info('您已经是该会员等级');
return;
}
// 打开支付弹窗
payModalStore.setOpen(true);
payModalStore.setMoney(clickLevelNumber);
return;
};
return (
<div className='w-full flex flex-col items-center py-10 px-4 bg-gray-50 h-full scrollbar'>
{/* 封面部分 */}
<div className='w-full max-w-6xl bg-gradient-to-r from-blue-600 to-indigo-800 rounded-xl p-10 mb-16 text-white shadow-lg'>
<div className='max-w-3xl'>
<h1 className='text-4xl md:text-5xl font-bold mb-4'></h1>
<p className='text-xl md:text-2xl font-light opacity-90'>便app应用访</p>
</div>
</div>
<h1 className='text-3xl font-bold mb-16 text-gray-800'></h1>
<div className='flex flex-wrap justify-center gap-6 w-full max-w-6xl'>
{vipPlans.map((plan, index) => {
const vipFeatures = vipFeatureList.find((item) => item.level === plan.level);
const planIsVip = plan.level === 'vip';
const isActive = plan.level === store.vipInfo?.level;
let planButtonText = plan.buttonText;
if (isActive && plan.level !== 'free') {
planButtonText = '已开通';
}
if (!isActive && plan.level === 'vip' && store.vipInfo?.level === 'love') {
planButtonText = '升级';
}
return (
<div key={index} className={`${plan.background} rounded-lg p-8 flex flex-col items-center w-full max-w-xs`}>
<h2 className={`text-3xl font-bold mb-4 ${plan.textColor}`}>{plan.level}</h2>
<p className={`text-center mb-8 ${plan.textColor}`}>{plan.description}</p>
<div className={`flex items-end mb-8 ${plan.textColor}`}>
<span className='text-xl'></span>
<span className='text-6xl font-semibold mx-2'>{plan.price}</span>
{plan.price !== '免费' && <span className='text-xl'>/</span>}
</div>
<button
className={`py-3 px-6 w-full rounded-md font-medium cursor-pointer transition-colors ${plan.buttonClass}`}
onClick={() => onBuyVip(plan.level)}>
{planButtonText}
</button>
<div className='flex flex-col items-start py-4'>
{vipFeatures?.features.map((feature, index) => (
<div key={index} className='flex items-center mb-2'>
<span className={`${planIsVip ? 'text-white' : 'text-gray-600'} mr-2`}>{index + 1}.</span>
<span className={`${planIsVip ? 'text-white' : 'text-gray-800'}`}>{feature.title}</span>
</div>
))}
</div>
</div>
);
})}
</div>
<PayModal />
</div>
);
};

View File

@ -0,0 +1,31 @@
import { Modal } from 'antd';
import { create } from 'zustand';
interface PayModalStore {
open: boolean;
setOpen: (open: boolean) => void;
money: number;
setMoney: (money: number) => void;
}
export const usePayModalStore = create<PayModalStore>((set) => ({
open: false,
setOpen: (open: boolean) => set({ open }),
money: 0,
setMoney: (money: number) => set({ money }),
}));
export const PayModal = () => {
const { open, setOpen, money } = usePayModalStore();
return (
<Modal open={open} onCancel={() => setOpen(false)} maskClosable={false} footer={null}>
<div className='px-4 py-4 flex flex-col gap-4 select-none'>
<h2 className='text-2xl font-bold mb-4'></h2>
<p className='text-lg'>{money} </p>
<div className='flex gap-2'>
<button className='bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 cursor-pointer'></button>
<button className='bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 cursor-pointer'></button>
</div>
</div>
</Modal>
);
};

101
src/pages/vip/query.ts Normal file
View File

@ -0,0 +1,101 @@
import { BaseQuery } from '@kevisual/query';
export const vipLevel = [
{
label: '免费会员',
value: 'free',
},
{
label: '普通会员',
value: 'love',
},
{
label: 'VIP会员',
value: 'vip',
},
];
export const VipCategory = [
{
label: 'Deploy Center',
value: 'center',
},
{
label: 'AI Chat',
value: 'chat',
},
];
export class QueryApi extends BaseQuery {
constructor(options: { query: any }) {
super(options);
}
async getList(params?: any, dataOpts?: any) {
return this.query.post(
{
path: 'vip',
key: 'list',
...params,
},
dataOpts,
);
}
async getDetail(id?: string, dataOpts?: any) {
return this.query.post(
{
path: 'vip',
key: 'get',
data: { id },
},
dataOpts,
);
}
async update(data?: any, dataOpts?: any) {
return this.query.post(
{
path: 'vip',
key: 'update',
data,
},
dataOpts,
);
}
async delete(id?: string, dataOpts?: any) {
return this.query.post(
{
path: 'vip',
key: 'delete',
data: { id },
},
dataOpts,
);
}
/**
* VIP列表
* @param dataOpts
* @returns
*/
async getMeVipList(dataOpts?: any) {
return this.query.post(
{
path: 'vip',
key: 'me-vip-list',
},
dataOpts,
);
}
/**
* VIP信息
* @param category
* @param dataOpts
* @returns
*/
async getMeVipInfo(category?: string, dataOpts?: any) {
return this.query.post(
{
path: 'vip',
key: 'me',
category: category || 'center',
},
dataOpts,
);
}
}

108
src/pages/vip/store.ts Normal file
View File

@ -0,0 +1,108 @@
import { create } from 'zustand';
import { query } from '@/modules/query';
import { QueryApi } from './query';
import { toast } from 'react-toastify';
export const queryApi = new QueryApi({ query });
type Store = {
list: any[];
setList: (list: any[]) => void;
pagination: {
page: number;
pageSize: number;
total: number;
};
setPagination: (pagination: { page: number; pageSize: number; total: number }) => void;
data: any;
setData: (data: any) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
formData: any;
setFormData: (data: any) => void;
showEdit: boolean;
setShowEdit: (showEdit: boolean) => void;
getList: (params?: { page?: number; pageSize?: number; category?: string; level?: string }) => Promise<any>;
init: () => Promise<void>;
getData: (id: string) => Promise<any>;
updateData: (data: any, opts?: { refresh?: boolean }) => Promise<any>;
deleteData: (id: string, opts?: { refresh?: boolean }) => Promise<any>;
};
export const useDemoStore = create<Store>((set, get) => ({
list: [],
setList: (list) => set({ list }),
pagination: {
page: 1,
pageSize: 2,
total: 0,
},
setPagination: (pagination) => set({ pagination }),
data: null,
setData: (data) => set({ data }),
loading: false,
setLoading: (loading) => set({ loading }),
formData: null,
setFormData: (formData) => set({ formData }),
showEdit: false,
setShowEdit: (showEdit) => set({ showEdit }),
getList: async (params?: any) => {
set({ loading: true });
let { page, pageSize, ...rest } = params || {};
const res = await queryApi.getList({
page: page || 1,
pageSize: pageSize || 10,
...rest,
});
set({ loading: false });
if (res.code === 200) {
set({
list: res.data.list,
pagination: res.data.pagination,
});
}
return res;
},
init: async () => {
await get().getList();
},
getData: async (id) => {
set({ loading: true });
const res = await queryApi.getDetail(id);
set({ loading: false });
if (res.code === 200) {
const data = res.data;
set({ data });
}
return res;
},
updateData: async (data, opts = { refresh: true }) => {
set({ loading: true });
const res = await queryApi.update(data);
set({ loading: false });
if (res.code === 200) {
set({ data: res.data });
toast.success('更新成功');
} else {
toast.error(res.message || '更新失败');
}
if (opts.refresh) {
await get().getList();
}
return res;
},
deleteData: async (id, opts = { refresh: true }) => {
set({ loading: true });
const res = await queryApi.delete(id);
set({ loading: false });
if (res.code === 200) {
set({ data: null });
toast.success('删除成功');
} else {
toast.error(res.message || '删除失败');
}
if (opts.refresh) {
await get().getList();
}
return res;
},
}));

View File

@ -27,19 +27,20 @@ if (isDev) {
} else { } else {
target = 'https://kevisual.cn'; target = 'https://kevisual.cn';
} }
target = 'http://localhost:4006';
let proxy = { let proxy = {
'/root/center/': { '/root/center/': {
target: `https://${target}/root/center/`, target: `${target}/root/center/`,
}, },
'/root/system-lib/': { '/root/system-lib/': {
target: `https://${target}/root/system-lib/`, target: `${target}/root/system-lib/`,
}, },
'/user/login/': { '/user/login/': {
target: `https://${target}/user/login/`, target: `${target}/user/login/`,
}, },
'/api': { '/api': {
target: `https://${target}`, target: `${target}`,
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
rewriteWsOrigin: true, rewriteWsOrigin: true,