init: 项目初始化提交
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled

This commit is contained in:
2025-05-27 18:35:06 +08:00
commit d3609c21c0
1430 changed files with 122218 additions and 0 deletions

5
apps/web-antd/.env Normal file
View File

@@ -0,0 +1,5 @@
# 应用标题
VITE_APP_TITLE=Vben Admin Antd
# 应用命名空间用于缓存、store等功能的前缀确保隔离
VITE_APP_NAMESPACE=vben-web-antd

View File

@@ -0,0 +1,7 @@
# public path
VITE_BASE=/
# Basic interface address SPA
VITE_GLOB_API_URL=/api
VITE_VISUALIZER=true

View File

@@ -0,0 +1,16 @@
# 端口号
VITE_PORT=5666
VITE_BASE=/
# 接口地址
VITE_GLOB_API_URL=/api/v1/admin
# 是否开启 Nitro Mock服务true 为开启false 为关闭
VITE_NITRO_MOCK=true
# 是否打开 devtoolstrue 为打开false 为关闭
VITE_DEVTOOLS=true
# 是否注入全局loading
VITE_INJECT_APP_LOADING=true

View File

@@ -0,0 +1,19 @@
VITE_BASE=/
# 接口地址
VITE_GLOB_API_URL=/api/v1/admin
# 是否开启压缩,可以设置为 none, brotli, gzip
VITE_COMPRESS=none
# 是否开启 PWA
VITE_PWA=false
# vue-router 的模式
VITE_ROUTER_HISTORY=hash
# 是否注入全局loading
VITE_INJECT_APP_LOADING=true
# 打包后是否生成dist.zip
VITE_ARCHIVER=true

35
apps/web-antd/index.html Normal file
View File

@@ -0,0 +1,35 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="renderer" content="webkit" />
<meta name="description" content="A Modern Back-end Management System" />
<meta name="keywords" content="Vben Admin Vue3 Vite" />
<meta name="author" content="Vben" />
<meta
name="viewport"
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
/>
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
<title><%= VITE_APP_TITLE %></title>
<link rel="icon" href="/favicon.ico" />
<script>
// 生产环境下注入百度统计
if (window._VBEN_ADMIN_PRO_APP_CONF_) {
var _hmt = _hmt || [];
(function () {
var hm = document.createElement('script');
hm.src =
'https://hm.baidu.com/hm.js?b38e689f40558f20a9a686d7f6f33edf';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(hm, s);
})();
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,54 @@
{
"name": "@vben/web-antd",
"version": "5.5.4",
"homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "apps/web-antd"
},
"license": "MIT",
"author": {
"name": "vben",
"email": "ann.vben@gmail.com",
"url": "https://github.com/anncwb"
},
"type": "module",
"scripts": {
"build": "pnpm vite build --mode production",
"build:analyze": "pnpm vite build --mode analyze",
"dev": "pnpm vite --mode development",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit --skipLibCheck"
},
"imports": {
"#/*": "./src/*"
},
"dependencies": {
"@vben-core/composables": "workspace:*",
"@vben-core/menu-ui": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben/access": "workspace:*",
"@vben/common-ui": "workspace:*",
"@vben/constants": "workspace:*",
"@vben/hooks": "workspace:*",
"@vben/icons": "workspace:*",
"@vben/layouts": "workspace:*",
"@vben/locales": "workspace:*",
"@vben/plugins": "workspace:*",
"@vben/preferences": "workspace:*",
"@vben/request": "workspace:*",
"@vben/stores": "workspace:*",
"@vben/styles": "workspace:*",
"@vben/types": "workspace:*",
"@vben/utils": "workspace:*",
"@vueuse/core": "catalog:",
"ant-design-vue": "catalog:",
"dayjs": "catalog:",
"pinia": "catalog:",
"sortablejs": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1,210 @@
/**
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
*/
import type { Component } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import {
defineAsyncComponent,
defineComponent,
getCurrentInstance,
h,
ref,
} from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { notification } from 'ant-design-vue';
const AutoComplete = defineAsyncComponent(
() => import('ant-design-vue/es/auto-complete'),
);
const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
const Checkbox = defineAsyncComponent(
() => import('ant-design-vue/es/checkbox'),
);
const CheckboxGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup),
);
const DatePicker = defineAsyncComponent(
() => import('ant-design-vue/es/date-picker'),
);
const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider'));
const Input = defineAsyncComponent(() => import('ant-design-vue/es/input'));
const InputNumber = defineAsyncComponent(
() => import('ant-design-vue/es/input-number'),
);
const InputPassword = defineAsyncComponent(() =>
import('ant-design-vue/es/input').then((res) => res.InputPassword),
);
const Mentions = defineAsyncComponent(
() => import('ant-design-vue/es/mentions'),
);
const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio'));
const RadioGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/radio').then((res) => res.RadioGroup),
);
const RangePicker = defineAsyncComponent(() =>
import('ant-design-vue/es/date-picker').then((res) => res.RangePicker),
);
const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate'));
const Select = defineAsyncComponent(() => import('ant-design-vue/es/select'));
const Space = defineAsyncComponent(() => import('ant-design-vue/es/space'));
const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch'));
const Textarea = defineAsyncComponent(() =>
import('ant-design-vue/es/input').then((res) => res.Textarea),
);
const TimePicker = defineAsyncComponent(
() => import('ant-design-vue/es/time-picker'),
);
const TreeSelect = defineAsyncComponent(
() => import('ant-design-vue/es/tree-select'),
);
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
const RichText = defineAsyncComponent(() =>
import('@vben-core/shadcn-ui').then((m) => m.RichText),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
return defineComponent({
inheritAttrs: false,
name: component.name,
setup: (props: any, { attrs, expose, slots }) => {
const placeholder =
props?.placeholder ||
attrs?.placeholder ||
$t(`ui.placeholder.${type}`);
// 透传组件暴露的方法
const innerRef = ref();
const publicApi: Recordable<any> = {};
expose(publicApi);
const instance = getCurrentInstance();
instance?.proxy?.$nextTick(() => {
for (const key in innerRef.value) {
if (typeof innerRef.value[key] === 'function') {
publicApi[key] = innerRef.value[key];
}
}
});
return () =>
h(
component,
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
slots,
);
},
});
};
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType =
| 'ApiSelect'
| 'ApiTreeSelect'
| 'AutoComplete'
| 'Checkbox'
| 'CheckboxGroup'
| 'DatePicker'
| 'DefaultButton'
| 'Divider'
| 'IconPicker'
| 'Input'
| 'InputNumber'
| 'InputPassword'
| 'Mentions'
| 'PrimaryButton'
| 'Radio'
| 'RadioGroup'
| 'RangePicker'
| 'Rate'
| 'RichText'
| 'Select'
| 'Space'
| 'Switch'
| 'Textarea'
| 'TimePicker'
| 'TreeSelect'
| 'Upload'
| BaseFormComponentType;
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载
// Button: () =>
// import('xxx').then((res) => res.Button),
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
component: Select,
loadingSlot: 'suffixIcon',
visibleEvent: 'onDropdownVisibleChange',
modelPropName: 'value',
}),
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
component: TreeSelect,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
optionsPropName: 'treeData',
visibleEvent: 'onVisibleChange',
}),
AutoComplete,
Checkbox,
CheckboxGroup,
DatePicker,
// 自定义默认按钮
DefaultButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'default' }, slots);
},
Divider,
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
iconSlot: 'addonAfter',
inputComponent: Input,
modelValueProp: 'value',
}),
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
Mentions: withDefaultPlaceholder(Mentions, 'input'),
// 自定义主要按钮
PrimaryButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'primary' }, slots);
},
Radio,
RadioGroup,
RangePicker,
Rate,
Select: withDefaultPlaceholder(Select, 'select'),
Space,
Switch,
Textarea: withDefaultPlaceholder(Textarea, 'input'),
TimePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload,
RichText,
};
// 将组件注册到全局共享状态中
globalShareState.setComponents(components);
// 定义全局共享状态中的消息提示
globalShareState.defineMessage({
// 复制成功消息提示
copyPreferencesSuccess: (title, content) => {
notification.success({
description: content,
message: title,
placement: 'bottomRight',
});
},
});
}
export { initComponentAdapter };

View File

@@ -0,0 +1,48 @@
import type {
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui';
import type { ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
setupVbenForm<ComponentType>({
config: {
// ant design vue组件库默认都是 v-model:value
baseModelPropName: 'value',
// 一些组件是 v-model:checked 或者 v-model:fileList
modelPropNameMap: {
Checkbox: 'checked',
Radio: 'checked',
Switch: 'checked',
Upload: 'fileList',
TinyMCE: 'modelValue',
},
},
defineRules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
},
// 选择项目必填国际化适配
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
});
const useVbenForm = useForm<ComponentType>;
export { useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };

View File

@@ -0,0 +1,276 @@
import type { Recordable } from '@vben/types';
import { h } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $te } from '@vben/locales';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { get, isFunction, isString } from '@vben/utils';
import { objectOmit } from '@vueuse/core';
import { Button, Image, Popconfirm, Switch, Tag } from 'ant-design-vue';
import { $t } from '#/locales';
import { useVbenForm } from './form';
setupVbenVxeTable({
configVxeTable: (vxeUI) => {
vxeUI.setConfig({
grid: {
align: 'center',
border: false,
columnConfig: {
resizable: true,
},
formConfig: {
// 全局禁用vxe-table的表单配置使用formOptions
enabled: false,
},
minHeight: 180,
proxyConfig: {
autoLoad: true,
response: {
result: 'items',
total: 'total',
list: '',
},
showActiveMsg: true,
showResponseMsg: false,
},
round: true,
showOverflow: true,
size: 'small',
},
});
/**
* 解决vxeTable在热更新时可能会出错的问题
*/
vxeUI.renderer.forEach((_item, key) => {
if (key.startsWith('Cell')) {
vxeUI.renderer.delete(key);
}
});
// 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) {
const { column, row } = params;
return h(Image, { src: row[column.field] });
},
});
// 表格配置项可以用 cellRender: { name: 'CellLink' },
vxeUI.renderer.add('CellLink', {
renderTableDefault(renderOpts) {
const { props } = renderOpts;
return h(
Button,
{ size: 'small', type: 'link' },
{ default: () => props?.text },
);
},
});
// 单元格渲染: Tag
vxeUI.renderer.add('CellTag', {
renderTableDefault({ options, props }, { column, row }) {
const value = get(row, column.field);
const tagOptions = options ?? [
{ color: 'success', label: $t('common.enabled'), value: 1 },
{ color: 'error', label: $t('common.disabled'), value: 0 },
];
const tagItem = tagOptions.find((item) => item.value === value);
return h(
Tag,
{
...props,
...objectOmit(tagItem ?? {}, ['label']),
},
{ default: () => tagItem?.label ?? value },
);
},
});
vxeUI.renderer.add('CellSwitch', {
renderTableDefault({ attrs, props }, { column, row }) {
const loadingKey = `__loading_${column.field}`;
const finallyProps = {
checkedChildren: $t('common.enabled'),
checkedValue: 1,
unCheckedChildren: $t('common.disabled'),
unCheckedValue: 0,
...props,
checked: row[column.field],
loading: row[loadingKey] ?? false,
'onUpdate:checked': onChange,
};
async function onChange(newVal: any) {
row[loadingKey] = true;
try {
const result = await attrs?.beforeChange?.(newVal, row);
if (result !== false) {
row[column.field] = newVal;
}
} finally {
row[loadingKey] = false;
}
}
return h(Switch, finallyProps);
},
});
/**
* 注册表格的操作按钮渲染器
*/
vxeUI.renderer.add('CellOperation', {
renderTableDefault({ attrs, options, props }, { column, row }) {
const defaultProps = { size: 'small', type: 'link', ...props };
let align = 'end';
switch (column.align) {
case 'center': {
align = 'center';
break;
}
case 'left': {
align = 'start';
break;
}
default: {
align = 'end';
break;
}
}
const presets: Recordable<Recordable<any>> = {
delete: {
danger: true,
text: $t('common.delete'),
},
edit: {
text: $t('common.edit'),
},
};
const operations: Array<Recordable<any>> = (
options || ['edit', 'delete']
)
.map((opt) => {
if (isString(opt)) {
return presets[opt]
? { code: opt, ...presets[opt], ...defaultProps }
: {
code: opt,
text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
...defaultProps,
};
} else {
return { ...defaultProps, ...presets[opt.code], ...opt };
}
})
.map((opt) => {
const optBtn: Recordable<any> = {};
Object.keys(opt).forEach((key) => {
optBtn[key] = isFunction(opt[key]) ? opt[key](row) : opt[key];
});
return optBtn;
})
.filter((opt) => opt.show !== false);
function renderBtn(opt: Recordable<any>, listen = true) {
return h(
Button,
{
...props,
...opt,
icon: undefined,
onClick: listen
? () =>
attrs?.onClick?.({
code: opt.code,
row,
})
: undefined,
},
{
default: () => {
const content = [];
if (opt.icon) {
content.push(
h(IconifyIcon, { class: 'size-5', icon: opt.icon }),
);
}
content.push(opt.text);
return content;
},
},
);
}
function renderConfirm(opt: Recordable<any>) {
return h(
Popconfirm,
{
getPopupContainer(el) {
return (
el
.closest('.vxe-table--viewport-wrapper')
?.querySelector('.vxe-table--main-wrapper')
?.querySelector('tbody') || document.body
);
},
placement: 'topLeft',
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
...props,
...opt,
icon: undefined,
onConfirm: () => {
attrs?.onClick?.({
code: opt.code,
row,
});
},
},
{
default: () => renderBtn({ ...opt }, false),
description: () =>
h(
'div',
{ class: 'truncate' },
$t('ui.actionMessage.deleteConfirm', [
row[attrs?.nameField || 'name'],
]),
),
},
);
}
const btns = operations.map((opt) =>
opt.code === 'delete' ? renderConfirm(opt) : renderBtn(opt),
);
return h(
'div',
{
class: 'flex table-operations',
style: { justifyContent: align },
},
btns,
);
},
});
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
// vxeUI.formats.add
},
useVbenForm,
});
export { useVbenVxeGrid };
export type OnActionClickParams<T = Recordable<any>> = {
code: string;
row: T;
};
export type OnActionClickFn<T = Recordable<any>> = (
params: OnActionClickParams<T>,
) => void;
export type * from '@vben/plugins/vxe-table';

View File

@@ -0,0 +1,52 @@
import { baseRequestClient, requestClient } from '#/api/request';
export namespace AuthApi {
/** 登录接口参数 */
export interface LoginParams {
password?: string;
username?: string;
}
/** 登录接口返回值 */
export interface LoginResult {
access_token: string;
}
export interface RefreshTokenResult {
data: string;
status: number;
}
}
/**
* 登录
*/
export async function loginApi(data: AuthApi.LoginParams) {
return requestClient.post<AuthApi.LoginResult>('/auth/login', data);
}
/**
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', {
withCredentials: true,
});
}
/**
* 获取用户权限码
*/
export async function getAccessCodesApi() {
// return requestClient.get<string[]>('/auth/codes');
return [];
}

View File

@@ -0,0 +1,3 @@
export * from './auth';
export * from './menu';
export * from './user';

View File

@@ -0,0 +1,10 @@
import type { RouteRecordStringComponent } from '@vben/types';
import { requestClient } from '#/api/request';
/**
* 获取用户所有菜单
*/
export async function getAllMenusApi() {
return requestClient.get<RouteRecordStringComponent[]>('/menu/all');
}

View File

@@ -0,0 +1,10 @@
import type { UserInfo } from '@vben/types';
import { requestClient } from '#/api/request';
/**
* 获取用户信息
*/
export async function getUserInfoApi() {
return requestClient.get<UserInfo>('/user/info');
}

View File

@@ -0,0 +1,36 @@
export * from './core';
export * from './notification';
export * from './order';
export * from './platform-user';
export * from './product-manage';
export * from './promotion';
export * from './system';
export interface ApiResponse<T = any> {
code: number;
data: T;
message: string;
}
export interface PageResult<T> {
items: T[];
total: number;
}
declare global {
interface Window {
$http: {
delete<T = any>(url: string, config?: any): Promise<ApiResponse<T>>;
get<T = any>(url: string, config?: any): Promise<ApiResponse<T>>;
post<T = any>(
url: string,
data?: any,
config?: any,
): Promise<ApiResponse<T>>;
put<T = any>(
url: string,
data?: any,
config?: any,
): Promise<ApiResponse<T>>;
};
}
}

View File

@@ -0,0 +1,105 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace NotificationApi {
export interface NotificationItem {
id: number;
title: string;
content?: string;
notification_page: string;
start_date: string;
start_time: string;
end_date: string;
end_time: string;
status: number;
create_time: string;
update_time: string;
}
export interface NotificationList {
total: number;
items: NotificationItem[];
}
export interface CreateNotificationRequest {
title: string;
content: string;
notification_page: string;
start_date: string;
start_time: string;
end_date: string;
end_time: string;
status: 0 | 1;
}
export interface CreateNotificationResponse {
id: number;
}
export interface UpdateNotificationRequest
extends Partial<CreateNotificationRequest> {
id: number;
}
export interface UpdateNotificationResponse {
success: boolean;
}
export interface DeleteNotificationResponse {
success: boolean;
}
}
/**
* 获取通知列表
*/
async function getNotificationList(params: Recordable<any>) {
return requestClient.get<NotificationApi.NotificationList>(
'/notification/list',
{
params,
},
);
}
/**
* 创建通知
*/
async function createNotification(
data: NotificationApi.CreateNotificationRequest,
) {
return requestClient.post<NotificationApi.CreateNotificationResponse>(
'/notification/create',
data,
);
}
/**
* 更新通知
*/
async function updateNotification(
id: number,
data: NotificationApi.UpdateNotificationRequest,
) {
return requestClient.put<NotificationApi.UpdateNotificationResponse>(
`/notification/update/${id}`,
data,
);
}
/**
* 删除通知
*/
async function deleteNotification(id: number) {
return requestClient.delete<NotificationApi.DeleteNotificationResponse>(
`/notification/delete/${id}`,
);
}
export {
createNotification,
deleteNotification,
getNotificationList,
updateNotification,
};

View File

@@ -0,0 +1,59 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace OrderApi {
export interface Order {
id: number;
order_no: string;
platform_order_id: string;
product_name: string;
payment_platform: 'alipay' | 'appleiap' | 'wechat';
payment_scene: 'app' | 'h5' | 'mini_program' | 'public_account';
amount: number;
status: 'closed' | 'failed' | 'paid' | 'pending' | 'refunded';
create_time: string;
pay_time: null | string;
refund_time: null | string;
is_promotion: 0 | 1;
}
export interface OrderList {
total: number;
items: Order[];
}
export interface RefundOrderRequest {
refund_amount: number;
refund_reason: string;
}
export interface RefundOrderResponse {
status: string;
refund_no: string;
amount: number;
}
}
/**
* 获取订单列表数据
*/
async function getOrderList(params: Recordable<any>) {
return requestClient.get<OrderApi.OrderList>('/order/list', {
params,
});
}
/**
* 订单退款
* @param id 订单 ID
* @param data 退款请求数据
*/
async function refundOrder(id: number, data: OrderApi.RefundOrderRequest) {
return requestClient.post<OrderApi.RefundOrderResponse>(
`/order/refund/${id}`,
data,
);
}
export { getOrderList, refundOrder };

View File

@@ -0,0 +1,53 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace PlatformUserApi {
export interface PlatformUserItem {
id: number;
mobile: string;
nickname: string;
info: string;
inside: number;
create_time: string;
update_time: string;
}
export interface PlatformUserList {
total: number;
items: PlatformUserItem[];
}
export interface UpdatePlatformUserRequest {
mobile: string;
nickname: string;
info: string;
inside: number;
}
}
/**
* 获取平台用户列表数据
*/
async function getPlatformUserList(params: Recordable<any>) {
return requestClient.get<PlatformUserApi.PlatformUserList>(
'/platform_user/list',
{
params,
},
);
}
/**
* 更新平台用户
* @param id 用户 ID
* @param data 用户数据
*/
async function updatePlatformUser(
id: number,
data: PlatformUserApi.UpdatePlatformUserRequest,
) {
return requestClient.put(`/platform_user/update/${id}`, data);
}
export { getPlatformUserList, updatePlatformUser };

View File

@@ -0,0 +1,81 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace FeatureApi {
export interface FeatureItem {
id: number;
api_id: string;
name: string;
create_time: string;
update_time: string;
}
export interface FeatureList {
total: number;
items: FeatureItem[];
}
export interface CreateFeatureRequest {
api_id: string;
name: string;
}
export interface UpdateFeatureRequest {
api_id?: string;
name?: string;
}
}
/**
* 获取模块列表数据
*/
async function getFeatureList(params: Recordable<any>) {
return requestClient.get<FeatureApi.FeatureList>('/feature/list', {
params,
});
}
/**
* 获取模块详情
* @param id 模块ID
*/
async function getFeatureDetail(id: number) {
return requestClient.get<FeatureApi.FeatureItem>(`/feature/detail/${id}`);
}
/**
* 创建模块
* @param data 模块数据
*/
async function createFeature(data: FeatureApi.CreateFeatureRequest) {
return requestClient.post<{ id: number }>('/feature/create', data);
}
/**
* 更新模块
* @param id 模块ID
* @param data 模块数据
*/
async function updateFeature(
id: number,
data: FeatureApi.UpdateFeatureRequest,
) {
return requestClient.put<{ success: boolean }>(`/feature/update/${id}`, data);
}
/**
* 删除模块
* @param id 模块ID
*/
async function deleteFeature(id: number) {
return requestClient.delete<{ success: boolean }>(`/feature/delete/${id}`);
}
export {
createFeature,
deleteFeature,
getFeatureDetail,
getFeatureList,
updateFeature,
};

View File

@@ -0,0 +1,2 @@
export * from './feature';
export * from './product';

View File

@@ -0,0 +1,144 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace ProductApi {
export interface ProductItem {
id: number;
product_name: string;
product_en: string;
description: string;
notes: string;
cost_price: number;
sell_price: number;
create_time: string;
update_time: string;
}
export interface ProductList {
total: number;
items: ProductItem[];
}
export interface CreateProductRequest {
product_name: string;
product_en: string;
description: string;
notes?: string;
cost_price: number;
sell_price: number;
}
export interface UpdateProductRequest {
product_name?: string;
product_en?: string;
description?: string;
notes?: string;
cost_price?: number;
sell_price?: number;
}
export interface ProductFeatureListItem {
id: number;
product_id: number;
feature_id: number;
api_id: string;
name: string;
sort: number;
enable: number;
is_important: number;
create_time: string;
update_time: string;
}
export interface ProductFeatureItem {
feature_id: number;
sort: number;
enable: number;
is_important: number;
}
export interface UpdateProductFeaturesRequest {
features: ProductFeatureItem[];
}
}
/**
* 获取产品列表数据
*/
async function getProductList(params: Recordable<any>) {
return requestClient.get<ProductApi.ProductList>('/product/list', {
params,
});
}
/**
* 获取产品详情
* @param id 产品ID
*/
async function getProductDetail(id: number) {
return requestClient.get<ProductApi.ProductItem>(`/product/detail/${id}`);
}
/**
* 创建产品
* @param data 产品数据
*/
async function createProduct(data: ProductApi.CreateProductRequest) {
return requestClient.post<{ id: number }>('/product/create', data);
}
/**
* 更新产品
* @param id 产品ID
* @param data 产品数据
*/
async function updateProduct(
id: number,
data: ProductApi.UpdateProductRequest,
) {
return requestClient.put<{ success: boolean }>(`/product/update/${id}`, data);
}
/**
* 删除产品
* @param id 产品ID
*/
async function deleteProduct(id: number) {
return requestClient.delete<{ success: boolean }>(`/product/delete/${id}`);
}
/**
* 获取产品功能列表
* @param productId 产品ID
*/
async function getProductFeatureList(productId: number) {
return requestClient.get<ProductApi.ProductFeatureListItem[]>(
`/product/feature/list/${productId}`,
);
}
/**
* 更新产品功能关联
* @param productId 产品ID
* @param data 功能列表数据
*/
async function updateProductFeatures(
productId: number,
data: ProductApi.UpdateProductFeaturesRequest,
) {
return requestClient.put<{ success: boolean }>(
`/product/feature/update/${productId}`,
data,
);
}
export {
createProduct,
deleteProduct,
getProductDetail,
getProductFeatureList,
getProductList,
updateProduct,
updateProductFeatures,
};

View File

@@ -0,0 +1,45 @@
import { requestClient } from '#/api/request';
export namespace PromotionAnalyticsApi {
export interface OverviewData {
today_click_count: number;
today_pay_count: number;
today_pay_amount: number;
total_click_count: number;
total_pay_count: number;
total_pay_amount: number;
}
export interface TrendData {
id: number;
link_id: number;
pay_amount: number;
click_count: number;
pay_count: number;
stats_date: string;
}
}
/**
* 获取推广数据概览
*/
async function statsTotal() {
return requestClient.get<PromotionAnalyticsApi.OverviewData>(
'/promotion/stats/total',
);
}
/**
* 获取推广数据趋势
* @param params 日期范围参数
*/
async function statsHistory(params: { end_date: string; start_date: string }) {
return requestClient.get<PromotionAnalyticsApi.TrendData[]>(
'/promotion/stats/history',
{
params,
},
);
}
export { statsHistory, statsTotal };

View File

@@ -0,0 +1,2 @@
export * from './analytics';
export * from './link';

View File

@@ -0,0 +1,67 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace PromotionLinkApi {
export interface PromotionLinkItem {
id: number;
name: string;
url: string;
create_time: string;
}
export interface PromotionLink {
total: number;
items: PromotionLinkItem[];
}
}
/**
* 获取推广链接列表数据
*/
async function getPromotionLinkList(params: Recordable<any>) {
return requestClient.get<PromotionLinkApi.PromotionLink>(
'/promotion/link/list',
{
params,
},
);
}
/**
* 创建推广链接
* @param data 推广链接数据
*/
async function createPromotionLink(
data: Omit<PromotionLinkApi.PromotionLinkItem, 'id'>,
) {
return requestClient.post('/promotion/link/create', data);
}
/**
* 更新推广链接
*
* @param id 推广链接 ID
* @param data 推广链接数据
*/
async function updatePromotionLink(
id: string,
data: Omit<PromotionLinkApi.PromotionLinkItem, 'id'>,
) {
return requestClient.put(`/promotion/link/update/${id}`, data);
}
/**
* 删除推广链接
* @param id 推广链接 ID
*/
async function deletePromotionLink(id: string) {
return requestClient.delete(`/promotion/link/delete/${id}`);
}
export {
createPromotionLink,
deletePromotionLink,
getPromotionLinkList,
updatePromotionLink,
};

View File

@@ -0,0 +1,113 @@
/**
* 该文件可自行根据业务逻辑进行调整
*/
import type { RequestClientOptions } from '@vben/request';
import { useAppConfig } from '@vben/hooks';
import { preferences } from '@vben/preferences';
import {
authenticateResponseInterceptor,
defaultResponseInterceptor,
errorMessageResponseInterceptor,
RequestClient,
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { useAuthStore } from '#/store';
import { refreshTokenApi } from './core';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
const client = new RequestClient({
...options,
baseURL,
});
/**
* 重新认证逻辑
*/
async function doReAuthenticate() {
console.warn('Access token or refresh token is invalid or expired. ');
const accessStore = useAccessStore();
const authStore = useAuthStore();
accessStore.setAccessToken(null);
if (
preferences.app.loginExpiredMode === 'modal' &&
accessStore.isAccessChecked
) {
accessStore.setLoginExpired(true);
} else {
await authStore.logout();
}
}
/**
* 刷新token逻辑
*/
async function doRefreshToken() {
const accessStore = useAccessStore();
const resp = await refreshTokenApi();
const newToken = resp.data;
accessStore.setAccessToken(newToken);
return newToken;
}
function formatToken(token: null | string) {
return token ? `Bearer ${token}` : null;
}
// 请求头处理
client.addRequestInterceptor({
fulfilled: async (config) => {
const accessStore = useAccessStore();
config.headers.Authorization = formatToken(accessStore.accessToken);
config.headers['Accept-Language'] = preferences.app.locale;
return config;
},
});
// 处理返回的响应数据格式
client.addResponseInterceptor(
defaultResponseInterceptor({
codeField: 'code',
dataField: 'data',
successCode: 200,
}),
);
// token过期的处理
client.addResponseInterceptor(
authenticateResponseInterceptor({
client,
doReAuthenticate,
doRefreshToken,
enableRefreshToken: preferences.app.enableRefreshToken,
formatToken,
}),
);
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
client.addResponseInterceptor(
errorMessageResponseInterceptor((msg: string, error) => {
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
// 当前mock接口返回的错误字段是 error 或者 message
const responseData = error?.response?.data ?? {};
const errorMessage = responseData?.error ?? responseData?.msg ?? '';
// 如果没有错误信息,则会根据状态码进行提示
message.error(errorMessage || msg);
}),
);
return client;
}
export const requestClient = createRequestClient(apiURL, {
responseReturn: 'data',
});
export const baseRequestClient = new RequestClient({ baseURL: apiURL });

View File

@@ -0,0 +1,54 @@
import { requestClient } from '#/api/request';
export namespace SystemDeptApi {
export interface SystemDept {
[key: string]: any;
children?: SystemDept[];
id: string;
name: string;
remark?: string;
status: 0 | 1;
}
}
/**
* 获取部门列表数据
*/
async function getDeptList() {
return requestClient.get<Array<SystemDeptApi.SystemDept>>(
'/system/dept/list',
);
}
/**
* 创建部门
* @param data 部门数据
*/
async function createDept(
data: Omit<SystemDeptApi.SystemDept, 'children' | 'id'>,
) {
return requestClient.post('/system/dept', data);
}
/**
* 更新部门
*
* @param id 部门 ID
* @param data 部门数据
*/
async function updateDept(
id: string,
data: Omit<SystemDeptApi.SystemDept, 'children' | 'id'>,
) {
return requestClient.put(`/system/dept/${id}`, data);
}
/**
* 删除部门
* @param id 部门 ID
*/
async function deleteDept(id: string) {
return requestClient.delete(`/system/dept/${id}`);
}
export { createDept, deleteDept, getDeptList, updateDept };

View File

@@ -0,0 +1,4 @@
export * from './dept';
export * from './menu';
export * from './role';
export * from './user';

View File

@@ -0,0 +1,156 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace SystemMenuApi {
/** 徽标颜色集合 */
export const BadgeVariants = [
'default',
'destructive',
'primary',
'success',
'warning',
] as const;
/** 徽标类型集合 */
export const BadgeTypes = ['dot', 'normal'] as const;
/** 菜单类型集合 */
export const MenuTypes = [
'catalog',
'menu',
'embedded',
'link',
'button',
] as const;
/** 系统菜单 */
export interface SystemMenu {
[key: string]: any;
/** 后端权限标识 */
authCode: string;
/** 子级 */
children?: SystemMenu[];
/** 组件 */
component?: string;
/** 菜单ID */
id: string;
/** 菜单元数据 */
meta?: {
/** 激活时显示的图标 */
activeIcon?: string;
/** 作为路由时需要激活的菜单的Path */
activePath?: string;
/** 固定在标签栏 */
affixTab?: boolean;
/** 在标签栏固定的顺序 */
affixTabOrder?: number;
/** 徽标内容(当徽标类型为normal时有效) */
badge?: string;
/** 徽标类型 */
badgeType?: (typeof BadgeTypes)[number];
/** 徽标颜色 */
badgeVariants?: (typeof BadgeVariants)[number];
/** 在菜单中隐藏下级 */
hideChildrenInMenu?: boolean;
/** 在面包屑中隐藏 */
hideInBreadcrumb?: boolean;
/** 在菜单中隐藏 */
hideInMenu?: boolean;
/** 在标签栏中隐藏 */
hideInTab?: boolean;
/** 菜单图标 */
icon?: string;
/** 内嵌Iframe的URL */
iframeSrc?: string;
/** 是否缓存页面 */
keepAlive?: boolean;
/** 外链页面的URL */
link?: string;
/** 同一个路由最大打开的标签数 */
maxNumOfOpenTab?: number;
/** 无需基础布局 */
noBasicLayout?: boolean;
/** 是否在新窗口打开 */
openInNewWindow?: boolean;
/** 菜单排序 */
order?: number;
/** 额外的路由参数 */
query?: Recordable<any>;
/** 菜单标题 */
title?: string;
};
/** 菜单名称 */
name: string;
/** 路由路径 */
path: string;
/** 父级ID */
pid: string;
/** 重定向 */
redirect?: string;
/** 菜单类型 */
type: (typeof MenuTypes)[number];
}
}
/**
* 获取菜单数据列表
*/
async function getMenuList() {
return requestClient.get<Array<SystemMenuApi.SystemMenu>>('/menu/list');
}
async function isMenuNameExists(
name: string,
id?: SystemMenuApi.SystemMenu['id'],
) {
return requestClient.get<boolean>('/menu/name-exists', {
params: { id, name },
});
}
async function isMenuPathExists(
path: string,
id?: SystemMenuApi.SystemMenu['id'],
) {
return requestClient.get<boolean>('/menu/path-exists', {
params: { id, path },
});
}
/**
* 创建菜单
* @param data 菜单数据
*/
async function createMenu(
data: Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>,
) {
return requestClient.post('/menu/create', data);
}
/**
* 更新菜单
*
* @param id 菜单 ID
* @param data 菜单数据
*/
async function updateMenu(
id: string,
data: Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>,
) {
return requestClient.put(`/menu/update/${id}`, data);
}
/**
* 删除菜单
* @param id 菜单 ID
*/
async function deleteMenu(id: string) {
return requestClient.delete(`/menu/delete/${id}`);
}
export {
createMenu,
deleteMenu,
getMenuList,
isMenuNameExists,
isMenuPathExists,
updateMenu,
};

View File

@@ -0,0 +1,61 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace SystemRoleApi {
export interface SystemRoleItem {
id: number;
role_name: string;
role_code: string;
description?: string;
status: 0 | 1;
sort: number;
create_time: string;
menu_ids: number[];
}
export interface SystemRole {
total: number;
items: SystemRoleItem[];
}
}
/**
* 获取角色列表数据
*/
async function getRoleList(params: Recordable<any>) {
return requestClient.get<SystemRoleApi.SystemRole>('/role/list', {
params,
});
}
/**
* 创建角色
* @param data 角色数据
*/
async function createRole(data: Omit<SystemRoleApi.SystemRoleItem, 'id'>) {
return requestClient.post('/role/create', data);
}
/**
* 更新角色
*
* @param id 角色 ID
* @param data 角色数据
*/
async function updateRole(
id: number,
data: Omit<SystemRoleApi.SystemRoleItem, 'id'>,
) {
return requestClient.put(`/role/update/${id}`, data);
}
/**
* 删除角色
* @param id 角色 ID
*/
async function deleteRole(id: string) {
return requestClient.delete(`/role/delete/${id}`);
}
export { createRole, deleteRole, getRoleList, updateRole };

View File

@@ -0,0 +1,54 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace SystemUserApi {
export interface SystemUser {
[key: string]: any;
id: string;
name: string;
permissions: string[];
remark?: string;
status: 0 | 1;
}
}
/**
* 获取角色列表数据
*/
async function getUserList(params: Recordable<any>) {
return requestClient.get<Array<SystemUserApi.SystemUser>>('/user/list', {
params,
});
}
/**
* 创建角色
* @param data 角色数据
*/
async function createUser(data: Omit<SystemUserApi.SystemUser, 'id'>) {
return requestClient.post('/user/create', data);
}
/**
* 更新角色
*
* @param id 角色 ID
* @param data 角色数据
*/
async function updateUser(
id: string,
data: Omit<SystemUserApi.SystemUser, 'id'>,
) {
return requestClient.put(`/user/update/${id}`, data);
}
/**
* 删除角色
* @param id 角色 ID
*/
async function deleteUser(id: string) {
return requestClient.delete(`/user/delete/${id}`);
}
export { createUser, deleteUser, getUserList, updateUser };

39
apps/web-antd/src/app.vue Normal file
View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useAntdDesignTokens } from '@vben/hooks';
import { preferences, usePreferences } from '@vben/preferences';
import { App, ConfigProvider, theme } from 'ant-design-vue';
import { antdLocale } from '#/locales';
defineOptions({ name: 'App' });
const { isDark } = usePreferences();
const { tokens } = useAntdDesignTokens();
const tokenTheme = computed(() => {
const algorithm = isDark.value
? [theme.darkAlgorithm]
: [theme.defaultAlgorithm];
// antd 紧凑模式算法
if (preferences.app.compact) {
algorithm.push(theme.compactAlgorithm);
}
return {
algorithm,
token: tokens,
};
});
</script>
<template>
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
<App>
<RouterView />
</App>
</ConfigProvider>
</template>

View File

@@ -0,0 +1,72 @@
import { createApp, watchEffect } from 'vue';
import { registerAccessDirective } from '@vben/access';
import { registerLoadingDirective } from '@vben/common-ui/es/loading';
import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores';
import '@vben/styles';
import '@vben/styles/antd';
import { useTitle } from '@vueuse/core';
import { $t, setupI18n } from '#/locales';
import { initComponentAdapter } from './adapter/component';
import App from './app.vue';
import { router } from './router';
async function bootstrap(namespace: string) {
// 初始化组件适配器
await initComponentAdapter();
// // 设置弹窗的默认配置
// setDefaultModalProps({
// fullscreenButton: false,
// });
// // 设置抽屉的默认配置
// setDefaultDrawerProps({
// zIndex: 1020,
// });
const app = createApp(App);
// 注册v-loading指令
registerLoadingDirective(app, {
loading: 'loading', // 在这里可以自定义指令名称也可以明确提供false表示不注册这个指令
spinning: 'spinning',
});
// 国际化 i18n 配置
await setupI18n(app);
// 配置 pinia-tore
await initStores(app, { namespace });
// 安装权限指令
registerAccessDirective(app);
// 初始化 tippy
const { initTippy } = await import('@vben/common-ui/es/tippy');
initTippy(app);
// 配置路由及路由守卫
app.use(router);
// 配置Motion插件
const { MotionPlugin } = await import('@vben/plugins/motion');
app.use(MotionPlugin);
// 动态更新标题
watchEffect(() => {
if (preferences.app.dynamicTitle) {
const routeTitle = router.currentRoute.value.meta?.title;
const pageTitle =
(routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name;
useTitle(pageTitle);
}
});
app.mount('#app');
}
export { bootstrap };

View File

@@ -0,0 +1,23 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { AuthPageLayout } from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { $t } from '#/locales';
const appName = computed(() => preferences.app.name);
const logo = computed(() => preferences.logo.source);
</script>
<template>
<AuthPageLayout
:app-name="appName"
:logo="logo"
:page-description="$t('authentication.pageDesc')"
:page-title="$t('authentication.pageTitle')"
>
<!-- 自定义工具栏 -->
<!-- <template #toolbar></template> -->
</AuthPageLayout>
</template>

View File

@@ -0,0 +1,157 @@
<script lang="ts" setup>
import type { NotificationItem } from '@vben/layouts';
import { computed, ref, watch } from 'vue';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
import { useWatermark } from '@vben/hooks';
import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
import {
BasicLayout,
LockScreen,
Notification,
UserDropdown,
} from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
import { $t } from '#/locales';
import { useAuthStore } from '#/store';
import LoginForm from '#/views/_core/authentication/login.vue';
const notifications = ref<NotificationItem[]>([
{
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
date: '3小时前',
isRead: true,
message: '描述信息描述信息描述信息',
title: '收到了 14 份新周报',
},
{
avatar: 'https://avatar.vercel.sh/1',
date: '刚刚',
isRead: false,
message: '描述信息描述信息描述信息',
title: '朱偏右 回复了你',
},
{
avatar: 'https://avatar.vercel.sh/1',
date: '2024-01-01',
isRead: false,
message: '描述信息描述信息描述信息',
title: '曲丽丽 评论了你',
},
{
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '代办提醒',
},
]);
const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark();
const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
const menus = computed(() => [
{
handler: () => {
openWindow(VBEN_DOC_URL, {
target: '_blank',
});
},
icon: BookOpenText,
text: $t('ui.widgets.document'),
},
{
handler: () => {
openWindow(VBEN_GITHUB_URL, {
target: '_blank',
});
},
icon: MdiGithub,
text: 'GitHub',
},
{
handler: () => {
openWindow(`${VBEN_GITHUB_URL}/issues`, {
target: '_blank',
});
},
icon: CircleHelp,
text: $t('ui.widgets.qa'),
},
]);
const avatar = computed(() => {
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
});
async function handleLogout() {
await authStore.logout(false);
}
function handleNoticeClear() {
notifications.value = [];
}
function handleMakeAll() {
notifications.value.forEach((item) => (item.isRead = true));
}
watch(
() => preferences.app.watermark,
async (enable) => {
if (enable) {
await updateWatermark({
content: `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
});
} else {
destroyWatermark();
}
},
{
immediate: true,
},
);
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<template #user-dropdown>
<UserDropdown
:avatar
:menus
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
/>
</template>
<template #notification>
<Notification
:dot="showDot"
:notifications="notifications"
@clear="handleNoticeClear"
@make-all="handleMakeAll"
/>
</template>
<template #extra>
<AuthenticationLoginExpiredModal
v-model:open="accessStore.loginExpired"
:avatar
>
<LoginForm />
</AuthenticationLoginExpiredModal>
</template>
<template #lock-screen>
<LockScreen :avatar @to-login="handleLogout" />
</template>
</BasicLayout>
</template>

View File

@@ -0,0 +1,6 @@
const BasicLayout = () => import('./basic.vue');
const AuthPageLayout = () => import('./auth.vue');
const IFrameView = () => import('@vben/layouts').then((m) => m.IFrameView);
export { AuthPageLayout, BasicLayout, IFrameView };

View File

@@ -0,0 +1,3 @@
# locale
每个app使用的国际化可能不同这里用于扩展国际化的功能例如扩展 dayjs、antd组件库的多语言切换以及app本身的国际化文件。

View File

@@ -0,0 +1,102 @@
import type { Locale } from 'ant-design-vue/es/locale';
import type { App } from 'vue';
import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales';
import { ref } from 'vue';
import {
$t,
setupI18n as coreSetup,
loadLocalesMapFromDir,
} from '@vben/locales';
import { preferences } from '@vben/preferences';
import antdEnLocale from 'ant-design-vue/es/locale/en_US';
import antdDefaultLocale from 'ant-design-vue/es/locale/zh_CN';
import dayjs from 'dayjs';
const antdLocale = ref<Locale>(antdDefaultLocale);
const modules = import.meta.glob('./langs/**/*.json');
const localesMap = loadLocalesMapFromDir(
/\.\/langs\/([^/]+)\/(.*)\.json$/,
modules,
);
/**
* 加载应用特有的语言包
* 这里也可以改造为从服务端获取翻译数据
* @param lang
*/
async function loadMessages(lang: SupportedLanguagesType) {
const [appLocaleMessages] = await Promise.all([
localesMap[lang]?.(),
loadThirdPartyMessage(lang),
]);
return appLocaleMessages?.default;
}
/**
* 加载第三方组件库的语言包
* @param lang
*/
async function loadThirdPartyMessage(lang: SupportedLanguagesType) {
await Promise.all([loadAntdLocale(lang), loadDayjsLocale(lang)]);
}
/**
* 加载dayjs的语言包
* @param lang
*/
async function loadDayjsLocale(lang: SupportedLanguagesType) {
let locale;
switch (lang) {
case 'en-US': {
locale = await import('dayjs/locale/en');
break;
}
case 'zh-CN': {
locale = await import('dayjs/locale/zh-cn');
break;
}
// 默认使用英语
default: {
locale = await import('dayjs/locale/en');
}
}
if (locale) {
dayjs.locale(locale);
} else {
console.error(`Failed to load dayjs locale for ${lang}`);
}
}
/**
* 加载antd的语言包
* @param lang
*/
async function loadAntdLocale(lang: SupportedLanguagesType) {
switch (lang) {
case 'en-US': {
antdLocale.value = antdEnLocale;
break;
}
case 'zh-CN': {
antdLocale.value = antdDefaultLocale;
break;
}
}
}
async function setupI18n(app: App, options: LocaleSetupOptions = {}) {
await coreSetup(app, {
defaultLocale: preferences.app.locale,
loadMessages,
missingWarn: !import.meta.env.PROD,
...options,
});
}
export { $t, antdLocale, setupI18n };

View File

@@ -0,0 +1,31 @@
export default {
platformUser: {
name: '平台用户',
list: '平台用户列表',
field: {
mobile: '手机号',
nickname: '昵称',
platform: '用户平台',
inside: '是否内部用户',
createTime: '创建时间',
updateTime: '更新时间',
info: '备注信息',
},
search: {
mobile: '请输入手机号',
nickname: '请输入昵称',
platform: '请选择用户平台',
inside: '请选择是否内部用户',
createTime: '请选择创建时间',
},
operation: '操作',
platformMap: {
h5: 'H5',
wechat: '微信',
},
insideMap: {
1: '是',
0: '否',
},
},
};

View File

@@ -0,0 +1,12 @@
{
"title": "Demos",
"antd": "Ant Design Vue",
"vben": {
"title": "Project",
"about": "About",
"document": "Document",
"antdv": "Ant Design Vue Version",
"naive-ui": "Naive UI Version",
"element-plus": "Element Plus Version"
}
}

View File

@@ -0,0 +1,14 @@
{
"auth": {
"login": "Login",
"register": "Register",
"codeLogin": "Code Login",
"qrcodeLogin": "Qr Code Login",
"forgetPassword": "Forget Password"
},
"dashboard": {
"title": "Dashboard",
"analytics": "Analytics",
"workspace": "Workspace"
}
}

View File

@@ -0,0 +1,77 @@
{
"title": "System Management",
"dept": {
"name": "Department",
"title": "Department Management",
"deptName": "Department Name",
"status": "Status",
"createTime": "Create Time",
"remark": "Remark",
"operation": "Operation",
"parentDept": "Parent Department"
},
"menu": {
"title": "Menu Management",
"parent": "Parent Menu",
"menuTitle": "Title",
"menuName": "Menu Name",
"name": "Menu",
"type": "Type",
"typeCatalog": "Catalog",
"typeMenu": "Menu",
"typeButton": "Button",
"typeLink": "Link",
"typeEmbedded": "Embedded",
"icon": "Icon",
"activeIcon": "Active Icon",
"activePath": "Active Path",
"path": "Route Path",
"component": "Component",
"status": "Status",
"authCode": "Auth Code",
"badge": "Badge",
"operation": "Operation",
"linkSrc": "Link Address",
"affixTab": "Affix In Tabs",
"keepAlive": "Keep Alive",
"hideInMenu": "Hide In Menu",
"hideInTab": "Hide In Tabbar",
"hideChildrenInMenu": "Hide Children In Menu",
"hideInBreadcrumb": "Hide In Breadcrumb",
"advancedSettings": "Other Settings",
"activePathMustExist": "The path could not find a valid menu",
"activePathHelp": "When jumping to the current route, \nthe menu path that needs to be activated must be specified when it does not display in the navigation menu.",
"badgeType": {
"title": "Badge Type",
"dot": "Dot",
"normal": "Text",
"none": "None"
},
"badgeVariants": "Badge Style"
},
"role": {
"title": "Role Management",
"list": "Role List",
"name": "Role",
"roleName": "Role Name",
"id": "Role ID",
"status": "Status",
"remark": "Remark",
"createTime": "Creation Time",
"operation": "Operation",
"permissions": "Permissions",
"setPermissions": "Permissions",
"roleCode": "Role Code",
"description": "Description"
},
"user": {
"name": "User",
"list": "User List",
"userName": "Username",
"realName": "Real Name",
"status": "Status",
"setPermissions": "Set Permissions",
"createTime": "Create Time",
"operation": "Operation"
}
}

View File

@@ -0,0 +1,12 @@
{
"title": "演示",
"antd": "Ant Design Vue",
"vben": {
"title": "项目",
"about": "关于",
"document": "文档",
"antdv": "Ant Design Vue 版本",
"naive-ui": "Naive UI 版本",
"element-plus": "Element Plus 版本"
}
}

View File

@@ -0,0 +1,14 @@
{
"auth": {
"login": "登录",
"register": "注册",
"codeLogin": "验证码登录",
"qrcodeLogin": "二维码登录",
"forgetPassword": "忘记密码"
},
"dashboard": {
"title": "概览",
"analytics": "分析页",
"workspace": "工作台"
}
}

View File

@@ -0,0 +1,79 @@
{
"dept": {
"list": "部门列表",
"createTime": "创建时间",
"deptName": "部门名称",
"name": "部门",
"operation": "操作",
"parentDept": "上级部门",
"remark": "备注",
"status": "状态",
"title": "部门管理"
},
"menu": {
"list": "菜单列表",
"activeIcon": "激活图标",
"activePath": "激活路径",
"activePathHelp": "跳转到当前路由时,需要激活的菜单路径。\n当不在导航菜单中显示时需要指定激活路径",
"activePathMustExist": "该路径未能找到有效的菜单",
"advancedSettings": "其它设置",
"affixTab": "固定在标签",
"authCode": "权限标识",
"badge": "徽章内容",
"badgeVariants": "徽标样式",
"badgeType": {
"dot": "点",
"none": "无",
"normal": "文字",
"title": "徽标类型"
},
"component": "页面组件",
"hideChildrenInMenu": "隐藏子菜单",
"hideInBreadcrumb": "在面包屑中隐藏",
"hideInMenu": "隐藏菜单",
"hideInTab": "在标签栏中隐藏",
"icon": "图标",
"keepAlive": "缓存标签页",
"linkSrc": "链接地址",
"menuName": "菜单名称",
"menuTitle": "标题",
"name": "菜单",
"operation": "操作",
"parent": "上级菜单",
"path": "路由地址",
"status": "状态",
"title": "菜单管理",
"type": "类型",
"typeButton": "按钮",
"typeCatalog": "目录",
"typeEmbedded": "内嵌",
"typeLink": "外链",
"typeMenu": "菜单"
},
"role": {
"title": "角色管理",
"list": "角色列表",
"name": "角色",
"roleName": "角色名称",
"id": "角色ID",
"status": "状态",
"remark": "备注",
"createTime": "创建时间",
"operation": "操作",
"permissions": "权限",
"setPermissions": "授权",
"roleCode": "角色编号",
"description": "描述"
},
"title": "系统管理",
"user": {
"name": "用户",
"list": "用户列表",
"userName": "用户名",
"realName": "真实姓名",
"status": "状态",
"setPermissions": "设置权限",
"createTime": "创建时间",
"operation": "操作"
}
}

31
apps/web-antd/src/main.ts Normal file
View File

@@ -0,0 +1,31 @@
import { initPreferences } from '@vben/preferences';
import { unmountGlobalLoading } from '@vben/utils';
import { overridesPreferences } from './preferences';
/**
* 应用初始化完成之后再进行页面加载渲染
*/
async function initApplication() {
// name用于指定项目唯一标识
// 用于区分不同项目的偏好设置以及存储数据的key前缀以及其他一些需要隔离的数据
const env = import.meta.env.PROD ? 'prod' : 'dev';
const appVersion = import.meta.env.VITE_APP_VERSION;
const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${appVersion}-${env}`;
// app偏好设置初始化
await initPreferences({
namespace,
overrides: overridesPreferences,
});
// 启动应用并挂载
// vue应用主要逻辑及视图
const { bootstrap } = await import('./bootstrap');
await bootstrap(namespace);
// 移除并销毁loading
unmountGlobalLoading();
}
initApplication();

View File

@@ -0,0 +1,14 @@
import { defineOverridesPreferences } from '@vben/preferences';
/**
* @description 项目配置文件
* 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置
* !!! 更改配置后请清空缓存,否则可能不生效
*/
export const overridesPreferences = defineOverridesPreferences({
// overrides
app: {
name: import.meta.env.VITE_APP_TITLE,
accessMode: 'backend',
},
});

View File

@@ -0,0 +1,42 @@
import type {
ComponentRecordType,
GenerateMenuAndRoutesOptions,
} from '@vben/types';
import { generateAccessible } from '@vben/access';
import { preferences } from '@vben/preferences';
import { message } from 'ant-design-vue';
import { getAllMenusApi } from '#/api';
import { BasicLayout, IFrameView } from '#/layouts';
import { $t } from '#/locales';
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
const layoutMap: ComponentRecordType = {
BasicLayout,
IFrameView,
};
return await generateAccessible(preferences.app.accessMode, {
...options,
fetchMenuListAsync: async () => {
message.loading({
content: `${$t('common.loadingMenu')}...`,
duration: 1.5,
});
return await getAllMenusApi();
},
// 可以指定没有权限跳转403页面
forbiddenComponent,
// 如果 route.meta.menuVisibleWithForbidden = true
layoutMap,
pageMap,
});
}
export { generateAccess };

View File

@@ -0,0 +1,133 @@
import type { Router } from 'vue-router';
import { DEFAULT_HOME_PATH, LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { startProgress, stopProgress } from '@vben/utils';
import { accessRoutes, coreRouteNames } from '#/router/routes';
import { useAuthStore } from '#/store';
import { generateAccess } from './access';
/**
* 通用守卫配置
* @param router
*/
function setupCommonGuard(router: Router) {
// 记录已经加载的页面
const loadedPaths = new Set<string>();
router.beforeEach(async (to) => {
to.meta.loaded = loadedPaths.has(to.path);
// 页面加载进度条
if (!to.meta.loaded && preferences.transition.progress) {
startProgress();
}
return true;
});
router.afterEach((to) => {
// 记录页面是否加载,如果已经加载,后续的页面切换动画等效果不在重复执行
loadedPaths.add(to.path);
// 关闭页面加载进度条
if (preferences.transition.progress) {
stopProgress();
}
});
}
/**
* 权限访问守卫配置
* @param router
*/
function setupAccessGuard(router: Router) {
router.beforeEach(async (to, from) => {
const accessStore = useAccessStore();
const userStore = useUserStore();
const authStore = useAuthStore();
// 基本路由,这些路由不需要进入权限拦截
if (coreRouteNames.includes(to.name as string)) {
if (to.path === LOGIN_PATH && accessStore.accessToken) {
return decodeURIComponent(
(to.query?.redirect as string) ||
userStore.userInfo?.homePath ||
DEFAULT_HOME_PATH,
);
}
return true;
}
// accessToken 检查
if (!accessStore.accessToken) {
// 明确声明忽略权限访问权限,则可以访问
if (to.meta.ignoreAccess) {
return true;
}
// 没有访问权限,跳转登录页面
if (to.fullPath !== LOGIN_PATH) {
return {
path: LOGIN_PATH,
// 如不需要,直接删除 query
query:
to.fullPath === DEFAULT_HOME_PATH
? {}
: { redirect: encodeURIComponent(to.fullPath) },
// 携带当前跳转的页面,登录后重新跳转该页面
replace: true,
};
}
return to;
}
// 是否已经生成过动态路由
if (accessStore.isAccessChecked) {
return true;
}
// 生成路由表
// 当前登录用户拥有的角色标识列表
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
const userRoles = userInfo.roles ?? [];
// 生成菜单和路由
const { accessibleMenus, accessibleRoutes } = await generateAccess({
roles: userRoles,
router,
// 则会在菜单中显示但是访问会被重定向到403
routes: accessRoutes,
});
// 保存菜单信息和路由信息
accessStore.setAccessMenus(accessibleMenus);
accessStore.setAccessRoutes(accessibleRoutes);
accessStore.setIsAccessChecked(true);
const redirectPath = (from.query.redirect ??
(to.path === DEFAULT_HOME_PATH
? userInfo.homePath || DEFAULT_HOME_PATH
: to.fullPath)) as string;
return {
...router.resolve(decodeURIComponent(redirectPath)),
replace: true,
};
});
}
/**
* 项目守卫配置
* @param router
*/
function createRouterGuard(router: Router) {
/** 通用 */
setupCommonGuard(router);
/** 权限访问 */
setupAccessGuard(router);
}
export { createRouterGuard };

View File

@@ -0,0 +1,37 @@
import {
createRouter,
createWebHashHistory,
createWebHistory,
} from 'vue-router';
import { resetStaticRoutes } from '@vben/utils';
import { createRouterGuard } from './guard';
import { routes } from './routes';
/**
* @zh_CN 创建vue-router实例
*/
const router = createRouter({
history:
import.meta.env.VITE_ROUTER_HISTORY === 'hash'
? createWebHashHistory(import.meta.env.VITE_BASE)
: createWebHistory(import.meta.env.VITE_BASE),
// 应该添加到路由的初始路由列表。
routes,
scrollBehavior: (to, _from, savedPosition) => {
if (savedPosition) {
return savedPosition;
}
return to.hash ? { behavior: 'smooth', el: to.hash } : { left: 0, top: 0 };
},
// 是否应该禁止尾部斜杠。
// strict: true,
});
const resetRoutes = () => resetStaticRoutes(router, routes);
// 创建路由守卫
createRouterGuard(router);
export { resetRoutes, router };

View File

@@ -0,0 +1,96 @@
import type { RouteRecordRaw } from 'vue-router';
import { DEFAULT_HOME_PATH, LOGIN_PATH } from '@vben/constants';
import { $t } from '#/locales';
const BasicLayout = () => import('#/layouts/basic.vue');
const AuthPageLayout = () => import('#/layouts/auth.vue');
/** 全局404页面 */
const fallbackNotFoundRoute: RouteRecordRaw = {
component: () => import('#/views/_core/fallback/not-found.vue'),
meta: {
hideInBreadcrumb: true,
hideInMenu: true,
hideInTab: true,
title: '404',
},
name: 'FallbackNotFound',
path: '/:path(.*)*',
};
/** 基本路由,这些路由是必须存在的 */
const coreRoutes: RouteRecordRaw[] = [
/**
* 根路由
* 使用基础布局作为所有页面的父级容器子级就不必配置BasicLayout。
* 此路由必须存在,且不应修改
*/
{
component: BasicLayout,
meta: {
hideInBreadcrumb: true,
title: 'Root',
},
name: 'Root',
path: '/',
redirect: DEFAULT_HOME_PATH,
children: [],
},
{
component: AuthPageLayout,
meta: {
hideInTab: true,
title: 'Authentication',
},
name: 'Authentication',
path: '/auth',
redirect: LOGIN_PATH,
children: [
{
name: 'Login',
path: 'login',
component: () => import('#/views/_core/authentication/login.vue'),
meta: {
title: $t('page.auth.login'),
},
},
{
name: 'CodeLogin',
path: 'code-login',
component: () => import('#/views/_core/authentication/code-login.vue'),
meta: {
title: $t('page.auth.codeLogin'),
},
},
{
name: 'QrCodeLogin',
path: 'qrcode-login',
component: () =>
import('#/views/_core/authentication/qrcode-login.vue'),
meta: {
title: $t('page.auth.qrcodeLogin'),
},
},
{
name: 'ForgetPassword',
path: 'forget-password',
component: () =>
import('#/views/_core/authentication/forget-password.vue'),
meta: {
title: $t('page.auth.forgetPassword'),
},
},
{
name: 'Register',
path: 'register',
component: () => import('#/views/_core/authentication/register.vue'),
meta: {
title: $t('page.auth.register'),
},
},
],
},
];
export { coreRoutes, fallbackNotFoundRoute };

View File

@@ -0,0 +1,46 @@
import type { RouteRecordRaw } from 'vue-router';
import { mergeRouteModules, traverseTreeValues } from '@vben/utils';
import { coreRoutes, fallbackNotFoundRoute } from './core';
const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', {
eager: true,
});
// 有需要可以自行打开注释,并创建文件夹
// const externalRouteFiles = import.meta.glob('./external/**/*.ts', { eager: true });
// const staticRouteFiles = import.meta.glob('./static/**/*.ts', { eager: true });
/** 动态路由 */
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
/** 外部路由列表访问这些页面可以不需要Layout可能用于内嵌在别的系统(不会显示在菜单中) */
// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
const staticRoutes: RouteRecordRaw[] = [];
const externalRoutes: RouteRecordRaw[] = [];
/** 路由列表由基本路由、外部路由和404兜底路由组成
* 无需走权限验证(会一直显示在菜单中) */
const routes: RouteRecordRaw[] = [
...coreRoutes,
...externalRoutes,
fallbackNotFoundRoute,
];
/** 基本路由列表,这些路由不需要进入权限拦截 */
const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
/** 有权限校验的路由列表,包含动态路由和静态路由 */
const accessRoutes = [...dynamicRoutes, ...staticRoutes];
const componentKeys: string[] = Object.keys(
import.meta.glob('../../views/**/*.vue'),
)
.filter((item) => !item.includes('/modules/'))
.map((v) => {
const path = v.replace('../../views/', '/');
return path.endsWith('.vue') ? path.slice(0, -4) : path;
});
export { accessRoutes, componentKeys, coreRouteNames, routes };

View File

@@ -0,0 +1,38 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:layout-dashboard',
order: -1,
title: $t('page.dashboard.title'),
},
name: 'Dashboard',
path: '/dashboard',
children: [
{
name: 'Analytics',
path: '/analytics',
component: () => import('#/views/dashboard/analytics/index.vue'),
meta: {
affixTab: true,
icon: 'lucide:area-chart',
title: $t('page.dashboard.analytics'),
},
},
{
name: 'Workspace',
path: '/workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',
title: $t('page.dashboard.workspace'),
},
},
],
},
];
export default routes;

View File

@@ -0,0 +1,16 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:shopping-cart',
order: 1000,
title: '订单管理',
},
name: 'Order',
path: '/order',
component: () => import('#/views/order/index.vue'),
},
];
export default routes;

View File

@@ -0,0 +1,115 @@
import type { Recordable, UserInfo } from '@vben/types';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { DEFAULT_HOME_PATH, LOGIN_PATH } from '@vben/constants';
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
import { notification } from 'ant-design-vue';
import { defineStore } from 'pinia';
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
import { $t } from '#/locales';
export const useAuthStore = defineStore('auth', () => {
const accessStore = useAccessStore();
const userStore = useUserStore();
const router = useRouter();
const loginLoading = ref(false);
/**
* 异步处理登录操作
* Asynchronously handle the login process
* @param params 登录表单数据
*/
async function authLogin(
params: Recordable<any>,
onSuccess?: () => Promise<void> | void,
) {
// 异步处理用户登录操作并获取 accessToken
let userInfo: null | UserInfo = null;
try {
loginLoading.value = true;
const { access_token } = await loginApi(params);
console.info('accessToken', access_token);
// 如果成功获取到 accessToken
if (access_token) {
accessStore.setAccessToken(access_token);
// 获取用户信息并存储到 accessStore 中
const [fetchUserInfoResult, accessCodes] = await Promise.all([
fetchUserInfo(),
getAccessCodesApi(),
]);
userInfo = fetchUserInfoResult;
userStore.setUserInfo(userInfo);
accessStore.setAccessCodes(accessCodes);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
} else {
onSuccess
? await onSuccess?.()
: await router.push(userInfo.homePath || DEFAULT_HOME_PATH);
}
if (userInfo?.realName) {
notification.success({
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
duration: 3,
message: $t('authentication.loginSuccess'),
});
}
}
} finally {
loginLoading.value = false;
}
return {
userInfo,
};
}
async function logout(redirect: boolean = true) {
try {
await logoutApi();
} catch {
// 不做任何处理
}
resetAllStores();
accessStore.setLoginExpired(false);
// 回登录页带上当前路由地址
await router.replace({
path: LOGIN_PATH,
query: redirect
? {
redirect: encodeURIComponent(router.currentRoute.value.fullPath),
}
: {},
});
}
async function fetchUserInfo() {
let userInfo: null | UserInfo = null;
userInfo = await getUserInfoApi();
userStore.setUserInfo(userInfo);
return userInfo;
}
function $reset() {
loginLoading.value = false;
}
return {
$reset,
authLogin,
fetchUserInfo,
loginLoading,
logout,
};
});

View File

@@ -0,0 +1 @@
export * from './auth';

View File

@@ -0,0 +1,3 @@
# \_core
此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { About } from '@vben/common-ui';
defineOptions({ name: 'About' });
</script>
<template>
<About />
</template>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { computed, ref } from 'vue';
import { AuthenticationCodeLogin, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
defineOptions({ name: 'CodeLogin' });
const loading = ref(false);
const CODE_LENGTH = 6;
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.mobile'),
},
fieldName: 'phoneNumber',
label: $t('authentication.mobile'),
rules: z
.string()
.min(1, { message: $t('authentication.mobileTip') })
.refine((v) => /^\d{11}$/.test(v), {
message: $t('authentication.mobileErrortip'),
}),
},
{
component: 'VbenPinInput',
componentProps: {
codeLength: CODE_LENGTH,
createText: (countdown: number) => {
const text =
countdown > 0
? $t('authentication.sendText', [countdown])
: $t('authentication.sendCode');
return text;
},
placeholder: $t('authentication.code'),
},
fieldName: 'code',
label: $t('authentication.code'),
rules: z.string().length(CODE_LENGTH, {
message: $t('authentication.codeTip', [CODE_LENGTH]),
}),
},
];
});
/**
* 异步处理登录操作
* Asynchronously handle the login process
* @param values 登录表单数据
*/
async function handleLogin(values: Recordable<any>) {
// eslint-disable-next-line no-console
console.log(values);
}
</script>
<template>
<AuthenticationCodeLogin
:form-schema="formSchema"
:loading="loading"
@submit="handleLogin"
/>
</template>

View File

@@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { computed, ref } from 'vue';
import { AuthenticationForgetPassword, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
defineOptions({ name: 'ForgetPassword' });
const loading = ref(false);
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenInput',
componentProps: {
placeholder: 'example@example.com',
},
fieldName: 'email',
label: $t('authentication.email'),
rules: z
.string()
.min(1, { message: $t('authentication.emailTip') })
.email($t('authentication.emailValidErrorTip')),
},
];
});
function handleSubmit(value: Recordable<any>) {
// eslint-disable-next-line no-console
console.log('reset email:', value);
}
</script>
<template>
<AuthenticationForgetPassword
:form-schema="formSchema"
:loading="loading"
@submit="handleSubmit"
/>
</template>

View File

@@ -0,0 +1,57 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import { computed, markRaw } from 'vue';
import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { useAuthStore } from '#/store';
defineOptions({ name: 'Login' });
const authStore = useAuthStore();
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
fieldName: 'username',
label: $t('authentication.username'),
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
},
{
component: 'VbenInputPassword',
componentProps: {
placeholder: $t('authentication.password'),
},
fieldName: 'password',
label: $t('authentication.password'),
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
},
{
component: markRaw(SliderCaptcha),
fieldName: 'captcha',
rules: z.boolean().refine((value) => value, {
message: $t('authentication.verifyRequiredTip'),
}),
},
];
});
</script>
<template>
<AuthenticationLogin
:form-schema="formSchema"
:loading="authStore.loginLoading"
:show-third-party-login="false"
:show-register="false"
:show-forget-password="false"
:show-qrcode-login="false"
:show-code-login="false"
@submit="authStore.authLogin"
/>
</template>

View File

@@ -0,0 +1,10 @@
<script lang="ts" setup>
import { AuthenticationQrCodeLogin } from '@vben/common-ui';
import { LOGIN_PATH } from '@vben/constants';
defineOptions({ name: 'QrCodeLogin' });
</script>
<template>
<AuthenticationQrCodeLogin :login-path="LOGIN_PATH" />
</template>

View File

@@ -0,0 +1,96 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { computed, h, ref } from 'vue';
import { AuthenticationRegister, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
defineOptions({ name: 'Register' });
const loading = ref(false);
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
fieldName: 'username',
label: $t('authentication.username'),
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
},
{
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: $t('authentication.password'),
},
fieldName: 'password',
label: $t('authentication.password'),
renderComponentContent() {
return {
strengthText: () => $t('authentication.passwordStrength'),
};
},
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
},
{
component: 'VbenInputPassword',
componentProps: {
placeholder: $t('authentication.confirmPassword'),
},
dependencies: {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),
});
},
triggerFields: ['password'],
},
fieldName: 'confirmPassword',
label: $t('authentication.confirmPassword'),
},
{
component: 'VbenCheckbox',
fieldName: 'agreePolicy',
renderComponentContent: () => ({
default: () =>
h('span', [
$t('authentication.agree'),
h(
'a',
{
class: 'vben-link ml-1 ',
href: '',
},
`${$t('authentication.privacyPolicy')} & ${$t('authentication.terms')}`,
),
]),
}),
rules: z.boolean().refine((value) => !!value, {
message: $t('authentication.agreeTip'),
}),
},
];
});
function handleSubmit(value: Recordable<any>) {
// eslint-disable-next-line no-console
console.log('register submit:', value);
}
</script>
<template>
<AuthenticationRegister
:form-schema="formSchema"
:loading="loading"
@submit="handleSubmit"
/>
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
</script>
<template>
<Fallback status="coming-soon" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback403Demo' });
</script>
<template>
<Fallback status="403" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback500Demo' });
</script>
<template>
<Fallback status="500" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback404Demo' });
</script>
<template>
<Fallback status="404" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'FallbackOfflineDemo' });
</script>
<template>
<Fallback status="offline" />
</template>

View File

@@ -0,0 +1,98 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
onMounted(() => {
renderEcharts({
grid: {
bottom: 0,
containLabel: true,
left: '1%',
right: '1%',
top: '2 %',
},
series: [
{
areaStyle: {},
data: [
111, 2000, 6000, 16_000, 33_333, 55_555, 64_000, 33_333, 18_000,
36_000, 70_000, 42_444, 23_222, 13_000, 8000, 4000, 1200, 333, 222,
111,
],
itemStyle: {
color: '#5ab1ef',
},
smooth: true,
type: 'line',
},
{
areaStyle: {},
data: [
33, 66, 88, 333, 3333, 6200, 20_000, 3000, 1200, 13_000, 22_000,
11_000, 2221, 1201, 390, 198, 60, 30, 22, 11,
],
itemStyle: {
color: '#019680',
},
smooth: true,
type: 'line',
},
],
tooltip: {
axisPointer: {
lineStyle: {
color: '#019680',
width: 1,
},
},
trigger: 'axis',
},
// xAxis: {
// axisTick: {
// show: false,
// },
// boundaryGap: false,
// data: Array.from({ length: 18 }).map((_item, index) => `${index + 6}:00`),
// type: 'category',
// },
xAxis: {
axisTick: {
show: false,
},
boundaryGap: false,
data: Array.from({ length: 18 }).map((_item, index) => `${index + 6}:00`),
splitLine: {
lineStyle: {
type: 'solid',
width: 1,
},
show: true,
},
type: 'category',
},
yAxis: [
{
axisTick: {
show: false,
},
max: 80_000,
splitArea: {
show: true,
},
splitNumber: 4,
type: 'value',
},
],
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
onMounted(() => {
renderEcharts({
legend: {
bottom: 0,
data: ['访问', '趋势'],
},
radar: {
indicator: [
{
name: '网页',
},
{
name: '移动端',
},
{
name: 'Ipad',
},
{
name: '客户端',
},
{
name: '第三方',
},
{
name: '其它',
},
],
radius: '60%',
splitNumber: 8,
},
series: [
{
areaStyle: {
opacity: 1,
shadowBlur: 0,
shadowColor: 'rgba(0,0,0,.2)',
shadowOffsetX: 0,
shadowOffsetY: 10,
},
data: [
{
itemStyle: {
color: '#b6a2de',
},
name: '访问',
value: [90, 50, 86, 40, 50, 20],
},
{
itemStyle: {
color: '#5ab1ef',
},
name: '趋势',
value: [70, 75, 70, 76, 20, 85],
},
],
itemStyle: {
// borderColor: '#fff',
borderRadius: 10,
borderWidth: 2,
},
symbolSize: 0,
type: 'radar',
},
],
tooltip: {},
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>

View File

@@ -0,0 +1,46 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
onMounted(() => {
renderEcharts({
series: [
{
animationDelay() {
return Math.random() * 400;
},
animationEasing: 'exponentialInOut',
animationType: 'scale',
center: ['50%', '50%'],
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
data: [
{ name: '外包', value: 500 },
{ name: '定制', value: 310 },
{ name: '技术支持', value: 274 },
{ name: '远程', value: 400 },
].sort((a, b) => {
return a.value - b.value;
}),
name: '商业占比',
radius: '80%',
roseType: 'radius',
type: 'pie',
},
],
tooltip: {
trigger: 'item',
},
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>

View File

@@ -0,0 +1,65 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
onMounted(() => {
renderEcharts({
legend: {
bottom: '2%',
left: 'center',
},
series: [
{
animationDelay() {
return Math.random() * 100;
},
animationEasing: 'exponentialInOut',
animationType: 'scale',
avoidLabelOverlap: false,
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
data: [
{ name: '搜索引擎', value: 1048 },
{ name: '直接访问', value: 735 },
{ name: '邮件营销', value: 580 },
{ name: '联盟广告', value: 484 },
],
emphasis: {
label: {
fontSize: '12',
fontWeight: 'bold',
show: true,
},
},
itemStyle: {
// borderColor: '#fff',
borderRadius: 10,
borderWidth: 2,
},
label: {
position: 'center',
show: false,
},
labelLine: {
show: false,
},
name: '访问来源',
radius: ['40%', '65%'],
type: 'pie',
},
],
tooltip: {
trigger: 'item',
},
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>

View File

@@ -0,0 +1,55 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
onMounted(() => {
renderEcharts({
grid: {
bottom: 0,
containLabel: true,
left: '1%',
right: '1%',
top: '2 %',
},
series: [
{
barMaxWidth: 80,
// color: '#4f69fd',
data: [
3000, 2000, 3333, 5000, 3200, 4200, 3200, 2100, 3000, 5100, 6000,
3200, 4800,
],
type: 'bar',
},
],
tooltip: {
axisPointer: {
lineStyle: {
// color: '#4f69fd',
width: 1,
},
},
trigger: 'axis',
},
xAxis: {
data: Array.from({ length: 12 }).map((_item, index) => `${index + 1}`),
type: 'category',
},
yAxis: {
max: 8000,
splitNumber: 4,
type: 'value',
},
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>

View File

@@ -0,0 +1,90 @@
<script lang="ts" setup>
import type { AnalysisOverviewItem } from '@vben/common-ui';
import type { TabOption } from '@vben/types';
import {
AnalysisChartCard,
AnalysisChartsTabs,
AnalysisOverview,
} from '@vben/common-ui';
import {
SvgBellIcon,
SvgCakeIcon,
SvgCardIcon,
SvgDownloadIcon,
} from '@vben/icons';
import AnalyticsTrends from './analytics-trends.vue';
import AnalyticsVisitsData from './analytics-visits-data.vue';
import AnalyticsVisitsSales from './analytics-visits-sales.vue';
import AnalyticsVisitsSource from './analytics-visits-source.vue';
import AnalyticsVisits from './analytics-visits.vue';
const overviewItems: AnalysisOverviewItem[] = [
{
icon: SvgCardIcon,
title: '用户量',
totalTitle: '总用户量',
totalValue: 120_000,
value: 2000,
},
{
icon: SvgCakeIcon,
title: '访问量',
totalTitle: '总访问量',
totalValue: 500_000,
value: 20_000,
},
{
icon: SvgDownloadIcon,
title: '下载量',
totalTitle: '总下载量',
totalValue: 120_000,
value: 8000,
},
{
icon: SvgBellIcon,
title: '使用量',
totalTitle: '总使用量',
totalValue: 50_000,
value: 5000,
},
];
const chartTabs: TabOption[] = [
{
label: '流量趋势',
value: 'trends',
},
{
label: '月访问量',
value: 'visits',
},
];
</script>
<template>
<div class="p-5">
<AnalysisOverview :items="overviewItems" />
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
<template #trends>
<AnalyticsTrends />
</template>
<template #visits>
<AnalyticsVisits />
</template>
</AnalysisChartsTabs>
<div class="mt-5 w-full md:flex">
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="访问数量">
<AnalyticsVisitsData />
</AnalysisChartCard>
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="访问来源">
<AnalyticsVisitsSource />
</AnalysisChartCard>
<AnalysisChartCard class="mt-5 md:mt-0 md:w-1/3" title="访问来源">
<AnalyticsVisitsSales />
</AnalysisChartCard>
</div>
</div>
</template>

View File

@@ -0,0 +1,266 @@
<script lang="ts" setup>
import type {
WorkbenchProjectItem,
WorkbenchQuickNavItem,
WorkbenchTodoItem,
WorkbenchTrendItem,
} from '@vben/common-ui';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import {
AnalysisChartCard,
WorkbenchHeader,
WorkbenchProject,
WorkbenchQuickNav,
WorkbenchTodo,
WorkbenchTrends,
} from '@vben/common-ui';
import { preferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue';
const userStore = useUserStore();
// 这是一个示例数据,实际项目中需要根据实际情况进行调整
// url 也可以是内部路由,在 navTo 方法中识别处理,进行内部跳转
// 例如url: /dashboard/workspace
const projectItems: WorkbenchProjectItem[] = [
{
color: '',
content: '不要等待机会,而要创造机会。',
date: '2021-04-01',
group: '开源组',
icon: 'carbon:logo-github',
title: 'Github',
url: 'https://github.com',
},
{
color: '#3fb27f',
content: '现在的你决定将来的你。',
date: '2021-04-01',
group: '算法组',
icon: 'ion:logo-vue',
title: 'Vue',
url: 'https://vuejs.org',
},
{
color: '#e18525',
content: '没有什么才能比努力更重要。',
date: '2021-04-01',
group: '上班摸鱼',
icon: 'ion:logo-html5',
title: 'Html5',
url: 'https://developer.mozilla.org/zh-CN/docs/Web/HTML',
},
{
color: '#bf0c2c',
content: '热情和欲望可以突破一切难关。',
date: '2021-04-01',
group: 'UI',
icon: 'ion:logo-angular',
title: 'Angular',
url: 'https://angular.io',
},
{
color: '#00d8ff',
content: '健康的身体是实现目标的基石。',
date: '2021-04-01',
group: '技术牛',
icon: 'bx:bxl-react',
title: 'React',
url: 'https://reactjs.org',
},
{
color: '#EBD94E',
content: '路是走出来的,而不是空想出来的。',
date: '2021-04-01',
group: '架构组',
icon: 'ion:logo-javascript',
title: 'Js',
url: 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript',
},
];
// 同样,这里的 url 也可以使用以 http 开头的外部链接
const quickNavItems: WorkbenchQuickNavItem[] = [
{
color: '#1fdaca',
icon: 'ion:home-outline',
title: '首页',
url: '/',
},
{
color: '#bf0c2c',
icon: 'ion:grid-outline',
title: '仪表盘',
url: '/dashboard',
},
{
color: '#e18525',
icon: 'ion:layers-outline',
title: '组件',
url: '/demos/features/icons',
},
{
color: '#3fb27f',
icon: 'ion:settings-outline',
title: '系统管理',
url: '/demos/features/login-expired', // 这里的 URL 是示例,实际项目中需要根据实际情况进行调整
},
{
color: '#4daf1bc9',
icon: 'ion:key-outline',
title: '权限管理',
url: '/demos/access/page-control',
},
{
color: '#00d8ff',
icon: 'ion:bar-chart-outline',
title: '图表',
url: '/analytics',
},
];
const todoItems = ref<WorkbenchTodoItem[]>([
{
completed: false,
content: `审查最近提交到Git仓库的前端代码确保代码质量和规范。`,
date: '2024-07-30 11:00:00',
title: '审查前端代码提交',
},
{
completed: true,
content: `检查并优化系统性能降低CPU使用率。`,
date: '2024-07-30 11:00:00',
title: '系统性能优化',
},
{
completed: false,
content: `进行系统安全检查,确保没有安全漏洞或未授权的访问。 `,
date: '2024-07-30 11:00:00',
title: '安全检查',
},
{
completed: false,
content: `更新项目中的所有npm依赖包确保使用最新版本。`,
date: '2024-07-30 11:00:00',
title: '更新项目依赖',
},
{
completed: false,
content: `修复用户报告的页面UI显示问题确保在不同浏览器中显示一致。 `,
date: '2024-07-30 11:00:00',
title: '修复UI显示问题',
},
]);
const trendItems: WorkbenchTrendItem[] = [
{
avatar: 'svg:avatar-1',
content: `在 <a>开源组</a> 创建了项目 <a>Vue</a>`,
date: '刚刚',
title: '威廉',
},
{
avatar: 'svg:avatar-2',
content: `关注了 <a>威廉</a> `,
date: '1个小时前',
title: '艾文',
},
{
avatar: 'svg:avatar-3',
content: `发布了 <a>个人动态</a> `,
date: '1天前',
title: '克里斯',
},
{
avatar: 'svg:avatar-4',
content: `发表文章 <a>如何编写一个Vite插件</a> `,
date: '2天前',
title: 'Vben',
},
{
avatar: 'svg:avatar-1',
content: `回复了 <a>杰克</a> 的问题 <a>如何进行项目优化?</a>`,
date: '3天前',
title: '皮特',
},
{
avatar: 'svg:avatar-2',
content: `关闭了问题 <a>如何运行项目</a> `,
date: '1周前',
title: '杰克',
},
{
avatar: 'svg:avatar-3',
content: `发布了 <a>个人动态</a> `,
date: '1周前',
title: '威廉',
},
{
avatar: 'svg:avatar-4',
content: `推送了代码到 <a>Github</a>`,
date: '2021-04-01 20:00',
title: '威廉',
},
{
avatar: 'svg:avatar-4',
content: `发表文章 <a>如何编写使用 Admin Vben</a> `,
date: '2021-03-01 20:00',
title: 'Vben',
},
];
const router = useRouter();
// 这是一个示例方法,实际项目中需要根据实际情况进行调整
// This is a sample method, adjust according to the actual project requirements
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
if (nav.url?.startsWith('http')) {
openWindow(nav.url);
return;
}
if (nav.url?.startsWith('/')) {
router.push(nav.url).catch((error) => {
console.error('Navigation failed:', error);
});
} else {
console.warn(`Unknown URL for navigation item: ${nav.title} -> ${nav.url}`);
}
}
</script>
<template>
<div class="p-5">
<WorkbenchHeader
:avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
>
<template #title>
早安, {{ userStore.userInfo?.realName }}, 开始您一天的工作吧
</template>
<template #description> 今日晴20 - 32 </template>
</WorkbenchHeader>
<div class="mt-5 flex flex-col lg:flex-row">
<div class="mr-4 w-full lg:w-3/5">
<WorkbenchProject :items="projectItems" title="项目" @click="navTo" />
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />
</div>
<div class="w-full lg:w-2/5">
<WorkbenchQuickNav
:items="quickNavItems"
class="mt-5 lg:mt-0"
title="快捷导航"
@click="navTo"
/>
<WorkbenchTodo :items="todoItems" class="mt-5" title="待办事项" />
<AnalysisChartCard class="mt-5" title="访问来源">
<AnalyticsVisitsSource />
</AnalysisChartCard>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,175 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { NotificationApi } from '#/api/notification';
// 表单配置
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'title',
label: '通知标题',
rules: 'required',
},
{
component: 'Input',
fieldName: 'notification_page',
label: '通知页面',
rules: 'required',
},
{
component: 'RichText',
fieldName: 'content',
label: '通知内容',
rules: 'required',
},
{
component: 'Input',
fieldName: 'show_date',
label: '展示日期',
},
{
component: 'Input',
fieldName: 'show_time',
label: '展示时间',
},
{
component: 'RadioGroup',
fieldName: 'status',
label: '状态',
rules: 'required',
componentProps: {
buttonStyle: 'solid',
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
optionType: 'button',
},
defaultValue: 1,
},
];
}
// 搜索表单配置
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'title',
label: '通知标题',
},
{
component: 'Select',
fieldName: 'notification_page',
label: '通知页面',
componentProps: {
allowClear: true,
options: [
{ label: '首页', value: 'home' },
{ label: '个人中心', value: 'profile' },
{ label: '订单页', value: 'order' },
],
},
},
{
component: 'Select',
fieldName: 'status',
label: '状态',
componentProps: {
allowClear: true,
options: [
{ label: '启用', value: 'active' },
{ label: '禁用', value: 'inactive' },
],
},
},
{
component: 'RangePicker',
fieldName: 'date_range',
label: '生效时间',
componentProps: {
showTime: true,
},
},
];
}
// 表格列配置
export function useColumns<T = NotificationApi.NotificationItem>(
onActionClick: OnActionClickFn<T>,
onStatusChange?: (
newStatus: 0 | 1,
row: T,
) => PromiseLike<boolean | undefined>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'title',
title: '通知标题',
minWidth: 200,
},
{
field: 'notification_page',
title: '通知页面',
width: 200,
},
{
field: 'show_date',
title: '展示日期',
width: 300,
formatter: ({ row }) => {
return !row.start_date && !row.end_date
? '每天'
: `${row.start_date}${row.end_date}`;
},
},
{
field: 'show_time',
title: '展示时间',
width: 300,
formatter: ({ row }) => {
return (!row.start_time && !row.end_time) ||
row.start_time === row.end_time
? '全天'
: `${row.start_time}${row.end_time}`;
},
},
{
cellRender: {
attrs: {
beforeChange: onStatusChange,
},
name: onStatusChange ? 'CellSwitch' : 'CellTag',
},
field: 'status',
title: '状态',
width: 100,
},
{
field: 'create_time',
title: '创建时间',
width: 180,
},
{
field: 'update_time',
title: '更新时间',
width: 180,
},
{
align: 'center',
cellRender: {
attrs: {
nameField: 'title',
nameTitle: '通知',
onClick: onActionClick,
},
name: 'CellOperation',
},
field: 'operation',
fixed: 'right',
title: '操作',
width: 130,
},
];
}

View File

@@ -0,0 +1,204 @@
<script lang="ts" setup>
import type { Recordable } from '@vben/types';
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { NotificationApi } from '#/api/notification';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Button, message, Modal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteNotification,
getNotificationList,
updateNotification,
} from '#/api/notification';
import { useColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
// 表单抽屉
const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form,
destroyOnClose: true,
});
// 表格配置
const columns = useColumns<NotificationApi.NotificationItem>(
onActionClick,
(newStatus: 0 | 1, row: NotificationApi.NotificationItem) => {
return onStatusChange(newStatus, row);
},
);
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
fieldMappingTime: [['date', ['startDate', 'endDate']]],
schema: useGridFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns,
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getNotificationList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
custom: true,
export: false,
refresh: { code: 'query' },
search: true,
zoom: true,
},
} as VxeTableGridOptions<NotificationApi.NotificationItem>,
});
// 操作处理函数
function onActionClick(
e: OnActionClickParams<NotificationApi.NotificationItem>,
) {
switch (e.code) {
case 'delete': {
onDelete(e.row);
break;
}
case 'edit': {
onEdit(e.row);
break;
}
}
}
/**
* 将Antd的Modal.confirm封装为promise方便在异步函数中调用。
* @param content 提示内容
* @param title 提示标题
*/
function confirm(content: string, title: string) {
return new Promise((reslove, reject) => {
Modal.confirm({
content,
onCancel() {
reject(new Error('已取消'));
},
onOk() {
reslove(true);
},
title,
});
});
}
/**
* 状态开关即将改变
* @param newStatus 期望改变的状态值
* @param row 行数据
* @returns 返回false则中止改变返回其他值undefined、true则允许改变
*/
async function onStatusChange(
newStatus: 0 | 1,
row: NotificationApi.NotificationItem,
) {
const status: Recordable<string> = {
1: '启用',
0: '禁用',
};
try {
await confirm(
`你要将通知"${row.title}"的状态切换为【${status[newStatus]}】吗?`,
'切换状态',
);
// 获取完整的通知数据
const notification = await getNotificationList({
page: 1,
pageSize: 1,
id: row.id,
});
const fullData = notification.items[0];
if (!fullData) {
message.error('获取通知数据失败');
return false;
}
await updateNotification(row.id, {
id: row.id,
status: newStatus,
title: fullData.title,
content: fullData.content || '',
notification_page: fullData.notification_page,
start_date: fullData.start_date,
start_time: fullData.start_time,
end_date: fullData.end_date,
end_time: fullData.end_time,
});
return true;
} catch {
return false;
}
}
// 编辑处理
function onEdit(row: NotificationApi.NotificationItem) {
formDrawerApi.setData(row).open();
}
// 删除处理
function onDelete(row: NotificationApi.NotificationItem) {
const hideLoading = message.loading({
content: `正在删除通知:${row.title}`,
duration: 0,
key: 'action_process_msg',
});
deleteNotification(row.id)
.then(() => {
message.success({
content: `删除通知成功:${row.title}`,
key: 'action_process_msg',
});
onRefresh();
})
.catch(() => {
hideLoading();
});
}
// 刷新处理
function onRefresh() {
gridApi.query();
}
// 创建处理
function onCreate() {
formDrawerApi.setData({}).open();
}
</script>
<template>
<Page auto-content-height>
<FormDrawer @success="onRefresh" />
<Grid table-title="通知列表">
<template #toolbar-tools>
<Button type="primary" @click="onCreate">
<Plus class="size-5" />
创建通知
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,270 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { NotificationApi } from '#/api/notification';
import { computed, nextTick, ref, watch } from 'vue';
import { useVbenDrawer, useVbenForm } from '@vben/common-ui';
import { DatePicker, RadioButton, RadioGroup } from 'ant-design-vue';
import dayjs from 'dayjs';
import { createNotification, updateNotification } from '#/api/notification';
import { useFormSchema } from '../data';
const emit = defineEmits<{
(e: 'success'): void;
}>();
// 使用单选按钮组来控制模式
const dateMode = ref<'daily' | 'range'>('range');
const timeMode = ref<'allDay' | 'range'>('range');
// 表单值管理
const formValues = ref<{
[key: string]: any;
dateRange?: [Dayjs, Dayjs] | undefined;
timeRange?: [Dayjs, Dayjs] | undefined;
}>({});
const id = ref<number>();
// 表单配置
const [Form, formApi] = useVbenForm({
schema: useFormSchema(),
showDefaultActions: false,
});
// 抽屉配置
const [Drawer, drawerApi] = useVbenDrawer({
class: 'w-[800px]',
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
// 获取表单值
const values = await formApi.getValues();
// 处理日期时间值
if (dateMode.value === 'daily') {
values.start_date = '';
values.end_date = '';
} else if (formValues.value.dateRange) {
const [start, end] = formValues.value.dateRange;
values.start_date = start.format('YYYY-MM-DD');
values.end_date = end.format('YYYY-MM-DD');
}
if (timeMode.value === 'allDay') {
values.start_time = '';
values.end_time = '';
} else if (formValues.value.timeRange) {
const [start, end] = formValues.value.timeRange;
values.start_time = start.format('HH:mm:ss');
values.end_time = end.format('HH:mm:ss');
}
// 提交数据
drawerApi.lock();
try {
await (id.value
? updateNotification(id.value, {
...values,
} as NotificationApi.UpdateNotificationRequest)
: createNotification(
values as NotificationApi.CreateNotificationRequest,
));
emit('success');
drawerApi.close();
} catch {
drawerApi.unlock();
}
},
onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<NotificationApi.NotificationItem>();
formApi.resetForm();
if (data) {
id.value = data.id;
// 初始化日期时间范围
const dateRange =
data.start_date && data.end_date
? ([dayjs(data.start_date), dayjs(data.end_date)] as [Dayjs, Dayjs])
: undefined;
const timeRange =
data.start_time && data.end_time
? ([
dayjs(data.start_time, 'HH:mm:ss'),
dayjs(data.end_time, 'HH:mm:ss'),
] as [Dayjs, Dayjs])
: undefined;
// 设置表单值
formValues.value = {
...data,
dateRange,
timeRange,
};
nextTick(() => {
// 设置表单字段值
formApi.setValues({
...data,
start_date: data.start_date || undefined,
end_date: data.end_date || undefined,
start_time: data.start_time || undefined,
end_time: data.end_time || undefined,
});
});
// 设置模式
dateMode.value = !data.start_date && !data.end_date ? 'daily' : 'range';
timeMode.value =
(!data.start_time && !data.end_time) ||
data.start_time === data.end_time
? 'allDay'
: 'range';
} else {
// 重置所有值
id.value = undefined;
formValues.value = {
dateRange: undefined,
timeRange: undefined,
};
dateMode.value = 'range';
timeMode.value = 'range';
}
}
},
});
// 计算抽屉标题
const getDrawerTitle = computed(() => {
return id.value ? '编辑通知' : '创建通知';
});
// 监听模式变化
watch(dateMode, (newMode) => {
if (newMode === 'daily') {
formValues.value.dateRange = undefined;
formApi.setFieldValue('start_date', '');
formApi.setFieldValue('end_date', '');
}
});
watch(timeMode, (newMode) => {
if (newMode === 'allDay') {
formValues.value.timeRange = undefined;
formApi.setFieldValue('start_time', '');
formApi.setFieldValue('end_time', '');
}
});
// 更新字段值
const updateField = (
field: string,
value: [Dayjs, Dayjs] | null | undefined,
) => {
if (!value) {
if (field === 'dateRange') {
formValues.value.dateRange = undefined;
formApi.setFieldValue('start_date', '');
formApi.setFieldValue('end_date', '');
} else if (field === 'timeRange') {
formValues.value.timeRange = undefined;
formApi.setFieldValue('start_time', '');
formApi.setFieldValue('end_time', '');
}
return;
}
const [start, end] = value;
if (!start || !end) return;
if (field === 'dateRange') {
formValues.value.dateRange = [start, end];
formApi.setFieldValue('start_date', start.format('YYYY-MM-DD'));
formApi.setFieldValue('end_date', end.format('YYYY-MM-DD'));
} else if (field === 'timeRange') {
formValues.value.timeRange = [start, end];
formApi.setFieldValue('start_time', start.format('HH:mm:ss'));
formApi.setFieldValue('end_time', end.format('HH:mm:ss'));
}
};
</script>
<template>
<Drawer :title="getDrawerTitle">
<Form>
<template #show_date>
<div class="space-y-4">
<div class="flex items-center gap-2">
<RadioGroup v-model:value="dateMode" button-style="solid">
<RadioButton value="range">日期范围</RadioButton>
<RadioButton value="daily">每天</RadioButton>
</RadioGroup>
</div>
<div v-if="dateMode === 'range'" class="form-item">
<label class="form-label">日期范围</label>
<DatePicker.RangePicker
:value="formValues.dateRange"
@update:value="
(val) => updateField('dateRange', val as [Dayjs, Dayjs] | null)
"
format="YYYY-MM-DD"
class="w-full"
/>
</div>
</div>
</template>
<template #show_time>
<div class="space-y-4">
<div class="flex items-center gap-2">
<RadioGroup v-model:value="timeMode" button-style="solid">
<RadioButton value="range">时间段</RadioButton>
<RadioButton value="allDay">全天</RadioButton>
</RadioGroup>
</div>
<div v-if="timeMode === 'range'" class="form-item">
<label class="form-label">时间范围</label>
<DatePicker.RangePicker
:value="formValues.timeRange"
@update:value="
(val) => updateField('timeRange', val as [Dayjs, Dayjs] | null)
"
format="HH:mm:ss"
picker="time"
class="w-full"
/>
</div>
</div>
</template>
</Form>
</Drawer>
</template>
<style scoped>
.form-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-label {
font-size: 14px;
color: rgb(0 0 0 / 85%);
}
:deep(.ant-radio-group) {
margin-bottom: 8px;
}
:deep(.ant-picker) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,221 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { OrderApi } from '#/api/order';
export function useColumns<T = OrderApi.Order>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: 'ID',
width: 80,
},
{
field: 'order_no',
title: '商户订单号',
minWidth: 180,
},
{
field: 'platform_order_id',
title: '支付订单号',
minWidth: 220,
},
{
field: 'product_name',
title: '产品',
minWidth: 150,
},
{
field: 'payment_platform',
title: '支付方式',
width: 120,
formatter: ({ row }) => {
const platformMap: Record<string, string> = {
alipay: '支付宝',
wechat: '微信支付',
appleiap: '苹果支付',
};
return platformMap[row.payment_platform] || row.payment_platform;
},
},
{
field: 'payment_scene',
title: '支付平台',
width: 120,
formatter: ({ row }) => {
const sceneMap: Record<string, string> = {
app: 'APP',
h5: 'H5',
mini_program: '小程序',
public_account: '公众号',
};
return sceneMap[row.payment_scene] || row.payment_scene;
},
},
{
field: 'amount',
title: '金额',
width: 120,
formatter: ({ row }) => {
return `¥${row.amount.toFixed(2)}`;
},
},
{
cellRender: {
name: 'CellTag',
options: [
{ value: 'pending', color: 'warning', label: '待支付' },
{ value: 'paid', color: 'success', label: '已支付' },
{ value: 'failed', color: 'error', label: '支付失败' },
{ value: 'refunded', color: 'purple', label: '已退款' },
{ value: 'refunding', color: 'pink', label: '退款中' },
{ value: 'closed', color: 'default', label: '已关闭' },
],
},
field: 'status',
title: '支付状态',
width: 120,
},
{
field: 'create_time',
title: '创建时间',
width: 180,
},
{
field: 'pay_time',
title: '支付时间',
width: 180,
},
{
field: 'refund_time',
title: '退款时间',
width: 180,
},
{
cellRender: {
name: 'CellTag',
options: [
{ value: 0, color: 'default', label: '否' },
{ value: 1, color: 'success', label: '是' },
],
},
field: 'is_promotion',
title: '推广订单',
width: 100,
},
{
align: 'center',
cellRender: {
attrs: {
nameField: 'order_no',
nameTitle: '商户订单号',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'refund',
text: '退款',
disabled: (row: OrderApi.Order) => {
return row.status !== 'paid';
},
},
],
},
field: 'operation',
fixed: 'right',
title: '操作',
width: 100,
},
];
}
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'order_no',
label: '商户订单号',
},
{
component: 'Input',
fieldName: 'platform_order_id',
label: '支付订单号',
},
{
component: 'Input',
fieldName: 'product_name',
label: '产品名称',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: '支付宝', value: 'alipay' },
{ label: '微信支付', value: 'wechat' },
{ label: '苹果支付', value: 'appleiap' },
],
},
fieldName: 'payment_platform',
label: '支付方式',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: 'APP', value: 'app' },
{ label: 'H5', value: 'h5' },
{ label: '小程序', value: 'mini_program' },
{ label: '公众号', value: 'public_account' },
],
},
fieldName: 'payment_scene',
label: '支付平台',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: '待支付', value: 'pending' },
{ label: '已支付', value: 'paid' },
{ label: '支付失败', value: 'failed' },
{ label: '已退款', value: 'refunded' },
{ label: '已关闭', value: 'closed' },
],
},
fieldName: 'status',
label: '支付状态',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: '否', value: 0 },
{ label: '是', value: 1 },
],
},
fieldName: 'is_promotion',
label: '推广订单',
},
{
component: 'RangePicker',
fieldName: 'create_time',
label: '创建时间',
},
{
component: 'RangePicker',
fieldName: 'pay_time',
label: '支付时间',
},
{
component: 'RangePicker',
fieldName: 'refund_time',
label: '退款时间',
},
];
}

View File

@@ -0,0 +1,86 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { OrderApi } from '#/api/order';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getOrderList } from '#/api/order';
import { useColumns, useGridFormSchema } from './data';
import RefundForm from './modules/refund-form.vue';
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
fieldMappingTime: [
['create_time', ['create_time_start', 'create_time_end']],
['pay_time', ['pay_time_start', 'pay_time_end']],
['refund_time', ['refund_time_start', 'refund_time_end']],
],
schema: useGridFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getOrderList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
custom: true,
export: true,
refresh: { code: 'query' },
search: true,
zoom: true,
},
} as VxeTableGridOptions<OrderApi.Order>,
});
const [RefundDrawer, refundDrawerApi] = useVbenDrawer({
connectedComponent: RefundForm,
destroyOnClose: true,
});
function onActionClick(e: OnActionClickParams<OrderApi.Order>) {
switch (e.code) {
case 'refund': {
onRefund(e.row);
break;
}
}
}
function onRefund(row: OrderApi.Order) {
refundDrawerApi.setData(row).open();
}
function onRefundSuccess() {
onRefresh();
}
function onRefresh() {
gridApi.query();
}
</script>
<template>
<Page auto-content-height>
<RefundDrawer @success="onRefundSuccess" />
<Grid table-title="订单列表" />
</Page>
</template>

View File

@@ -0,0 +1,171 @@
<script lang="ts" setup>
import type { OrderApi } from '#/api/order';
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm, z } from '#/adapter/form';
import { refundOrder } from '#/api/order';
const emits = defineEmits(['success']);
const formData = ref<OrderApi.Order>();
const loading = ref(false);
const refundResult = ref<OrderApi.RefundOrderResponse>();
// 预设退款原因选项
const refundReasonOptions = [
{ label: '用户不满意要求退款', value: '用户不满意要求退款' },
{ label: '产品问题退款', value: '产品问题退款' },
{ label: '重复下单退款', value: '重复下单退款' },
{ label: '其他原因', value: 'other' },
];
const [Form, formApi] = useVbenForm({
schema: [
{
component: 'InputNumber',
componentProps: {
min: 0,
max: computed(() => formData.value?.amount || 0),
precision: 2,
style: { width: '100%' },
addonBefore: '¥',
},
fieldName: 'refund_amount',
label: '退款金额',
rules: z
.number()
.min(0.01, '退款金额必须大于0')
.refine(
(val: number) => val <= (formData.value?.amount || 0),
'退款金额不能大于订单金额',
),
},
{
component: 'InputNumber',
componentProps: {
min: 0,
max: computed(() => formData.value?.amount || 0),
precision: 2,
style: { width: '100%' },
addonBefore: '¥',
},
fieldName: 'confirm_refund_amount',
label: '确认退款金额',
rules: z.number().refine(async (val: number) => {
const form = await formApi.getValues();
return val === (form as { refund_amount: number }).refund_amount;
}, '两次输入的退款金额不一致'),
},
{
component: 'RadioGroup',
componentProps: {
options: refundReasonOptions,
style: { width: '100%' },
},
fieldName: 'preset_reason',
label: '退款原因',
defaultValue: 'other',
},
{
component: 'Textarea',
componentProps: (values) => {
return {
rows: 3,
placeholder: '请输入具体退款原因',
maxLength: 200,
showCount: true,
style: { width: '100%' },
disabled:
(values as { preset_reason?: string })?.preset_reason !== 'other',
};
},
fieldName: 'refund_reason',
label: ' ',
rules: z.string().superRefine(async (val, ctx) => {
const values = await formApi.getValues();
const presetReason = (values as { preset_reason?: string })
?.preset_reason;
if (presetReason === 'other') {
if (!val || val.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '请输入具体退款原因',
});
} else if (val.length > 200) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '退款原因不能超过200个字符',
});
}
}
}),
},
],
showDefaultActions: false,
});
const [Drawer, drawerApi] = useVbenDrawer({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
const values = await formApi.getValues<{
preset_reason: string;
refund_amount: number;
refund_reason: string;
}>();
loading.value = true;
try {
refundResult.value = await refundOrder(formData.value!.id, {
refund_amount: values.refund_amount,
refund_reason:
values.preset_reason === 'other'
? values.refund_reason
: values.preset_reason,
});
message.success({
content: `退款成功!退款单号:${refundResult.value.refund_no}`,
duration: 0,
key: 'refund_result',
});
emits('success');
drawerApi.close();
} finally {
loading.value = false;
}
},
onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<OrderApi.Order>();
formApi.resetForm();
refundResult.value = undefined;
if (data) {
formData.value = data;
// 默认填入订单金额和选择其他原因
formApi.setValues({
refund_amount: data.amount,
confirm_refund_amount: data.amount,
preset_reason: 'other',
});
}
}
},
});
const getDrawerTitle = computed(() => {
return `退款订单 ${formData.value?.order_no || ''}`;
});
</script>
<template>
<Drawer :title="getDrawerTitle" :loading="loading" width="600">
<Form />
</Drawer>
</template>

View File

@@ -0,0 +1,138 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { PlatformUserApi } from '#/api/platform-user';
// 表单配置
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'mobile',
label: '手机号',
rules: 'required',
},
{
component: 'Input',
fieldName: 'nickname',
label: '昵称',
},
{
component: 'Textarea',
fieldName: 'info',
label: '备注信息',
},
{
component: 'RadioGroup',
fieldName: 'inside',
label: '是否内部用户',
rules: 'required',
componentProps: {
buttonStyle: 'solid',
options: [
{ label: '是', value: 1 },
{ label: '否', value: 0 },
],
optionType: 'button',
},
defaultValue: 0,
},
];
}
// 搜索表单配置
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'mobile',
label: '手机号',
},
{
component: 'Input',
fieldName: 'nickname',
label: '昵称',
},
{
component: 'Select',
fieldName: 'inside',
label: '是否内部用户',
componentProps: {
allowClear: true,
options: [
{ label: '是', value: 1 },
{ label: '否', value: 0 },
],
},
},
{
component: 'RangePicker',
fieldName: 'create_time',
label: '创建时间',
componentProps: {
showTime: true,
},
},
];
}
// 表格列配置
export function useColumns<T = PlatformUserApi.PlatformUserItem>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '用户ID',
width: 100,
},
{
field: 'mobile',
title: '手机号',
},
{
field: 'nickname',
title: '昵称',
width: 120,
},
{
field: 'info',
title: '备注信息',
},
{
field: 'inside',
title: '是否内部用户',
width: 100,
formatter: ({ cellValue }) => (cellValue === 1 ? '是' : '否'),
},
{
field: 'create_time',
title: '创建时间',
width: 160,
sortable: true,
sortType: 'string',
},
{
field: 'update_time',
title: '更新时间',
width: 160,
},
{
align: 'center',
cellRender: {
attrs: {
nameField: 'nickname',
nameTitle: '用户',
onClick: onActionClick,
},
name: 'CellOperation',
options: [
'edit', // 默认的编辑按钮
],
},
field: 'operation',
fixed: 'right',
title: '操作',
width: 130,
},
];
}

View File

@@ -0,0 +1,122 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeGridListeners,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { PlatformUserApi } from '#/api/platform-user';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getPlatformUserList } from '#/api/platform-user';
import { useColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
// 表单抽屉
const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form,
destroyOnClose: true,
});
// 表格配置
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
fieldMappingTime: [
['create_time', ['create_time_start', 'create_time_end']],
],
schema: useGridFormSchema(),
submitOnChange: true,
},
gridEvents: {
sortChange: () => {
gridApi.query();
},
} as VxeGridListeners<PlatformUserApi.PlatformUserItem>,
gridOptions: {
columns: useColumns(onActionClick),
height: 'auto',
keepSource: true,
sortConfig: {
remote: true,
multiple: false,
trigger: 'default',
orders: ['asc', 'desc', null],
resetPage: true,
},
proxyConfig: {
ajax: {
query: async ({ page, sort }, formValues) => {
const sortParams = sort
? {
order_by: sort.field,
order_type: sort.order,
}
: {};
const res = await getPlatformUserList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
...sortParams,
});
return {
...res,
sort: sort || null,
};
},
},
props: {
result: 'items',
total: 'total',
},
autoLoad: true,
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
custom: true,
export: false,
refresh: { code: 'query' },
search: true,
zoom: true,
},
} as VxeTableGridOptions<PlatformUserApi.PlatformUserItem>,
});
// 操作处理函数
function onActionClick(
e: OnActionClickParams<PlatformUserApi.PlatformUserItem>,
) {
switch (e.code) {
case 'edit': {
onEdit(e.row);
break;
}
}
}
// 编辑处理
function onEdit(row: PlatformUserApi.PlatformUserItem) {
formDrawerApi.setData(row).open();
}
// 刷新处理
function onRefresh() {
gridApi.query();
}
</script>
<template>
<Page auto-content-height>
<FormDrawer @success="onRefresh" />
<Grid table-title="平台用户列表">
<template #toolbar-tools>
<!-- 暂时不需要添加按钮 -->
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,65 @@
<script lang="ts" setup>
import type { PlatformUserApi } from '#/api/platform-user';
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { updatePlatformUser } from '#/api/platform-user';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<PlatformUserApi.PlatformUserItem>();
const [Form, formApi] = useVbenForm({
schema: useFormSchema(),
showDefaultActions: false,
});
const id = ref();
const [Drawer, drawerApi] = useVbenDrawer({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
const values = await formApi.getValues();
drawerApi.lock();
updatePlatformUser(
id.value,
values as PlatformUserApi.UpdatePlatformUserRequest,
)
.then(() => {
emit('success');
drawerApi.close();
})
.catch(() => {
drawerApi.unlock();
});
},
onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<PlatformUserApi.PlatformUserItem>();
formApi.resetForm();
if (data) {
formData.value = data;
id.value = data.id;
formApi.setValues(data);
} else {
id.value = undefined;
}
}
},
});
const getDrawerTitle = computed(() => {
return formData.value?.id ? '编辑用户' : '创建用户';
});
</script>
<template>
<Drawer :title="getDrawerTitle">
<Form />
</Drawer>
</template>

View File

@@ -0,0 +1,80 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FeatureApi } from '#/api/product-manage';
// 表单配置
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'api_id',
label: '模块编号',
rules: 'required',
},
{
component: 'Input',
fieldName: 'name',
label: '描述',
rules: 'required',
},
];
}
// 搜索表单配置
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'api_id',
label: '模块编号',
},
{
component: 'Input',
fieldName: 'name',
label: '描述',
},
];
}
// 表格列配置
export function useColumns<T = FeatureApi.FeatureItem>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'api_id',
title: '模块编号',
minWidth: 150,
},
{
field: 'name',
title: '描述',
minWidth: 200,
},
{
field: 'create_time',
title: '创建时间',
minWidth: 180,
},
{
field: 'update_time',
title: '更新时间',
minWidth: 180,
},
{
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '模块',
onClick: onActionClick,
},
name: 'CellOperation',
},
field: 'operation',
fixed: 'right',
title: '操作',
width: 130,
},
];
}

View File

@@ -0,0 +1,121 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { FeatureApi } from '#/api/product-manage';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteFeature, getFeatureList } from '#/api/product-manage';
import { useColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
// 表单抽屉
const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form,
destroyOnClose: true,
});
// 表格配置
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getFeatureList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
custom: true,
export: false,
refresh: { code: 'query' },
search: true,
zoom: true,
},
} as VxeTableGridOptions<FeatureApi.FeatureItem>,
});
// 操作处理函数
function onActionClick(e: OnActionClickParams<FeatureApi.FeatureItem>) {
switch (e.code) {
case 'delete': {
onDelete(e.row);
break;
}
case 'edit': {
onEdit(e.row);
break;
}
}
}
// 编辑处理
function onEdit(row: FeatureApi.FeatureItem) {
formDrawerApi.setData(row).open();
}
// 删除处理
function onDelete(row: FeatureApi.FeatureItem) {
const hideLoading = message.loading({
content: `正在删除 ${row.name}`,
duration: 0,
key: 'action_process_msg',
});
deleteFeature(row.id)
.then(() => {
message.success({
content: `删除 ${row.name} 成功`,
key: 'action_process_msg',
});
onRefresh();
})
.catch(() => {
hideLoading();
});
}
// 刷新处理
function onRefresh() {
gridApi.query();
}
// 创建处理
function onCreate() {
formDrawerApi.setData({}).open();
}
</script>
<template>
<Page auto-content-height>
<FormDrawer @success="onRefresh" />
<Grid table-title="模块列表">
<template #toolbar-tools>
<Button type="primary" @click="onCreate">
<Plus class="size-5" />
新增模块
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,63 @@
<script lang="ts" setup>
import type { FeatureApi } from '#/api/product-manage';
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { createFeature, updateFeature } from '#/api/product-manage';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<FeatureApi.FeatureItem>();
const [Form, formApi] = useVbenForm({
schema: useFormSchema(),
showDefaultActions: false,
});
const id = ref();
const [Drawer, drawerApi] = useVbenDrawer({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
const values = await formApi.getValues();
drawerApi.lock();
try {
await (id.value
? updateFeature(id.value, values as FeatureApi.UpdateFeatureRequest)
: createFeature(values as FeatureApi.CreateFeatureRequest));
emit('success');
drawerApi.close();
} catch {
drawerApi.unlock();
}
},
onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<FeatureApi.FeatureItem>();
formApi.resetForm();
if (data) {
formData.value = data;
id.value = data.id;
formApi.setValues(data);
} else {
id.value = undefined;
}
}
},
});
const getDrawerTitle = computed(() => {
return formData.value?.id ? '编辑模块' : '创建模块';
});
</script>
<template>
<Drawer :title="getDrawerTitle">
<Form />
</Drawer>
</template>

View File

@@ -0,0 +1,141 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ProductApi } from '#/api/product-manage';
// 表单配置
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'product_name',
label: '产品名称',
rules: 'required',
},
{
component: 'Input',
fieldName: 'product_en',
label: '产品编号',
rules: 'required',
},
{
component: 'RichText',
fieldName: 'description',
label: '描述',
rules: 'required',
},
{
component: 'Textarea',
fieldName: 'notes',
label: '备注',
},
{
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
},
fieldName: 'cost_price',
label: '成本价',
rules: 'required',
},
{
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
},
fieldName: 'sell_price',
label: '售价',
rules: 'required',
},
];
}
// 搜索表单配置
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'product_name',
label: '产品名称',
},
{
component: 'Input',
fieldName: 'product_en',
label: '产品编号',
},
];
}
// 表格列配置
export function useColumns<T = ProductApi.ProductItem>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'product_name',
title: '产品名称',
width: 150,
},
{
field: 'product_en',
title: '产品编号',
width: 150,
},
{
field: 'description',
title: '描述',
},
{
field: 'cost_price',
formatter: ({ cellValue }) => `¥${cellValue.toFixed(2)}`,
title: '成本价',
width: 120,
},
{
field: 'sell_price',
formatter: ({ cellValue }) => `¥${cellValue.toFixed(2)}`,
title: '售价',
width: 120,
},
{
field: 'create_time',
title: '创建时间',
width: 180,
},
{
field: 'update_time',
title: '更新时间',
width: 180,
},
{
align: 'center',
cellRender: {
attrs: {
nameField: 'product_name',
nameTitle: '产品',
onClick: onActionClick,
},
options: [
{
code: 'edit',
text: '编辑',
},
{
code: 'delete',
text: '删除',
},
{
code: 'features',
text: '模块管理',
},
],
name: 'CellOperation',
},
field: 'operation',
fixed: 'right',
title: '操作',
width: 180,
},
];
}

View File

@@ -0,0 +1,143 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { ProductApi } from '#/api/product-manage';
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteProduct, getProductList } from '#/api/product-manage';
import { useColumns, useGridFormSchema } from './data';
import FeatureManage from './modules/feature-manage.vue';
import Form from './modules/form.vue';
// 表单抽屉
const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form,
destroyOnClose: true,
});
// 模块管理弹窗
const [FeatureManageModal, featureManageModalApi] = useVbenModal({
connectedComponent: FeatureManage,
destroyOnClose: true,
});
// 表格配置
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getProductList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
custom: true,
export: false,
refresh: { code: 'query' },
search: true,
zoom: true,
},
} as VxeTableGridOptions<ProductApi.ProductItem>,
});
// 操作处理函数
function onActionClick(e: OnActionClickParams<ProductApi.ProductItem>) {
switch (e.code) {
case 'delete': {
onDelete(e.row);
break;
}
case 'edit': {
onEdit(e.row);
break;
}
case 'features': {
onManageFeatures(e.row);
break;
}
}
}
// 编辑处理
function onEdit(row: ProductApi.ProductItem) {
formDrawerApi.setData(row).open();
}
// 删除处理
function onDelete(row: ProductApi.ProductItem) {
const hideLoading = message.loading({
content: `正在删除 ${row.product_name}`,
duration: 0,
key: 'action_process_msg',
});
deleteProduct(row.id)
.then(() => {
message.success({
content: `删除 ${row.product_name} 成功`,
key: 'action_process_msg',
});
onRefresh();
})
.catch(() => {
hideLoading();
});
}
// 刷新处理
function onRefresh() {
gridApi.query();
}
// 创建处理
function onCreate() {
formDrawerApi.setData({}).open();
}
// 模块管理处理
function onManageFeatures(row: ProductApi.ProductItem) {
featureManageModalApi
.setData({
productId: row.id,
productName: row.product_name,
})
.open();
}
</script>
<template>
<Page auto-content-height>
<FormDrawer @success="onRefresh" />
<FeatureManageModal @success="onRefresh" />
<Grid table-title="产品列表">
<template #toolbar-tools>
<Button type="primary" @click="onCreate">
<Plus class="size-5" />
新增产品
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,349 @@
<script lang="ts" setup>
import type { TableColumnsType } from 'ant-design-vue';
import type { SortableEvent } from 'sortablejs';
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { FeatureApi } from '#/api/product-manage/feature';
import type { ProductApi } from '#/api/product-manage/product';
import { h, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useSortable } from '@vben-core/composables';
import {
Modal as AModal,
Button,
message,
Switch,
Table,
} from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getFeatureList } from '#/api/product-manage/feature';
import {
getProductFeatureList,
updateProductFeatures,
} from '#/api/product-manage/product';
import { useColumns, useGridFormSchema } from '../../feature/data';
const emit = defineEmits(['success']);
// 定义数据接口
interface ModalData {
productId: number;
productName: string;
}
// 临时存储的已关联模块列表
interface TempFeatureItem
extends Omit<
ProductApi.ProductFeatureListItem,
'create_time' | 'id' | 'product_id' | 'update_time'
> {
temp_id: string; // 临时ID用于区分新增的模块
}
const [Modal, modalApi] = useVbenModal({
title: '管理产品模块',
destroyOnClose: true,
onOpenChange: (isOpen) => {
if (isOpen) {
loadFeatureList();
}
},
onConfirm: async () => {
try {
const { productId } = modalApi.getData<ModalData>();
// 准备要保存的数据
const features = tempFeatureList.value.map((item) => ({
feature_id: item.feature_id,
sort: item.sort,
enable: item.enable,
is_important: item.is_important,
}));
// 更新产品模块关联
await updateProductFeatures(productId, { features });
message.success('保存成功');
emit('success');
return true;
} catch {
message.error('保存失败');
return false;
}
},
});
const loading = ref(false);
const tempFeatureList = ref<TempFeatureItem[]>([]);
// 表格配置
const [Grid, _gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: (
useColumns((e: OnActionClickParams<FeatureApi.FeatureItem>) => {
if (e.code === 'add' && e.row) {
handleAddFeature(e.row);
}
}) || []
).map((col) => {
if (col.field === 'operation' && col.cellRender) {
return {
...col,
cellRender: {
...col.cellRender,
options: [
{
code: 'add',
title: '添加',
show: (row: FeatureApi.FeatureItem) => {
return !tempFeatureList.value.some(
(item) => item.feature_id === row.id,
);
},
},
],
},
};
}
return col;
}),
height: 500,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getFeatureList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
custom: true,
export: false,
refresh: { code: 'query' },
search: true,
zoom: true,
},
} as VxeTableGridOptions<FeatureApi.FeatureItem>,
});
// 已关联模块表格列配置
const columns: TableColumnsType<TempFeatureItem> = [
{
title: '排序',
dataIndex: 'sort',
width: 80,
},
{
title: '模块编码',
dataIndex: 'api_id',
width: 150,
},
{
title: '模块描述',
dataIndex: 'name',
},
{
title: '是否启用',
dataIndex: 'enable',
width: 100,
customRender: ({ record }) => {
return h(Switch, {
checked: record.enable === 1,
onChange: (checked: any) => {
record.enable = checked ? 1 : 0;
},
});
},
},
{
title: '是否重要',
dataIndex: 'is_important',
width: 100,
customRender: ({ record }) => {
return h(Switch, {
checked: record.is_important === 1,
onChange: (checked: any) => {
record.is_important = checked ? 1 : 0;
},
});
},
},
{
title: '操作',
dataIndex: 'operation',
width: 100,
fixed: 'right',
customRender: ({ record }) => {
return h(
Button,
{
type: 'link',
danger: true,
onClick: () => handleRemoveFeature(record),
},
() => '移除',
);
},
},
];
// 加载模块列表
async function loadFeatureList() {
const { productId, productName } = modalApi.getData<ModalData>();
// 更新标题
modalApi.setState({ title: `管理产品模块 - ${productName}` });
loading.value = true;
try {
const res = await getProductFeatureList(productId);
// 转换为临时数据格式
tempFeatureList.value = res.map((item) => ({
...item,
temp_id: `existing_${item.id}`,
}));
initSortable();
} finally {
loading.value = false;
}
}
// 初始化拖拽排序
async function initSortable() {
const el = document.querySelector('.ant-table-tbody');
if (!el) return;
const { initializeSortable } = useSortable(el as HTMLElement, {
animation: 150,
handle: '.ant-table-row',
onEnd: async (evt: SortableEvent) => {
const { newIndex, oldIndex } = evt;
if (
typeof newIndex !== 'number' ||
typeof oldIndex !== 'number' ||
newIndex === oldIndex
)
return;
// 重新排序列表
const newList = [...tempFeatureList.value];
const [removed] = newList.splice(oldIndex, 1);
if (removed) {
newList.splice(newIndex, 0, removed);
// 更新排序值
newList.forEach((item, index) => {
item.sort = index + 1;
});
tempFeatureList.value = newList;
}
},
});
await initializeSortable();
}
// 处理添加模块
function handleAddFeature(feature: FeatureApi.FeatureItem) {
// 获取当前最大排序值
const maxSort = Math.max(
...tempFeatureList.value.map((item) => item.sort),
0,
);
// 添加到临时列表
tempFeatureList.value.push({
feature_id: feature.id,
api_id: feature.api_id,
name: feature.name,
sort: maxSort + 1,
enable: 1,
is_important: 0,
temp_id: `new_${Date.now()}_${feature.id}`,
});
}
// 处理移除模块
function handleRemoveFeature(record: TempFeatureItem) {
AModal.confirm({
title: '确认移除',
content: `确定要移除模块"${record.name}"吗?`,
onOk: () => {
tempFeatureList.value = tempFeatureList.value.filter(
(item) => item.temp_id !== record.temp_id,
);
// 重新排序
tempFeatureList.value.forEach((item, index) => {
item.sort = index + 1;
});
},
});
}
</script>
<template>
<Modal class="w-[calc(100vw-200px)]">
<div class="p-4">
<div class="mb-4 text-gray-500">
提示可以通过拖拽行来调整模块顺序通过开关控制模块的启用状态和重要程度
</div>
<div class="flex gap-4">
<!-- 左侧可选模块列表 -->
<div class="w-[600px] flex-shrink-0">
<div class="mb-2 text-base font-medium">可选模块</div>
<Grid />
</div>
<!-- 右侧已关联模块列表 -->
<div class="flex-1">
<div class="mb-2 text-base font-medium">已关联模块</div>
<Table
:columns="columns"
:data-source="tempFeatureList"
:loading="loading"
:pagination="false"
:row-key="(record) => record.temp_id"
:scroll="{ y: 500 }"
class="sortable-table"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'sort'">
<div class="flex items-center">
<span class="mr-2 cursor-move"></span>
{{ record.sort }}
</div>
</template>
</template>
</Table>
</div>
</div>
</div>
</Modal>
</template>
<style>
.sortable-table .ant-table-row {
cursor: move;
}
.sortable-table .ant-table-row.sortable-ghost {
background: #fafafa;
opacity: 0.5;
}
</style>

View File

@@ -0,0 +1,61 @@
<script lang="ts" setup>
import type { ProductApi } from '#/api/product-manage';
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { createProduct, updateProduct } from '#/api/product-manage';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<ProductApi.ProductItem>();
const [Form, formApi] = useVbenForm({
schema: useFormSchema(),
showDefaultActions: false,
});
const id = ref();
const [Drawer, drawerApi] = useVbenDrawer({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
const values = await formApi.getValues();
drawerApi.lock();
try {
await (id.value
? updateProduct(id.value, values as ProductApi.UpdateProductRequest)
: createProduct(values as ProductApi.CreateProductRequest));
emit('success');
drawerApi.close();
} catch {
drawerApi.unlock();
}
},
onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<ProductApi.ProductItem>();
formApi.resetForm();
if (data) {
formData.value = data;
id.value = data.id;
formApi.setValues(data);
} else {
id.value = undefined;
}
}
},
});
const getDrawerTitle = computed(() => {
return formData.value?.id ? '编辑产品' : '创建产品';
});
</script>
<template>
<Drawer :title="getDrawerTitle"> <Form /> </Drawer>
</template>

View File

@@ -0,0 +1,124 @@
<script lang="ts" setup>
import type { AnalysisOverviewItem } from '@vben/common-ui';
import type { PromotionAnalyticsApi } from '#/api/promotion/analytics';
import { onMounted, ref } from 'vue';
import { AnalysisChartCard, AnalysisOverview } from '@vben/common-ui';
import { SvgCakeIcon, SvgCardIcon, SvgDownloadIcon } from '@vben/icons';
import { DatePicker } from 'ant-design-vue';
import dayjs from 'dayjs';
import { statsHistory, statsTotal } from '#/api/promotion/analytics';
import PromotionTrends from './promotion-trends.vue';
const overviewItems = ref<AnalysisOverviewItem[]>([
{
icon: SvgCardIcon,
title: '今日点击数',
totalTitle: '累计',
totalValue: 0,
value: 0,
},
{
icon: SvgCakeIcon,
title: '今日付费次数',
totalTitle: '累计',
totalValue: 0,
value: 0,
},
{
icon: SvgDownloadIcon,
title: '今日付费金额',
totalTitle: '累计',
totalValue: 0,
value: 0,
},
]);
const trendData = ref<PromotionAnalyticsApi.TrendData[]>([]);
const dateRange = ref<[dayjs.Dayjs, dayjs.Dayjs]>([
dayjs().subtract(7, 'day'),
dayjs(),
]);
const fetchOverview = async () => {
try {
const data = await statsTotal();
overviewItems.value = [
{
icon: SvgCardIcon,
title: '今日点击数',
totalTitle: '累计',
totalValue: data.total_click_count,
value: data.today_click_count,
decimals: 0,
},
{
icon: SvgCakeIcon,
title: '今日付费次数',
totalTitle: '累计',
totalValue: data.total_pay_count,
value: data.today_pay_count,
decimals: 0,
},
{
icon: SvgDownloadIcon,
title: '今日付费金额',
totalTitle: '累计',
totalValue: data.total_pay_amount,
value: data.today_pay_amount,
decimals: 2,
},
];
} catch (error) {
console.error('获取概览数据失败:', error);
}
};
const fetchTrendData = async () => {
try {
const data = await statsHistory({
start_date: dateRange.value[0].format('YYYY-MM-DD'),
end_date: dateRange.value[1].format('YYYY-MM-DD'),
});
trendData.value = data;
} catch (error) {
console.error('获取趋势数据失败:', error);
}
};
const handleDateChange = () => {
fetchTrendData();
};
onMounted(() => {
fetchOverview();
fetchTrendData();
});
</script>
<template>
<div class="p-5">
<AnalysisOverview :items="overviewItems" />
<div class="mt-5">
<AnalysisChartCard title="数据趋势">
<div class="flex flex-col gap-4">
<div class="flex justify-end">
<DatePicker.RangePicker
v-model:value="dateRange"
@change="handleDateChange"
/>
</div>
<PromotionTrends type="count" :data="trendData" />
<PromotionTrends type="amount" :data="trendData" />
</div>
</AnalysisChartCard>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,92 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { PromotionAnalyticsApi } from '#/api/promotion/analytics';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
data: PromotionAnalyticsApi.TrendData[];
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const renderChart = () => {
// 计算转化率数据
const totalClicks = props.data.reduce(
(sum, item) => sum + item.click_count,
0,
);
const totalPays = props.data.reduce((sum, item) => sum + item.pay_count, 0);
const totalAmount = props.data.reduce(
(sum, item) => sum + item.pay_amount,
0,
);
const conversionRate = totalClicks > 0 ? (totalPays / totalClicks) * 100 : 0;
const averageAmount = totalPays > 0 ? totalAmount / totalPays : 0;
renderEcharts({
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c}%',
},
series: [
{
name: '转化率',
type: 'funnel',
left: '10%',
top: 60,
bottom: 60,
width: '80%',
min: 0,
max: 100,
minSize: '0%',
maxSize: '100%',
sort: 'descending',
gap: 2,
label: {
show: true,
position: 'inside',
},
labelLine: {
length: 10,
lineStyle: {
width: 1,
type: 'solid',
},
},
itemStyle: {
borderColor: '#fff',
borderWidth: 1,
},
emphasis: {
label: {
fontSize: 20,
},
},
data: [
{ value: 100, name: '点击量' },
{ value: conversionRate, name: '付费转化率' },
{ value: averageAmount, name: '平均付费金额' },
],
},
],
});
};
watch(() => props.data, renderChart, { deep: true });
onMounted(() => {
renderChart();
});
</script>
<template>
<EchartsUI ref="chartRef" class="h-[400px]" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,146 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { PromotionAnalyticsApi } from '#/api/promotion/analytics';
import { onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const props = defineProps<{
data: PromotionAnalyticsApi.TrendData[];
type: 'amount' | 'count';
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const renderChart = () => {
const dates = props.data.map((item) => item.stats_date);
const clickCounts = props.data.map((item) => item.click_count);
const payCounts = props.data.map((item) => item.pay_count);
const payAmounts = props.data.map((item) => item.pay_amount);
renderEcharts({
grid: {
top: 60,
left: 50,
right: 50,
bottom: 50,
},
legend: {
data: props.type === 'count' ? ['点击数', '付费次数'] : ['付费金额'],
top: 20,
},
series:
props.type === 'count'
? [
{
name: '点击数',
type: 'line',
data: clickCounts,
smooth: true,
showSymbol: false,
itemStyle: {
color: '#5ab1ef',
},
},
{
name: '付费次数',
type: 'line',
data: payCounts,
smooth: true,
showSymbol: false,
itemStyle: {
color: '#019680',
},
},
]
: [
{
name: '付费金额',
type: 'line',
data: payAmounts,
smooth: true,
showSymbol: false,
itemStyle: {
color: '#b6a2de',
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(182, 162, 222, 0.3)',
},
{
offset: 1,
color: 'rgba(182, 162, 222, 0.1)',
},
],
},
},
},
],
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985',
},
},
},
xAxis: {
type: 'category',
data: dates,
axisLabel: {
formatter: (value: string) => value.slice(5), // 只显示月-日
},
splitLine: {
lineStyle: {
type: 'solid',
width: 1,
},
show: true,
},
},
yAxis: {
type: 'value',
axisLabel: {
formatter: (value: number) => {
if (props.type === 'amount') {
return `¥${value}`;
}
return value.toString();
},
},
splitLine: {
lineStyle: {
type: 'solid',
width: 1,
},
show: true,
},
},
});
};
watch(() => props.data, renderChart, { deep: true });
watch(() => props.type, renderChart);
onMounted(() => {
renderChart();
});
</script>
<template>
<EchartsUI ref="chartRef" class="h-[400px]" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,92 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { SystemRoleApi } from '#/api';
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'name',
label: '名称',
rules: 'required',
},
];
}
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'name',
label: '名称',
},
{
component: 'Input',
fieldName: 'url',
label: '链接',
},
];
}
export function useColumns<T = SystemRoleApi.SystemRole>(
onActionClick: OnActionClickFn<T>,
): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '名称',
minWidth: 120,
},
{
field: 'url',
title: '链接',
minWidth: 250,
slots: { default: 'url' },
},
{
field: 'click_count',
title: '累计点击数',
minWidth: 120,
},
{
field: 'pay_count',
title: '付费次数',
minWidth: 120,
},
{
field: 'pay_amount',
title: '付费金额',
minWidth: 120,
},
{
field: 'create_time',
title: '创建时间',
minWidth: 180,
},
{
field: 'last_click_time',
title: '最后点击时间',
minWidth: 180,
},
{
field: 'last_pay_time',
title: '最后付费时间',
minWidth: 180,
},
{
align: 'center',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: '名称',
onClick: onActionClick,
},
name: 'CellOperation',
},
field: 'operation',
fixed: 'right',
title: '操作',
width: 130,
},
];
}

View File

@@ -0,0 +1,142 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { PromotionLinkApi } from '#/api';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { Copy, Plus } from '@vben/icons';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deletePromotionLink, getPromotionLinkList } from '#/api';
import { $t } from '#/locales';
import { useColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form,
destroyOnClose: true,
});
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
fieldMappingTime: [['create_time', ['startTime', 'endTime']]],
schema: useGridFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPromotionLinkList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
custom: true,
export: false,
refresh: { code: 'query' },
search: true,
zoom: true,
},
} as VxeTableGridOptions<PromotionLinkApi.PromotionLink>,
});
function onActionClick(
e: OnActionClickParams<PromotionLinkApi.PromotionLinkItem>,
) {
switch (e.code) {
case 'delete': {
onDelete(e.row);
break;
}
case 'edit': {
onEdit(e.row);
break;
}
}
}
function onEdit(row: PromotionLinkApi.PromotionLinkItem) {
formDrawerApi.setData(row).open();
}
function onDelete(row: PromotionLinkApi.PromotionLinkItem) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
deletePromotionLink(row.id.toString())
.then(() => {
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_process_msg',
});
onRefresh();
})
.catch(() => {
hideLoading();
});
}
function onRefresh() {
gridApi.query();
}
function onCreate() {
formDrawerApi.setData({}).open();
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text).then(
() => {
message.success('复制成功');
},
() => {
message.error('复制失败');
},
);
}
</script>
<template>
<Page auto-content-height>
<FormDrawer @success="onRefresh" />
<Grid table-title="链接列表">
<template #toolbar-tools>
<Button type="primary" @click="onCreate">
<Plus class="size-5" />
创建链接
</Button>
</template>
<template #url="{ row }">
<div class="flex items-center gap-2">
<span class="truncate">{{ row.url }}</span>
<Button
type="link"
size="small"
class="!p-0"
@click="copyToClipboard(row.url)"
>
<Copy class="size-4" />
</Button>
</div>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { PromotionLinkApi } from '#/api';
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import { createPromotionLink, updatePromotionLink } from '#/api';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emits = defineEmits(['success']);
const formData = ref<PromotionLinkApi.PromotionLinkItem>();
const [Form, formApi] = useVbenForm({
schema: useFormSchema(),
showDefaultActions: false,
});
const id = ref();
const [Drawer, drawerApi] = useVbenDrawer({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
const values =
await formApi.getValues<Omit<PromotionLinkApi.PromotionLinkItem, 'id'>>();
drawerApi.lock();
(id.value
? updatePromotionLink(id.value, values)
: createPromotionLink(values)
)
.then(() => {
emits('success');
drawerApi.close();
})
.catch(() => {
drawerApi.unlock();
});
},
onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<PromotionLinkApi.PromotionLinkItem>();
formApi.resetForm();
if (data) {
formData.value = data;
id.value = data.id;
formApi.setValues(data);
} else {
id.value = undefined;
}
}
},
});
const getDrawerTitle = computed(() => {
return formData.value?.id
? $t('common.edit', $t('system.role.name'))
: $t('common.create', $t('system.role.name'));
});
</script>
<template>
<Drawer :title="getDrawerTitle">
<Form />
</Drawer>
</template>
<style lang="css" scoped>
:deep(.ant-tree-title) {
.tree-actions {
display: none;
margin-left: 20px;
}
}
:deep(.ant-tree-title:hover) {
.tree-actions {
display: flex;
flex: auto;
justify-content: flex-end;
margin-left: 20px;
}
}
</style>

View File

@@ -0,0 +1,135 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn } from '#/adapter/vxe-table';
import type { SystemDeptApi } from '#/api/system/dept';
import { z } from '#/adapter/form';
import { getDeptList } from '#/api/system/dept';
import { $t } from '#/locales';
/**
* 获取编辑表单的字段配置。如果没有使用多语言可以直接export一个数组常量
*/
export function useSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'name',
label: $t('system.dept.deptName'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('system.dept.deptName'), 2]))
.max(
20,
$t('ui.formRules.maxLength', [$t('system.dept.deptName'), 20]),
),
},
{
component: 'ApiTreeSelect',
componentProps: {
allowClear: true,
api: getDeptList,
class: 'w-full',
labelField: 'name',
valueField: 'id',
childrenField: 'children',
},
fieldName: 'pid',
label: $t('system.dept.parentDept'),
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: [
{ label: $t('common.enabled'), value: 1 },
{ label: $t('common.disabled'), value: 0 },
],
optionType: 'button',
},
defaultValue: 1,
fieldName: 'status',
label: $t('system.dept.status'),
},
{
component: 'Textarea',
componentProps: {
maxLength: 50,
rows: 3,
showCount: true,
},
fieldName: 'remark',
label: $t('system.dept.remark'),
rules: z
.string()
.max(50, $t('ui.formRules.maxLength', [$t('system.dept.remark'), 50]))
.optional(),
},
];
}
/**
* 获取表格列配置
* @description 使用函数的形式返回列数据而不是直接export一个Array常量是为了响应语言切换时重新翻译表头
* @param onActionClick 表格操作按钮点击事件
*/
export function useColumns(
onActionClick?: OnActionClickFn<SystemDeptApi.SystemDept>,
): VxeTableGridOptions<SystemDeptApi.SystemDept>['columns'] {
return [
{
align: 'left',
field: 'name',
fixed: 'left',
title: $t('system.dept.deptName'),
treeNode: true,
width: 150,
},
{
cellRender: { name: 'CellTag' },
field: 'status',
title: $t('system.dept.status'),
width: 100,
},
{
field: 'createTime',
title: $t('system.dept.createTime'),
width: 180,
},
{
field: 'remark',
title: $t('system.dept.remark'),
},
{
align: 'right',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: $t('system.dept.name'),
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'append',
text: '新增下级',
},
'edit', // 默认的编辑按钮
{
code: 'delete', // 默认的删除按钮
disabled: (row: SystemDeptApi.SystemDept) => {
return !!(row.children && row.children.length > 0);
},
},
],
},
field: 'operation',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
title: $t('system.dept.operation'),
width: 200,
},
];
}

View File

@@ -0,0 +1,143 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { SystemDeptApi } from '#/api/system/dept';
import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteDept, getDeptList } from '#/api/system/dept';
import { $t } from '#/locales';
import { useColumns } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/**
* 编辑部门
* @param row
*/
function onEdit(row: SystemDeptApi.SystemDept) {
formModalApi.setData(row).open();
}
/**
* 添加下级部门
* @param row
*/
function onAppend(row: SystemDeptApi.SystemDept) {
formModalApi.setData({ pid: row.id }).open();
}
/**
* 创建新部门
*/
function onCreate() {
formModalApi.setData(null).open();
}
/**
* 删除部门
* @param row
*/
function onDelete(row: SystemDeptApi.SystemDept) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
key: 'action_process_msg',
});
deleteDept(row.id)
.then(() => {
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
key: 'action_process_msg',
});
refreshGrid();
})
.catch(() => {
hideLoading();
});
}
/**
* 表格操作按钮的回调函数
*/
function onActionClick({
code,
row,
}: OnActionClickParams<SystemDeptApi.SystemDept>) {
switch (code) {
case 'append': {
onAppend(row);
break;
}
case 'delete': {
onDelete(row);
break;
}
case 'edit': {
onEdit(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
gridEvents: {},
gridOptions: {
columns: useColumns(onActionClick),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_params) => {
return await getDeptList();
},
},
},
toolbarConfig: {
custom: true,
export: false,
refresh: { code: 'query' },
zoom: true,
},
treeConfig: {
parentField: 'pid',
rowField: 'id',
transform: false,
},
} as VxeTableGridOptions,
});
/**
* 刷新表格
*/
function refreshGrid() {
gridApi.query();
}
</script>
<template>
<Page auto-content-height>
<FormModal @success="refreshGrid" />
<Grid table-title="部门列表">
<template #toolbar-tools>
<Button type="primary" @click="onCreate">
<Plus class="size-5" />
{{ $t('ui.actionTitle.create', [$t('system.dept.name')]) }}
</Button>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,78 @@
<script lang="ts" setup>
import type { SystemDeptApi } from '#/api/system/dept';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createDept, updateDept } from '#/api/system/dept';
import { $t } from '#/locales';
import { useSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<SystemDeptApi.SystemDept>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', [$t('system.dept.name')])
: $t('ui.actionTitle.create', [$t('system.dept.name')]);
});
const [Form, formApi] = useVbenForm({
layout: 'vertical',
schema: useSchema(),
showDefaultActions: false,
});
function resetForm() {
formApi.resetForm();
formApi.setValues(formData.value || {});
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (valid) {
modalApi.lock();
const data = await formApi.getValues();
try {
await (formData.value?.id
? updateDept(formData.value.id, data)
: createDept(data));
modalApi.close();
emit('success');
} finally {
modalApi.lock(false);
}
}
},
onOpenChange(isOpen) {
if (isOpen) {
const data = modalApi.getData<SystemDeptApi.SystemDept>();
if (data) {
if (data.pid === 0) {
data.pid = undefined;
}
formData.value = data;
formApi.setValues(formData.value);
}
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
<template #prepend-footer>
<div class="flex-auto">
<Button type="primary" danger @click="resetForm">
{{ $t('common.reset') }}
</Button>
</div>
</template>
</Modal>
</template>

Some files were not shown because too many files have changed in this diff Show More