1
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 (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
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
杨志
2025-12-05 13:39:40 +08:00
parent 21107f02fd
commit 51a72f1f0c
1239 changed files with 107262 additions and 1 deletions

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

@@ -0,0 +1,5 @@
# 应用标题
VITE_APP_TITLE=教室预订管理系统
# 应用命名空间
VITE_APP_NAMESPACE=booking-admin

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,18 @@
# 端口号
VITE_PORT=5555
# 资源公共路径
VITE_BASE=/
# 接口地址(开发环境使用相对路径,通过 Vite 代理)
# 注意API 定义中已经包含了 /api 前缀,所以这里设置为 /
VITE_GLOB_API_URL=/
# 是否开启 Nitro Mock服务
VITE_NITRO_MOCK=false
# 是否打开 devtools
VITE_DEVTOOLS=true
# 是否注入全局loading
VITE_INJECT_APP_LOADING=true

View File

@@ -0,0 +1,8 @@
# 生产环境配置
VITE_BASE=/super/
VITE_GLOB_API_URL=https://xuanzuo.dhdjy.com
VITE_ROUTER_HISTORY=history
VITE_COMPRESS=gzip
VITE_PWA=false
VITE_INJECT_APP_LOADING=true
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,50 @@
{
"name": "@vben/web-antd",
"version": "5.5.9",
"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/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:",
"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,441 @@
/**
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
*/
import type {
UploadChangeParam,
UploadFile,
UploadProps,
} from 'ant-design-vue';
import type { Component, Ref } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import {
defineAsyncComponent,
defineComponent,
h,
ref,
render,
unref,
watch,
} from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { isEmpty } from '@vben/utils';
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 Image = defineAsyncComponent(() => import('ant-design-vue/es/image'));
const PreviewGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
return defineComponent({
name: component.name,
inheritAttrs: false,
setup: (props: any, { attrs, expose, slots }) => {
const placeholder =
props?.placeholder ||
attrs?.placeholder ||
$t(`ui.placeholder.${type}`);
// 透传组件暴露的方法
const innerRef = ref();
expose(
new Proxy(
{},
{
get: (_target, key) => innerRef.value?.[key],
has: (_target, key) => key in (innerRef.value || {}),
},
),
);
return () =>
h(
component,
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
slots,
);
},
});
};
const withPreviewUpload = () => {
return defineComponent({
name: Upload.name,
emits: ['change', 'update:modelValue'],
setup: (
props: any,
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
) => {
const previewVisible = ref<boolean>(false);
const placeholder = attrs?.placeholder || $t(`ui.placeholder.upload`);
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
const fileList = ref<UploadProps['fileList']>(
attrs?.fileList || attrs?.['file-list'] || [],
);
const handleChange = async (event: UploadChangeParam) => {
fileList.value = event.fileList;
emit('change', event);
emit(
'update:modelValue',
event.fileList?.length ? fileList.value : undefined,
);
};
const handlePreview = async (file: UploadFile) => {
previewVisible.value = true;
await previewImage(file, previewVisible, fileList);
};
const renderUploadButton = (): any => {
const isDisabled = attrs.disabled;
// 如果禁用,不渲染上传按钮
if (isDisabled) {
return null;
}
// 否则渲染默认上传按钮
return isEmpty(slots)
? createDefaultSlotsWithUpload(listType, placeholder)
: slots;
};
// 可以监听到表单API设置的值
watch(
() => attrs.modelValue,
(res) => {
fileList.value = res;
},
);
return () =>
h(
Upload,
{
...props,
...attrs,
fileList: fileList.value,
onChange: handleChange,
onPreview: handlePreview,
},
renderUploadButton(),
);
},
});
};
const createDefaultSlotsWithUpload = (
listType: string,
placeholder: string,
) => {
switch (listType) {
case 'picture-card': {
return {
default: () => placeholder,
};
}
default: {
return {
default: () =>
h(
Button,
{
icon: h(IconifyIcon, {
icon: 'ant-design:upload-outlined',
class: 'mb-1 size-4',
}),
},
() => placeholder,
),
};
}
}
};
const previewImage = async (
file: UploadFile,
visible: Ref<boolean>,
fileList: Ref<UploadProps['fileList']>,
) => {
// 检查是否为图片文件的辅助函数
const isImageFile = (file: UploadFile): boolean => {
const imageExtensions = new Set([
'bmp',
'gif',
'jpeg',
'jpg',
'png',
'webp',
]);
if (file.url) {
const ext = file.url?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
}
if (!file.type) {
const ext = file.name?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
}
return file.type.startsWith('image/');
};
// 如果当前文件不是图片,直接打开
if (!isImageFile(file)) {
if (file.url) {
window.open(file.url, '_blank');
} else if (file.preview) {
window.open(file.preview, '_blank');
} else {
console.warn('无法打开文件没有可用的URL或预览地址');
}
return;
}
// 对于图片文件,继续使用预览组
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
Image,
PreviewGroup,
]);
const getBase64 = (file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => resolve(reader.result));
reader.addEventListener('error', (error) => reject(error));
});
};
// 从fileList中过滤出所有图片文件
const imageFiles = (unref(fileList) || []).filter((element) =>
isImageFile(element),
);
// 为所有没有预览地址的图片生成预览
for (const imgFile of imageFiles) {
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
imgFile.preview = (await getBase64(imgFile.originFileObj)) as string;
}
}
const container: HTMLElement | null = document.createElement('div');
document.body.append(container);
// 用于追踪组件是否已卸载
let isUnmounted = false;
const PreviewWrapper = {
setup() {
return () => {
if (isUnmounted) return null;
return h(
PreviewGroupComponent,
{
class: 'hidden',
preview: {
visible: visible.value,
// 设置初始显示的图片索引
current: imageFiles.findIndex((f) => f.uid === file.uid),
onVisibleChange: (value: boolean) => {
visible.value = value;
if (!value) {
// 延迟清理,确保动画完成
setTimeout(() => {
if (!isUnmounted && container) {
isUnmounted = true;
render(null, container);
container.remove();
}
}, 300);
}
},
},
},
() =>
// 渲染所有图片文件
imageFiles.map((imgFile) =>
h(ImageComponent, {
key: imgFile.uid,
src: imgFile.url || imgFile.preview,
}),
),
);
};
},
};
render(h(PreviewWrapper), container);
};
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType =
| 'ApiSelect'
| 'ApiTreeSelect'
| 'AutoComplete'
| 'Checkbox'
| 'CheckboxGroup'
| 'DatePicker'
| 'DefaultButton'
| 'Divider'
| 'IconPicker'
| 'Input'
| 'InputNumber'
| 'InputPassword'
| 'Mentions'
| 'PrimaryButton'
| 'Radio'
| 'RadioGroup'
| 'RangePicker'
| 'Rate'
| '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,
name: 'ApiSelect',
},
'select',
{
component: Select,
loadingSlot: 'suffixIcon',
visibleEvent: 'onDropdownVisibleChange',
modelPropName: 'value',
},
),
ApiTreeSelect: withDefaultPlaceholder(
{
...ApiComponent,
name: 'ApiTreeSelect',
},
'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: withPreviewUpload(),
};
// 将组件注册到全局共享状态中
globalShareState.setComponents(components);
// 定义全局共享状态中的消息提示
globalShareState.defineMessage({
// 复制成功消息提示
copyPreferencesSuccess: (title, content) => {
notification.success({
description: content,
message: title,
placement: 'bottomRight',
});
},
});
}
export { initComponentAdapter };

View File

@@ -0,0 +1,49 @@
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';
async function initSetupVbenForm() {
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',
},
},
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 { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };

View File

@@ -0,0 +1,69 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { Button, Image } from 'ant-design-vue';
import { useVbenForm } from './form';
setupVbenVxeTable({
configVxeTable: (vxeUI) => {
vxeUI.setConfig({
grid: {
align: 'center',
border: false,
columnConfig: {
resizable: true,
},
minHeight: 180,
formConfig: {
// 全局禁用vxe-table的表单配置使用formOptions
enabled: false,
},
proxyConfig: {
autoLoad: true,
response: {
result: 'items',
total: 'total',
list: 'items',
},
showActiveMsg: true,
showResponseMsg: false,
},
round: true,
showOverflow: true,
size: 'small',
} as VxeTableGridOptions,
});
// 表格配置项可以用 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 },
);
},
});
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
// vxeUI.formats.add
},
useVbenForm,
});
export { useVbenVxeGrid };
export type * from '@vben/plugins/vxe-table';

View File

@@ -0,0 +1,78 @@
import { requestClient } from '#/api/request';
export namespace BookingApi {
/** 预订列表查询参数 */
export interface ListParams {
page?: number;
limit?: number;
classroom_id?: number;
booking_date?: string;
status?: number;
student_id?: number;
type?: string;
}
/** 预订信息 */
export interface BookingInfo {
id?: number;
booking_no?: string | null;
classroom_id?: number;
seat_number?: string;
user_id?: number | null;
user_name?: string | null;
booking_date?: string;
start_time?: string;
end_time?: string;
purpose?: string | null;
status?: number;
remark?: string | null;
create_time?: string;
update_time?: string;
seat_row_index?: number;
seat_col_index?: number;
student_id?: number;
classroom_name?: string;
time_range?: string;
[key: string]: any;
}
/** 同意座位变更申请参数 */
export interface ApproveParams {
id: number;
}
/** 拒绝座位变更申请参数 */
export interface RejectParams {
id: number;
reason: string;
}
}
/**
* 获取预订列表
* GET /api/admin/booking/list
*/
export async function getBookingListApi(params?: BookingApi.ListParams) {
// 使用 responseReturn: 'body' 获取完整响应体
return requestClient.get<any>('/api/admin/booking/list', {
params,
responseReturn: 'body',
});
}
/**
* 同意座位变更申请
* POST /api/admin/booking/approve
*/
export async function approveBookingApi(data: BookingApi.ApproveParams) {
return requestClient.post('/api/admin/booking/approve', data);
}
/**
* 拒绝座位变更申请
* POST /api/admin/booking/reject
*/
export async function rejectBookingApi(data: BookingApi.RejectParams) {
return requestClient.post('/api/admin/booking/reject', data);
}

View File

@@ -0,0 +1,143 @@
import { requestClient } from '#/api/request';
export namespace ClassApi {
/** 班级列表查询参数 */
export interface ListParams {
page?: number;
limit?: number;
name?: string;
teacher_name?: string;
school_id?: number;
}
/** 班级信息 */
export interface ClassInfo {
id?: number;
name?: string;
grade?: string;
department_id?: number;
teacher_id?: number;
student_count?: number;
class_room_id?: number;
description?: string;
status?: number;
create_time?: string;
update_time?: string;
teacher?: {
id?: number;
teacher_id?: string | null;
name?: string;
gender?: string;
department_id?: number;
title?: string | null;
phone?: string;
email?: string;
avatar?: string | null;
remark?: string;
status?: number;
user_id?: number | null;
create_time?: string;
update_time?: string | null;
nickname?: string;
};
classroom?: {
id?: number;
name?: string;
location?: string;
teacher_id?: number;
type?: string;
capacity?: number;
layout?: string;
status?: number;
description?: string;
create_time?: string;
update_time?: string;
layout_cols?: number;
layout_rows?: number;
fiexd_start_time?: string;
fiexd_end_time?: string;
view_images?: string;
school_id?: number;
school?: any;
};
[key: string]: any;
}
/** 保存班级参数 */
export interface SaveParams extends ClassInfo {
id?: number;
}
/** 删除班级参数 */
export interface DeleteParams {
id: number;
}
/** 班级详情参数 */
export interface DetailParams {
id: number;
}
}
/**
* 获取班级列表
* GET /api/admin/classmanager/list
*/
export async function getClassListApi(params?: ClassApi.ListParams) {
// 使用 responseReturn: 'body' 获取完整响应体
return requestClient.get<any>('/api/admin/classmanager/list', {
params,
responseReturn: 'body',
});
}
/**
* 获取班主任列表
* GET /api/admin/classmanager/getTeachers
*/
export async function getTeachersApi() {
// 使用 responseReturn: 'body' 获取完整响应体,支持可能的 code/data 格式
return requestClient.get<any>('/api/admin/classmanager/getTeachers', {
responseReturn: 'body',
});
}
/**
* 获取教室列表
* GET /api/admin/classmanager/getClassrooms
*/
export async function getClassroomsApi() {
// 使用 responseReturn: 'body' 获取完整响应体,支持可能的 code/data 格式
return requestClient.get<any>('/api/admin/classmanager/getClassrooms', {
responseReturn: 'body',
});
}
/**
* 获取班级详情
* GET /api/admin/classmanager/detail
*/
export async function getClassDetailApi(params: ClassApi.DetailParams) {
// 使用 responseReturn: 'body' 获取完整响应体
return requestClient.get<any>('/api/admin/classmanager/detail', {
params,
responseReturn: 'body',
});
}
/**
* 保存班级(新增/编辑)
* POST /api/admin/classmanager/save
*/
export async function saveClassApi(data: ClassApi.SaveParams) {
return requestClient.post<ClassApi.ClassInfo>('/api/admin/classmanager/save', data);
}
/**
* 删除班级
* POST /api/admin/classmanager/deleteClass
*/
export async function deleteClassApi(data: ClassApi.DeleteParams) {
return requestClient.post('/api/admin/classmanager/deleteClass', data);
}

View File

@@ -0,0 +1,237 @@
import { requestClient } from '#/api/request';
export namespace ClassroomApi {
/** 教室列表查询参数 */
export interface ListParams {
page?: number;
limit?: number;
name?: string;
building?: string;
type?: 'normal' | 'multimedia' | 'lab';
school_id?: number;
}
/** 教室位置信息 */
export interface ClassroomLocation {
room?: string;
floor?: string;
building?: string;
}
/** 教室座位布局单元格 */
export interface ClassroomLayoutCell {
col: number;
row: number;
type: 'empty' | 'pillar' | 'aisle' | 'seat' | 'door';
number?: string;
status?: number;
name?: string;
}
/** 教室座位布局 */
export interface ClassroomLayout {
cols: number;
rows: number;
cells: ClassroomLayoutCell[];
}
/** 教室信息 */
export interface ClassroomInfo {
id?: number;
name?: string;
location?: ClassroomLocation;
teacher_id?: number;
type?: 'normal' | 'multimedia' | 'lab' | string;
capacity?: number;
layout?: ClassroomLayout;
school_id?: number;
[key: string]: any;
}
/** 教室详情参数 */
export interface DetailParams {
id: number;
}
/** 保存教室参数 */
export interface SaveParams extends ClassroomInfo {
id?: number;
}
/** 删除教室参数 */
export interface DeleteParams {
id: number;
}
/** 批量删除教室参数 */
export interface BatchDeleteParams {
ids: number[];
}
/** 保存座位布局参数 */
export interface SaveLayoutParams {
id: number;
layout: any;
}
/** 获取教室座位状态参数 */
export interface StatusParams {
id: number;
}
/** 分配座位参数 */
export interface AssignSeatParams {
classroomId: number;
studentId: number;
row: number;
col: number;
number: string;
}
/** 随机分配座位参数 */
export interface RandomAssignSeatsParams {
classroomId: number;
}
/** 获取未分配座位的学生列表参数 */
export interface UnassignedStudentsParams {
classroomId: number;
}
/** 取消选座参数 */
export interface CancelBookingParams {
id: number;
}
/** 批量取消选座参数 */
export interface CancelBookingAutoParams {
ids: number[];
}
}
/**
* 获取教室列表
* GET /api/admin/classroom/list
*/
export async function getClassroomListApi(params?: ClassroomApi.ListParams) {
// 使用 responseReturn: 'body' 获取完整响应体,包含 code, count, data 等字段
return requestClient.get<any>('/api/admin/classroom/list', {
params,
responseReturn: 'body',
});
}
/**
* 获取教室详情
* GET /api/admin/classroom/detail
*/
export async function getClassroomDetailApi(params: ClassroomApi.DetailParams) {
// 使用 responseReturn: 'body' 获取完整响应体
return requestClient.get<any>('/api/admin/classroom/detail', {
params,
responseReturn: 'body',
});
}
/**
* 保存教室(新增/编辑)
* POST /api/admin/classroom/save
*/
export async function saveClassroomApi(data: ClassroomApi.SaveParams) {
return requestClient.post<ClassroomApi.ClassroomInfo>('/api/admin/classroom/save', data);
}
/**
* 删除教室
* POST /api/admin/classroom/delete
*/
export async function deleteClassroomApi(data: ClassroomApi.DeleteParams) {
return requestClient.post('/api/admin/classroom/delete', data);
}
/**
* 批量删除教室
* POST /api/admin/classroom/batchDelete
*/
export async function batchDeleteClassroomApi(data: ClassroomApi.BatchDeleteParams) {
return requestClient.post('/api/admin/classroom/batchDelete', data);
}
/**
* 保存座位布局
* POST /api/admin/classroom/saveLayout
*/
export async function saveClassroomLayoutApi(data: ClassroomApi.SaveLayoutParams) {
return requestClient.post('/api/admin/classroom/saveLayout', data);
}
/**
* 获取教室座位状态
* GET /api/admin/classroom/status
*/
export async function getClassroomStatusApi(params: ClassroomApi.StatusParams) {
return requestClient.get<any>('/api/admin/classroom/status', { params });
}
/**
* 分配座位
* POST /api/admin/classroom/assignSeat
*/
export async function assignSeatApi(data: ClassroomApi.AssignSeatParams) {
return requestClient.post('/api/admin/classroom/assignSeat', data);
}
/**
* 随机分配座位
* POST /api/admin/classroom/randomAssignSeats
*/
export async function randomAssignSeatsApi(data: ClassroomApi.RandomAssignSeatsParams) {
return requestClient.post('/api/admin/classroom/randomAssignSeats', data);
}
/**
* 获取未分配座位的学生列表
* GET /api/admin/classroom/getUnassignedStudents
*/
export async function getUnassignedStudentsApi(params: ClassroomApi.UnassignedStudentsParams) {
return requestClient.get<any>('/api/admin/classroom/getUnassignedStudents', { params });
}
/**
* 取消选座
* POST /api/admin/classroom/cancelBooking
*/
export async function cancelBookingApi(data: ClassroomApi.CancelBookingParams) {
return requestClient.post('/api/admin/classroom/cancelBooking', data);
}
/**
* 批量取消选座
* POST /api/admin/classroom/cancelBookingAuto
*/
export async function cancelBookingAutoApi(data: ClassroomApi.CancelBookingAutoParams) {
return requestClient.post('/api/admin/classroom/cancelBookingAuto', data);
}
/**
* 获取分校列表(用于教室管理)
* GET /api/admin/classroom/getSchoolList
*/
export async function getClassroomSchoolListApi() {
return requestClient.get<any[]>('/api/admin/classroom/getSchoolList');
}
/**
* 上传图片
* POST /api/admin/classroom/upload
*/
export async function uploadClassroomImageApi(file: File) {
const formData = new FormData();
formData.append('file', file);
return requestClient.post<string>('/api/admin/classroom/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}

View File

@@ -0,0 +1,19 @@
import { requestClient } from '#/api/request';
export namespace CommonApi {
/** 必应壁纸信息 */
export interface BingWallpaper {
url?: string;
copyright?: string;
[key: string]: any;
}
}
/**
* 获取必应每日壁纸
* GET /api/admin/common/getBingWallpaper
*/
export async function getBingWallpaperApi() {
return requestClient.get<CommonApi.BingWallpaper>('/api/admin/common/getBingWallpaper');
}

View File

@@ -0,0 +1,31 @@
import { requestClient } from '#/api/request';
export namespace IndexApi {
/** 系统信息 */
export interface SystemInfo {
os?: string;
php?: string;
server?: string;
mysql?: string;
upload_max?: string;
max_execution_time?: string;
disk_free_space?: string;
disk_total_space?: string;
disk_usage?: string;
runtime_path?: string;
framework_version?: string;
[key: string]: any;
}
}
/**
* 获取系统信息
* GET /api/admin/index/systemInfo
*/
export async function getSystemInfoApi() {
// 使用 responseReturn: 'body' 获取完整响应体
return requestClient.get<any>('/api/admin/index/systemInfo', {
responseReturn: 'body',
});
}

View File

@@ -0,0 +1,10 @@
export * from './classroom';
export * from './booking';
export * from './student';
export * from './class';
export * from './teacher';
export * from './school';
export * from './setting';
export * from './profile';
export * from './common';
export * from './index-page';

View File

@@ -0,0 +1,75 @@
import { requestClient } from '#/api/request';
export namespace ProfileApi {
/** 个人信息 */
export interface ProfileInfo {
id?: number;
username?: string;
nickname?: string;
email?: string | null;
avatar?: string | null;
role?: string;
school_id?: number | null;
status?: number;
[key: string]: any;
}
/** 更新个人信息参数 */
export interface UpdateProfileParams {
nickname?: string;
email?: string;
avatar?: File | string;
}
/** 修改密码参数 */
export interface UpdatePasswordParams {
old_password: string;
new_password: string;
confirm_password: string;
}
}
/**
* 获取个人信息
* GET /api/admin/profile/info
*/
export async function getProfileInfoApi() {
return requestClient.get<ProfileApi.ProfileInfo>('/api/admin/profile/info');
}
/**
* 更新个人信息
* POST /api/admin/profile/update
*/
export async function updateProfileApi(data: ProfileApi.UpdateProfileParams) {
const formData = new FormData();
if (data.nickname) {
formData.append('nickname', data.nickname);
}
// email 可能是空字符串,需要明确处理
if (data.email !== undefined && data.email !== null && data.email !== '') {
formData.append('email', data.email);
} else if (data.email === '') {
// 如果传了空字符串,可能需要传空值,根据后端需求调整
formData.append('email', '');
}
if (data.avatar instanceof File) {
formData.append('avatar', data.avatar);
} else if (data.avatar) {
formData.append('avatar', data.avatar);
}
return requestClient.post<ProfileApi.ProfileInfo>('/api/admin/profile/update', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
/**
* 修改密码
* POST /api/admin/profile/updatePassword
*/
export async function updatePasswordApi(data: ProfileApi.UpdatePasswordParams) {
return requestClient.post('/api/admin/profile/updatePassword', data);
}

View File

@@ -0,0 +1,134 @@
import { requestClient } from '#/api/request';
export namespace SchoolApi {
/** 学校列表查询参数 */
export interface ListParams {
page?: number;
limit?: number;
name?: string;
}
/** 学校信息 */
export interface SchoolInfo {
id?: number;
name?: string;
address?: string;
description?: string;
contact?: string;
phone?: string;
create_time?: string;
update_time?: string;
[key: string]: any;
}
/** 学校详情参数 */
export interface DetailParams {
id: number;
}
/** 保存学校参数 */
export interface SaveParams extends SchoolInfo {
id?: number;
}
/** 删除学校参数 */
export interface DeleteParams {
id: number;
}
/** 获取分校账号列表参数 */
export interface AccountListParams {
school_id: number;
}
/** 分校账号信息 */
export interface AccountInfo {
id?: number;
school_id?: number;
username?: string;
password?: string;
status?: number;
create_time?: string;
update_time?: string;
[key: string]: any;
}
/** 保存分校账号参数 */
export interface SaveAccountParams extends AccountInfo {
id?: number;
}
/** 删除分校账号参数 */
export interface DeleteAccountParams {
id: number;
}
}
/**
* 获取学校列表(仅超级管理员)
* GET /api/admin/school/list
*/
export async function getSchoolListApi(params?: SchoolApi.ListParams) {
// 使用 responseReturn: 'body' 获取完整响应体
return requestClient.get<any>('/api/admin/school/list', {
params,
responseReturn: 'body',
});
}
/**
* 获取学校详情(仅超级管理员)
* GET /api/admin/school/detail
*/
export async function getSchoolDetailApi(params: SchoolApi.DetailParams) {
// 使用 responseReturn: 'body' 获取完整响应体
return requestClient.get<any>('/api/admin/school/detail', {
params,
responseReturn: 'body',
});
}
/**
* 保存学校(新增/编辑)(仅超级管理员)
* POST /api/admin/school/save
*/
export async function saveSchoolApi(data: SchoolApi.SaveParams) {
return requestClient.post<SchoolApi.SchoolInfo>('/api/admin/school/save', data);
}
/**
* 删除学校(仅超级管理员)
* POST /api/admin/school/delete
*/
export async function deleteSchoolApi(data: SchoolApi.DeleteParams) {
return requestClient.post('/api/admin/school/delete', data);
}
/**
* 获取分校账号列表(仅超级管理员)
* GET /api/admin/school/accountList
*/
export async function getSchoolAccountListApi(params: SchoolApi.AccountListParams) {
// 使用 responseReturn: 'body' 获取完整响应体
return requestClient.get<any>('/api/admin/school/accountList', {
params,
responseReturn: 'body',
});
}
/**
* 保存分校账号(仅超级管理员)
* POST /api/admin/school/saveAccount
*/
export async function saveSchoolAccountApi(data: SchoolApi.SaveAccountParams) {
return requestClient.post<SchoolApi.AccountInfo>('/api/admin/school/saveAccount', data);
}
/**
* 删除分校账号(仅超级管理员)
* POST /api/admin/school/deleteAccount
*/
export async function deleteSchoolAccountApi(data: SchoolApi.DeleteAccountParams) {
return requestClient.post('/api/admin/school/deleteAccount', data);
}

View File

@@ -0,0 +1,65 @@
import { requestClient } from '#/api/request';
export namespace SettingApi {
/** 系统设置 */
export interface SettingInfo {
site_name?: string;
site_desc?: string;
site_icp?: string;
site_banners?: string; // JSON字符串
default_avatar?: string;
upload_allowed_ext?: string;
upload_max_size?: string;
upload_path?: string;
upload_image_size?: string;
upload_image_ext?: string;
mail_host?: string;
mail_port?: string;
mail_username?: string;
mail_password?: string;
wxapp_appid?: string;
wxapp_secret?: string;
wxapp_name?: string;
wxapp_original?: string;
[key: string]: any;
}
/** 保存系统设置参数 */
export interface SaveParams extends SettingInfo {
[key: string]: any;
}
}
/**
* 获取系统设置(仅超级管理员)
* GET /api/admin/setting/get
*/
export async function getSettingApi() {
// 使用 responseReturn: 'body' 获取完整响应体
return requestClient.get<any>('/api/admin/setting/get', {
responseReturn: 'body',
});
}
/**
* 保存系统设置(仅超级管理员)
* POST /api/admin/setting/save
*/
export async function saveSettingApi(data: SettingApi.SaveParams) {
return requestClient.post('/api/admin/setting/save', data);
}
/**
* 上传图片(仅超级管理员)
* POST /api/admin/setting/upload
*/
export async function uploadSettingImageApi(file: File) {
const formData = new FormData();
formData.append('file', file);
return requestClient.post<string>('/api/admin/setting/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}

View File

@@ -0,0 +1,154 @@
import { requestClient } from '#/api/request';
export namespace StudentApi {
/** 学生列表查询参数 */
export interface ListParams {
page?: number;
limit?: number;
student_id?: string;
name?: string;
phone?: string;
class_id?: number;
school_id?: number;
bind_status?: string;
}
/** 学生信息 */
export interface StudentInfo {
id?: number;
student_id?: string | null;
name?: string;
gender?: string;
class_id?: number;
phone?: string;
email?: string;
avatar?: string | null;
remark?: string;
status?: number;
user_id?: number | null;
create_time?: string;
update_time?: string | null;
class_name?: string;
bind_status?: string;
school_name?: string;
[key: string]: any;
}
/** 学生详情参数 */
export interface DetailParams {
id: number;
}
/** 保存学生参数 */
export interface SaveParams extends StudentInfo {
id?: number;
}
/** 删除学生参数 */
export interface DeleteParams {
id: number;
}
/** 更新学生状态参数 */
export interface StatusParams {
id: number;
status: number;
}
/** 解绑学生参数 */
export interface UnbindParams {
id: number;
}
/** 上传学生导入文件参数 */
export interface UploadParams {
file: File;
}
/** 导入学生数据参数 */
export interface ImportParams {
class_id: number;
file: string;
}
}
/**
* 获取学生列表
* GET /api/admin/student/list
*/
export async function getStudentListApi(params?: StudentApi.ListParams) {
// 使用 responseReturn: 'body' 获取完整响应体
return requestClient.get<any>('/api/admin/student/list', {
params,
responseReturn: 'body',
});
}
/**
* 获取学生详情
* GET /api/admin/student/detail
*/
export async function getStudentDetailApi(params: StudentApi.DetailParams) {
// 使用 responseReturn: 'body' 获取完整响应体
return requestClient.get<any>('/api/admin/student/detail', {
params,
responseReturn: 'body',
});
}
/**
* 保存学生(新增/编辑)
* POST /api/admin/student/save
*/
export async function saveStudentApi(data: StudentApi.SaveParams) {
return requestClient.post<StudentApi.StudentInfo>('/api/admin/student/save', data);
}
/**
* 删除学生
* POST /api/admin/student/delete
*/
export async function deleteStudentApi(data: StudentApi.DeleteParams) {
return requestClient.post('/api/admin/student/delete', data);
}
/**
* 更新学生状态
* POST /api/admin/student/status
*/
export async function updateStudentStatusApi(data: StudentApi.StatusParams) {
return requestClient.post('/api/admin/student/status', data);
}
/**
* 解绑学生
* POST /api/admin/student/unbind
*/
export async function unbindStudentApi(data: StudentApi.UnbindParams) {
return requestClient.post('/api/admin/student/unbind', data);
}
/**
* 上传学生导入文件
* POST /api/admin/student/upload
*/
export async function uploadStudentFileApi(file: File) {
const formData = new FormData();
formData.append('file', file);
// 使用 responseReturn: 'body' 获取完整响应体
return requestClient.post<any>('/api/admin/student/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
responseReturn: 'body',
});
}
/**
* 导入学生数据
* POST /api/admin/student/import
*/
export async function importStudentDataApi(data: StudentApi.ImportParams) {
return requestClient.post('/api/admin/student/import', data);
}

View File

@@ -0,0 +1,92 @@
import { requestClient } from '#/api/request';
export namespace TeacherApi {
/** 教师列表查询参数 */
export interface ListParams {
page?: number;
limit?: number;
teacher_id?: string;
name?: string;
department_id?: number;
nickname?: string;
}
/** 教师信息 */
export interface TeacherInfo {
id?: number;
teacher_id?: string | null;
name?: string;
gender?: string;
department_id?: number;
title?: string | null;
phone?: string;
email?: string;
avatar?: string | null;
remark?: string;
status?: number;
user_id?: number | null;
create_time?: string;
update_time?: string | null;
nickname?: string;
department_name?: string | null;
classroom_count?: number;
classroom_names?: string[];
[key: string]: any;
}
/** 教师详情参数 */
export interface DetailParams {
id: number;
}
/** 保存教师参数 */
export interface SaveParams extends TeacherInfo {
id?: number;
}
/** 删除教师参数 */
export interface DeleteParams {
id: number;
}
}
/**
* 获取教师列表
* GET /api/admin/teacher/list
*/
export async function getTeacherListApi(params?: TeacherApi.ListParams) {
// 使用 responseReturn: 'body' 获取完整响应体
return requestClient.get<any>('/api/admin/teacher/list', {
params,
responseReturn: 'body',
});
}
/**
* 获取教师详情
* GET /api/admin/teacher/detail
*/
export async function getTeacherDetailApi(params: TeacherApi.DetailParams) {
// 使用 responseReturn: 'body' 获取完整响应体
return requestClient.get<any>('/api/admin/teacher/detail', {
params,
responseReturn: 'body',
});
}
/**
* 保存教师(新增/编辑)
* POST /api/admin/teacher/save
*/
export async function saveTeacherApi(data: TeacherApi.SaveParams) {
return requestClient.post<TeacherApi.TeacherInfo>('/api/admin/teacher/save', data);
}
/**
* 删除教师
* POST /api/admin/teacher/delete
*/
export async function deleteTeacherApi(data: TeacherApi.DeleteParams) {
return requestClient.post('/api/admin/teacher/delete', data);
}

View File

@@ -0,0 +1,58 @@
import { baseRequestClient, requestClient } from '#/api/request';
export namespace AuthApi {
/** 登录接口参数 */
export interface LoginParams {
username: string;
password: string;
}
/** 登录接口返回值 */
export interface LoginResult {
accessToken?: string;
[key: string]: any;
}
export interface RefreshTokenResult {
data: string;
status: number;
}
}
/**
* 管理员登录
* POST /api/admin/login
*/
export async function loginApi(data: AuthApi.LoginParams) {
return requestClient.post<AuthApi.LoginResult>('/api/admin/login', data);
}
/**
* 获取当前管理员信息
* GET /api/admin/login/info
*/
export async function getAdminInfoApi() {
return requestClient.get<any>('/api/admin/login/info');
}
/**
* 退出登录
* POST /api/admin/login/logout
*/
export async function logoutApi() {
return baseRequestClient.post('/api/admin/login/logout');
}
/**
* 刷新accessToken如果后端支持
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh');
}
/**
* 获取用户权限码
*/
export async function getAccessCodesApi() {
return requestClient.get<string[]>('/auth/codes');
}

View File

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

View File

@@ -0,0 +1,17 @@
import type { RouteRecordStringComponent } from '@vben/types';
import { requestClient } from '#/api/request';
/**
* 获取用户所有菜单
* 如果后端没有此接口,返回空数组,使用静态路由
*/
export async function getAllMenusApi() {
try {
return await requestClient.get<RouteRecordStringComponent[]>('/menu/all');
} catch (error) {
console.warn('获取菜单接口不存在,使用静态路由:', error);
// 返回空数组,使用静态路由
return [];
}
}

View File

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

View File

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

View File

@@ -0,0 +1,129 @@
/**
* 该文件可自行根据业务逻辑进行调整
*/
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;
}
// 请求头处理
// 根据 API.md使用 Session 认证,不需要 Authorization header
// 但需要设置 withCredentials: true 以支持跨域携带 Cookie
client.addRequestInterceptor({
fulfilled: async (config) => {
const accessStore = useAccessStore();
// 使用 Session 认证,通过 Cookie 传递,需要设置 withCredentials
config.withCredentials = true;
config.headers['Accept-Language'] = preferences.app.locale;
// 如果需要 token可以保留但主要依赖 Session
if (accessStore.accessToken) {
config.headers.Authorization = formatToken(accessStore.accessToken);
}
return config;
},
});
// 处理返回的响应数据格式
// 根据实际接口,成功响应 code 可能为 0 或 200可能是字符串或数字
client.addResponseInterceptor(
defaultResponseInterceptor({
codeField: 'code',
dataField: 'data',
successCode: (code: any) => {
// 处理字符串和数字两种情况
const codeNum = typeof code === 'string' ? parseInt(code, 10) : code;
return codeNum === 0 || codeNum === 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?.message ?? '';
// 如果没有错误信息,则会根据状态码进行提示
message.error(errorMessage || msg);
}),
);
return client;
}
export const requestClient = createRequestClient(apiURL, {
responseReturn: 'data',
withCredentials: true, // 支持 Session 认证
});
export const baseRequestClient = new RequestClient({
baseURL: apiURL,
withCredentials: true, // 支持 Session 认证
});

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,76 @@
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 { initSetupVbenForm } from './adapter/form';
import App from './app.vue';
import { router } from './router';
async function bootstrap(namespace: string) {
// 初始化组件适配器
await initComponentAdapter();
// 初始化表单组件
await initSetupVbenForm();
// // 设置弹窗的默认配置
// 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,904 @@
<script lang="ts" setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import { Button, Input, Select, Space, message, Modal, Radio, Divider, Card } from 'ant-design-vue';
import type { ClassroomApi } from '#/api';
import { saveClassroomLayoutApi } from '#/api';
defineOptions({ name: 'SeatLayoutEditor' });
interface Props {
classroomId: number;
layout?: ClassroomApi.ClassroomLayout | null;
}
const props = withDefaults(defineProps<Props>(), {
layout: null,
});
const emit = defineEmits<{
saved: [layout: ClassroomApi.ClassroomLayout];
}>();
const loading = ref(false);
const cols = ref(10);
const rows = ref(8);
const selectedCellType = ref<ClassroomApi.ClassroomLayoutCell['type']>('seat');
const editingCell = ref<{ col: number; row: number } | null>(null);
const seatNumber = ref('');
const isDrawing = ref(false);
const autoNumbering = ref(true);
const seatNumberStart = ref(1);
const lastDrawnCell = ref<{ col: number; row: number } | null>(null);
const hasChangesInDrawing = ref(false); // 标记当前绘制操作是否有变化
// 初始化布局数据
const cells = ref<ClassroomApi.ClassroomLayoutCell[]>([]);
// 历史记录(用于撤销)
interface HistoryState {
cols: number;
rows: number;
cells: ClassroomApi.ClassroomLayoutCell[];
}
const history = ref<HistoryState[]>([]);
const canUndo = computed(() => history.value.length > 0);
// 保存当前状态到历史记录
const saveHistory = () => {
history.value.push({
cols: cols.value,
rows: rows.value,
cells: JSON.parse(JSON.stringify(cells.value)),
});
// 限制历史记录数量,避免内存占用过大
if (history.value.length > 50) {
history.value.shift();
}
};
// 撤销操作
const handleUndo = () => {
if (history.value.length === 0) {
message.warning('没有可撤销的操作');
return;
}
const lastState = history.value.pop()!;
cols.value = lastState.cols;
rows.value = lastState.rows;
cells.value = JSON.parse(JSON.stringify(lastState.cells));
message.success('已撤销');
};
// 初始化布局
const initLayout = () => {
cells.value = [];
for (let row = 0; row < rows.value; row++) {
for (let col = 0; col < cols.value; col++) {
cells.value.push({
col,
row,
type: 'empty',
});
}
}
};
// 从现有布局加载
const loadLayout = () => {
if (props.layout) {
cols.value = props.layout.cols || 10;
rows.value = props.layout.rows || 8;
if (props.layout.cells && props.layout.cells.length > 0) {
cells.value = JSON.parse(JSON.stringify(props.layout.cells));
} else {
initLayout();
}
} else {
initLayout();
}
};
// 监听布局变化
watch(
() => props.layout,
() => {
loadLayout();
},
{ immediate: true },
);
// 在左侧添加列
const addColumnLeft = () => {
saveHistory();
// 所有现有单元格的列号加1
cells.value.forEach((cell) => {
cell.col += 1;
});
// 添加新列列号为0
for (let row = 0; row < rows.value; row++) {
cells.value.push({
col: 0,
row,
type: 'empty',
});
}
cols.value += 1;
message.success('已在左侧添加一列');
};
// 在右侧添加列
const addColumnRight = () => {
saveHistory();
// 添加新列(列号为当前最大列号+1
for (let row = 0; row < rows.value; row++) {
cells.value.push({
col: cols.value,
row,
type: 'empty',
});
}
cols.value += 1;
message.success('已在右侧添加一列');
};
// 在上方添加行
const addRowTop = () => {
saveHistory();
// 所有现有单元格的行号加1
cells.value.forEach((cell) => {
cell.row += 1;
});
// 添加新行行号为0
for (let col = 0; col < cols.value; col++) {
cells.value.push({
col,
row: 0,
type: 'empty',
});
}
rows.value += 1;
message.success('已在上方添加一行');
};
// 在下方添加行
const addRowBottom = () => {
saveHistory();
// 添加新行(行号为当前最大行号+1
for (let col = 0; col < cols.value; col++) {
cells.value.push({
col,
row: rows.value,
type: 'empty',
});
}
rows.value += 1;
message.success('已在下方添加一行');
};
// 获取单元格
const getCell = (col: number, row: number) => {
return cells.value.find((c) => c.col === col && c.row === row);
};
// 获取下一个座位编号
const getNextSeatNumber = () => {
const seats = cells.value.filter((c) => c.type === 'seat' && c.number);
if (seats.length === 0) {
return String(seatNumberStart.value);
}
// 找到最大的座位编号
const maxNumber = Math.max(
...seats.map((c) => {
const num = parseInt(c.number || '0', 10);
return isNaN(num) ? 0 : num;
}),
);
return String(maxNumber + 1);
};
// 设置单元格类型
const setCellType = (col: number, row: number, type: ClassroomApi.ClassroomLayoutCell['type']) => {
const cell = getCell(col, row);
if (!cell) return;
const wasSeat = cell.type === 'seat';
const oldType = cell.type;
cell.type = type;
if (type !== 'seat') {
delete cell.number;
delete cell.status;
}
if (type === 'empty') {
delete cell.name;
} else if (type === 'pillar' || type === 'aisle' || type === 'door') {
cell.name = type === 'pillar' ? '柱子' : type === 'aisle' ? '过道' : '门';
} else if (type === 'seat') {
// 如果是座位类型
if (autoNumbering.value) {
// 自动编号:只有从非座位类型变为座位类型时才分配新编号
if (!wasSeat || !cell.number) {
cell.number = getNextSeatNumber();
cell.status = 1;
}
// 如果已经是座位类型,保持原有编号不变
} else if (!cell.number) {
// 手动输入模式,如果没有编号,设置为空
cell.status = 1;
}
}
};
// 处理单元格设置(用于点击和拖动)
const applyCellType = (col: number, row: number, skipSameType = false) => {
const cell = getCell(col, row);
if (!cell) return;
const oldType = cell.type;
// 如果跳过相同类型,且当前类型已经是目标类型,则不处理
if (skipSameType && cell.type === selectedCellType.value) {
// 如果是座位类型但没有编号,需要输入
if (selectedCellType.value === 'seat' && !autoNumbering.value && !cell.number) {
editingCell.value = { col, row };
seatNumber.value = '';
}
return;
}
// 记录最后绘制的单元格,避免重复绘制
if (lastDrawnCell.value && lastDrawnCell.value.col === col && lastDrawnCell.value.row === row) {
return;
}
if (selectedCellType.value === 'seat' && !autoNumbering.value && !isDrawing.value) {
// 如果是座位且不自动编号,且不是拖动模式,需要输入座位号
editingCell.value = { col, row };
seatNumber.value = cell?.number || '';
} else {
// 其他情况直接设置
setCellType(col, row, selectedCellType.value);
lastDrawnCell.value = { col, row };
// 标记有变化
if (oldType !== selectedCellType.value) {
hasChangesInDrawing.value = true;
}
}
};
// 点击单元格
const handleCellClick = (col: number, row: number) => {
if (!isDrawing.value) {
// 点击操作前保存历史记录
const cell = getCell(col, row);
if (cell && cell.type !== selectedCellType.value) {
saveHistory();
}
applyCellType(col, row, true);
}
};
// 鼠标按下开始绘制
const handleCellMouseDown = (col: number, row: number, event: MouseEvent) => {
event.preventDefault();
isDrawing.value = true;
hasChangesInDrawing.value = false; // 重置变化标记
lastDrawnCell.value = null; // 重置最后绘制的单元格
// 拖动操作开始前保存历史记录
saveHistory();
applyCellType(col, row, false);
};
// 鼠标移动绘制
const handleCellMouseEnter = (col: number, row: number) => {
if (isDrawing.value) {
applyCellType(col, row, false);
}
};
// 鼠标释放停止绘制
const handleCellMouseUp = () => {
if (isDrawing.value) {
isDrawing.value = false;
lastDrawnCell.value = null;
// 如果拖动过程中没有实际变化,撤销刚才保存的历史记录
if (!hasChangesInDrawing.value && history.value.length > 0) {
history.value.pop();
}
hasChangesInDrawing.value = false;
}
};
// 全局鼠标事件处理(确保即使鼠标移出单元格也能停止绘制)
const handleGlobalMouseUp = () => {
handleCellMouseUp();
};
// 组件挂载时添加全局事件监听
onMounted(() => {
document.addEventListener('mouseup', handleGlobalMouseUp);
document.addEventListener('mouseleave', handleGlobalMouseUp);
});
onUnmounted(() => {
document.removeEventListener('mouseup', handleGlobalMouseUp);
document.removeEventListener('mouseleave', handleGlobalMouseUp);
});
// 确认座位号
const confirmSeatNumber = () => {
if (!editingCell.value) return;
const cell = getCell(editingCell.value.col, editingCell.value.row);
if (cell) {
// 如果类型或编号发生变化,保存历史记录
if (cell.type !== 'seat' || cell.number !== seatNumber.value) {
saveHistory();
}
cell.type = 'seat';
cell.number = seatNumber.value;
cell.status = 1; // 默认可用
}
editingCell.value = null;
seatNumber.value = '';
};
// 批量填充
const handleFillAll = () => {
if (selectedCellType.value === 'empty') {
message.warning('不能批量填充为空白类型');
return;
}
Modal.confirm({
title: '确认批量填充',
content: `确定要将所有空白单元格填充为"${getTypeName(selectedCellType.value)}"吗?`,
onOk: () => {
// 如果是座位类型且自动编号,先获取当前最大编号
const emptyCells = cells.value.filter((cell) => cell.type === 'empty');
emptyCells.forEach((cell) => {
setCellType(cell.col, cell.row, selectedCellType.value);
});
message.success(`批量填充完成,共填充 ${emptyCells.length} 个单元格`);
},
});
};
// 获取类型名称
const getTypeName = (type: ClassroomApi.ClassroomLayoutCell['type']) => {
const typeMap: Record<string, string> = {
empty: '空白',
seat: '座位',
pillar: '柱子',
aisle: '过道',
door: '门',
};
return typeMap[type] || type;
};
// 统计座位数量
const seatCount = computed(() => {
return cells.value.filter((c) => c.type === 'seat').length;
});
// 取消编辑
const cancelEdit = () => {
editingCell.value = null;
seatNumber.value = '';
};
// 获取单元格样式
const getCellClass = (col: number, row: number) => {
const cell = getCell(col, row);
if (!cell) return 'cell-empty';
const classes = ['cell', `cell-${cell.type}`];
if (editingCell.value && editingCell.value.col === col && editingCell.value.row === row) {
classes.push('cell-editing');
}
return classes.join(' ');
};
// 获取单元格显示文本
const getCellText = (col: number, row: number) => {
const cell = getCell(col, row);
if (!cell) return '';
if (cell.type === 'seat' && cell.number) {
return cell.number;
}
if (cell.name) {
return cell.name;
}
return '';
};
// 保存布局
const handleSave = async () => {
if (!props.classroomId) {
message.error('教室ID不能为空');
return;
}
loading.value = true;
try {
const layout: ClassroomApi.ClassroomLayout = {
cols: cols.value,
rows: rows.value,
cells: cells.value,
};
await saveClassroomLayoutApi({
id: props.classroomId,
layout,
});
message.success('座位布局保存成功');
emit('saved', layout);
} catch (error) {
console.error('保存座位布局失败:', error);
message.error('保存座位布局失败');
} finally {
loading.value = false;
}
};
// 清空布局
const handleClear = () => {
Modal.confirm({
title: '确认清空',
content: '确定要清空所有座位布局吗?',
onOk: () => {
saveHistory(); // 保存清空前的状态,可以撤销
initLayout();
message.success('已清空布局');
},
});
};
</script>
<template>
<div class="seat-layout-editor" :class="{ drawing: isDrawing }">
<Card>
<div class="editor-toolbar">
<div class="toolbar-section">
<div class="toolbar-item">
<label class="toolbar-label">当前列数</label>
<span class="toolbar-value">{{ cols }}</span>
<Button size="small" @click="addColumnLeft">左侧添加</Button>
<Button size="small" @click="addColumnRight">右侧添加</Button>
</div>
<Divider type="vertical" />
<div class="toolbar-item">
<label class="toolbar-label">当前行数</label>
<span class="toolbar-value">{{ rows }}</span>
<Button size="small" @click="addRowTop">上方添加</Button>
<Button size="small" @click="addRowBottom">下方添加</Button>
</div>
<Divider type="vertical" />
<div class="toolbar-item">
<label class="toolbar-label">选择类型</label>
<Radio.Group v-model:value="selectedCellType" button-style="solid" size="large">
<Radio.Button value="empty">
<span class="type-icon type-empty"></span>
空白
</Radio.Button>
<Radio.Button value="seat">
<span class="type-icon type-seat"></span>
座位
</Radio.Button>
<Radio.Button value="pillar">
<span class="type-icon type-pillar"></span>
柱子
</Radio.Button>
<Radio.Button value="aisle">
<span class="type-icon type-aisle"></span>
过道
</Radio.Button>
<Radio.Button value="door">
<span class="type-icon type-door"></span>
</Radio.Button>
</Radio.Group>
</div>
</div>
<Divider />
<div class="toolbar-section">
<div class="toolbar-item">
<label class="toolbar-label">座位编号</label>
<Radio.Group v-model:value="autoNumbering">
<Radio :value="true">自动编号</Radio>
<Radio :value="false">手动输入</Radio>
</Radio.Group>
<Input
v-if="autoNumbering"
:value="String(seatNumberStart)"
type="number"
:min="1"
:style="{ width: '100px', marginLeft: '8px' }"
placeholder="起始编号"
@change="(e: any) => { const val = parseInt(e.target.value); if (!isNaN(val) && val > 0) seatNumberStart = val; }"
/>
</div>
<Divider type="vertical" />
<Space>
<Button :disabled="!canUndo" @click="handleUndo">
<template #icon>
<span></span>
</template>
撤销
</Button>
<Button @click="handleFillAll">批量填充空白</Button>
<Button danger @click="handleClear">清空布局</Button>
<Button type="primary" size="large" :loading="loading" @click="handleSave">
保存布局
</Button>
</Space>
</div>
<div class="toolbar-stats">
<span>座位总数<strong>{{ seatCount }}</strong></span>
</div>
</div>
</Card>
<Card class="layout-card">
<div class="editor-hint">
<p>
<strong>操作提示</strong>
选择类型后<strong>点击</strong>单元格即可设置类型<strong>按住鼠标左键拖动</strong>可以连续绘制多个单元格
{{ autoNumbering ? '座位将自动编号。' : '选择"座位"时需要手动输入座位号。' }}
<span v-if="isDrawing" class="drawing-indicator">正在绘制中...</span>
</p>
</div>
<div class="layout-container">
<div class="layout-header">
<div class="header-cell"></div>
<div
v-for="col in cols"
:key="`header-col-${col}`"
class="header-cell header-col"
>
{{ col }}
</div>
</div>
<div class="layout-body">
<div
v-for="row in rows"
:key="`row-${row}`"
class="layout-row"
>
<div class="header-cell header-row">{{ row }}</div>
<div
v-for="col in cols"
:key="`cell-${col}-${row}`"
:class="getCellClass(col - 1, row - 1)"
@click="handleCellClick(col - 1, row - 1)"
@mousedown="(e) => handleCellMouseDown(col - 1, row - 1, e)"
@mouseenter="handleCellMouseEnter(col - 1, row - 1)"
>
{{ getCellText(col - 1, row - 1) }}
</div>
</div>
</div>
</div>
</Card>
<Modal
:open="!!editingCell"
title="输入座位号"
@ok="confirmSeatNumber"
@cancel="cancelEdit"
>
<Input
v-model:value="seatNumber"
placeholder="请输入座位号"
@press-enter="confirmSeatNumber"
/>
</Modal>
</div>
</template>
<style scoped>
.seat-layout-editor {
padding: 0;
}
.editor-toolbar {
padding: 0;
}
.toolbar-section {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 12px;
}
.toolbar-item {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-label {
font-size: 15px;
font-weight: 600;
color: var(--ant-color-text);
white-space: nowrap;
}
.toolbar-value {
display: inline-block;
min-width: 30px;
padding: 0 8px;
font-size: 15px;
font-weight: 700;
color: var(--ant-color-primary);
text-align: center;
}
.toolbar-item :deep(.ant-radio-wrapper),
.toolbar-item :deep(.ant-radio-button-wrapper) {
color: var(--ant-color-text);
font-weight: 500;
}
.toolbar-item :deep(.ant-radio-button-wrapper-checked) {
color: var(--ant-color-text-inverse);
}
.toolbar-stats {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--ant-color-border-secondary);
font-size: 15px;
color: var(--ant-color-text-secondary);
font-weight: 500;
}
.toolbar-stats strong {
color: var(--ant-color-primary);
font-size: 18px;
font-weight: 700;
}
.type-icon {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 4px;
vertical-align: middle;
}
.type-empty {
background: #fff;
border: 1px solid #d9d9d9;
}
.type-seat {
background: #52c41a;
}
.type-pillar {
background: #8c8c8c;
}
.type-aisle {
background: #faad14;
}
.type-door {
background: #1890ff;
}
.layout-card {
margin-top: 16px;
}
.editor-hint {
margin-bottom: 16px;
padding: 12px 16px;
background: var(--ant-color-info-bg, #e6f7ff);
border: 1px solid var(--ant-color-info-border, #91d5ff);
border-radius: 4px;
}
.editor-hint p {
margin: 0;
font-size: 14px;
color: var(--ant-color-info, #1890ff);
line-height: 1.6;
}
.drawing-indicator {
display: inline-block;
margin-left: 12px;
padding: 2px 8px;
background: #ff4d4f;
color: #fff;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.layout-container {
overflow-x: auto;
padding: 10px;
background: var(--ant-color-fill-tertiary, #fafafa);
border-radius: 4px;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.layout-header {
display: flex;
margin-bottom: 4px;
gap: 4px;
}
.layout-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.layout-row {
display: flex;
gap: 4px;
}
.header-cell {
width: 45px;
min-width: 45px;
height: 45px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: var(--ant-color-text-secondary, #595959);
background: var(--ant-color-fill-secondary, #f5f5f5);
border: 2px solid var(--ant-color-border-secondary, #e8e8e8);
border-radius: 2px;
user-select: none;
box-sizing: border-box;
}
.header-col {
background: var(--ant-color-primary-bg, #e6f7ff);
color: var(--ant-color-primary, #1890ff);
font-weight: 700;
}
.header-row {
background: var(--ant-color-success-bg, #f6ffed);
color: var(--ant-color-success, #52c41a);
font-weight: 700;
}
.cell {
width: 45px;
min-width: 45px;
min-height: 45px;
height: 45px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 2px solid #d9d9d9;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
transition: all 0.15s;
user-select: none;
position: relative;
line-height: 1;
box-sizing: border-box;
}
.cell:hover {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
transform: scale(1.05);
z-index: 1;
}
.seat-layout-editor.drawing .cell {
cursor: crosshair;
}
.cell-empty {
background: var(--ant-color-bg-container, #ffffff);
color: var(--ant-color-text-disabled, #bfbfbf);
border-color: var(--ant-color-border, #d9d9d9);
}
.cell-seat {
background: #52c41a;
color: #ffffff;
border-color: #389e0d;
font-weight: 700;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.cell-seat:hover {
background: #73d13d;
border-color: #52c41a;
box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.2);
}
.cell-pillar {
background: #595959;
color: #ffffff;
border-color: #434343;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.cell-pillar:hover {
background: #8c8c8c;
box-shadow: 0 0 0 3px rgba(89, 89, 89, 0.2);
}
.cell-aisle {
background: #fa8c16;
color: #ffffff;
border-color: #d46b08;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.cell-aisle:hover {
background: #ffa940;
box-shadow: 0 0 0 3px rgba(250, 140, 22, 0.2);
}
.cell-door {
background: #1890ff;
color: #ffffff;
border-color: #096dd9;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.cell-door:hover {
background: #40a9ff;
box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.2);
}
.cell-editing {
border-color: #ff4d4f !important;
box-shadow: 0 0 0 3px rgba(255, 77, 79, 0.2) !important;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
</style>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Modal, Form, Input, message } from 'ant-design-vue';
import { updatePasswordApi, type ProfileApi } from '#/api/admin/profile';
const props = defineProps<{
open: boolean;
}>();
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const formRef = ref();
const loading = ref(false);
const formData = ref<ProfileApi.UpdatePasswordParams>({
old_password: '',
new_password: '',
confirm_password: '',
});
// 监听对话框打开
watch(
() => props.open,
(newVal) => {
if (!newVal) {
// 关闭时重置表单
formRef.value?.resetFields();
formData.value = {
old_password: '',
new_password: '',
confirm_password: '',
};
}
},
);
// 验证确认密码
function validateConfirmPassword(_rule: any, value: string) {
if (!value) {
return Promise.reject(new Error('请再次输入新密码'));
}
if (value !== formData.value.new_password) {
return Promise.reject(new Error('两次输入的密码不一致'));
}
return Promise.resolve();
}
// 提交表单
async function handleSubmit() {
try {
await formRef.value?.validate();
loading.value = true;
await updatePasswordApi(formData.value);
message.success('密码修改成功');
emit('update:open', false);
} catch (error: any) {
if (error?.errorFields) {
// 表单验证错误
return;
}
message.error(error?.message || '修改密码失败');
} finally {
loading.value = false;
}
}
function handleCancel() {
emit('update:open', false);
}
</script>
<template>
<Modal
:open="open"
title="修改密码"
:confirm-loading="loading"
@ok="handleSubmit"
@cancel="handleCancel"
width="500px"
>
<Form
ref="formRef"
:model="formData"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item
label="当前密码"
name="old_password"
:rules="[{ required: true, message: '请输入当前密码' }]"
>
<Input.Password
v-model:value="formData.old_password"
placeholder="请输入当前密码"
/>
</Form.Item>
<Form.Item
label="新密码"
name="new_password"
:rules="[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少6位' },
]"
>
<Input.Password
v-model:value="formData.new_password"
placeholder="请输入新密码"
/>
</Form.Item>
<Form.Item
label="确认密码"
name="confirm_password"
:rules="[{ validator: validateConfirmPassword }]"
>
<Input.Password
v-model:value="formData.confirm_password"
placeholder="请再次输入新密码"
/>
</Form.Item>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,259 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { Modal, Form, Input, Upload, message } from 'ant-design-vue';
import { useAppConfig } from '@vben/hooks';
import { getProfileInfoApi, updateProfileApi, type ProfileApi } from '#/api/admin/profile';
import { useUserStore } from '@vben/stores';
const props = defineProps<{
open: boolean;
}>();
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const formRef = ref();
const loading = ref(false);
const userStore = useUserStore();
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const formData = ref<{
nickname: string;
email: string;
avatar?: string;
avatarFile?: File;
}>({
nickname: '',
email: '',
avatar: '',
});
const fileList = ref<any[]>([]);
// 获取完整的头像URL
const getAvatarUrl = (avatarPath?: string | null): string => {
if (!avatarPath) return '';
// 如果已经是完整URL直接返回
if (avatarPath.startsWith('http://') || avatarPath.startsWith('https://')) {
return avatarPath;
}
// 拼接完整的URL
// apiURL 是字符串,不是 ref所以不需要 .value
if (!apiURL) {
console.warn('apiURL is not configured');
return avatarPath;
}
const baseUrl = apiURL.replace(/\/$/, '');
const path = avatarPath.startsWith('/') ? avatarPath : `/${avatarPath}`;
return `${baseUrl}${path}`;
};
// 获取个人信息
async function loadProfileInfo() {
try {
loading.value = true;
const res = await getProfileInfoApi();
console.log('获取个人信息成功:', res);
// 检查返回的数据
if (!res) {
throw new Error('返回数据为空');
}
formData.value = {
nickname: res.nickname || '',
email: res.email || '',
avatar: res.avatar || '',
};
if (res.avatar) {
const avatarUrl = getAvatarUrl(res.avatar);
fileList.value = [
{
uid: '-1',
name: 'avatar',
status: 'done',
url: avatarUrl,
},
];
} else {
fileList.value = [];
}
} catch (error: any) {
console.error('获取个人信息失败:', error);
console.error('错误详情:', {
message: error?.message,
response: error?.response,
data: error?.response?.data,
});
const errorMessage = error?.response?.data?.message || error?.message || '获取个人信息失败';
message.error(errorMessage);
} finally {
loading.value = false;
}
}
// 监听对话框打开
watch(
() => props.open,
(newVal) => {
if (newVal) {
loadProfileInfo();
} else {
// 关闭时重置表单
formRef.value?.resetFields();
fileList.value = [];
}
},
);
// 处理头像上传前
function beforeUpload(file: File) {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('只能上传图片文件!');
return false;
}
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
message.error('图片大小不能超过 2MB');
return false;
}
formData.value.avatarFile = file;
// 生成预览
const reader = new FileReader();
reader.onload = (e) => {
const previewUrl = e.target?.result as string;
fileList.value = [
{
uid: Date.now().toString(),
name: file.name,
status: 'done',
url: previewUrl,
originFileObj: file,
},
];
};
reader.readAsDataURL(file);
return false; // 阻止自动上传
}
// 处理头像上传变化
function handleAvatarChange(info: any) {
if (info.file.status === 'removed') {
fileList.value = [];
formData.value.avatarFile = undefined;
formData.value.avatar = '';
}
}
// 提交表单
async function handleSubmit() {
try {
await formRef.value?.validate();
loading.value = true;
const params: ProfileApi.UpdateProfileParams = {
nickname: formData.value.nickname,
email: formData.value.email || undefined, // 如果为空字符串,传 undefined
};
// 只有选择了新文件才上传
if (formData.value.avatarFile) {
params.avatar = formData.value.avatarFile;
}
const res = await updateProfileApi(params);
message.success('个人信息更新成功');
// 更新用户信息
if (userStore.userInfo) {
userStore.userInfo.nickname = formData.value.nickname;
userStore.userInfo.email = formData.value.email || null;
// 更新后重新获取数据以获取最新的头像路径
if (res?.avatar) {
userStore.userInfo.avatar = res.avatar;
// 更新文件列表中的头像URL
const avatarUrl = getAvatarUrl(res.avatar);
if (fileList.value.length > 0) {
fileList.value[0].url = avatarUrl;
}
}
}
emit('update:open', false);
} catch (error: any) {
if (error?.errorFields) {
// 表单验证错误
return;
}
message.error(error?.message || '更新个人信息失败');
} finally {
loading.value = false;
}
}
function handleCancel() {
emit('update:open', false);
}
</script>
<template>
<Modal
:open="open"
title="个人信息"
:confirm-loading="loading"
@ok="handleSubmit"
@cancel="handleCancel"
width="600px"
>
<Form
ref="formRef"
:model="formData"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item
label="昵称"
name="nickname"
:rules="[{ required: true, message: '请输入昵称' }]"
>
<Input
v-model:value="formData.nickname"
placeholder="请输入昵称"
/>
</Form.Item>
<Form.Item
label="邮箱"
name="email"
:rules="[
{ type: 'email', message: '请输入正确的邮箱格式' },
]"
>
<Input
v-model:value="formData.email"
placeholder="请输入邮箱(可选)"
/>
</Form.Item>
<Form.Item label="头像" name="avatar">
<Upload
v-model:file-list="fileList"
list-type="picture-card"
:max-count="1"
:before-upload="beforeUpload"
@change="handleAvatarChange"
accept="image/*"
>
<div v-if="fileList.length < 1">
<div style="margin-top: 8px">
<span>上传</span>
</div>
</div>
</Upload>
</Form.Item>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,25 @@
<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);
const logoDark = computed(() => preferences.logo.sourceDark);
</script>
<template>
<AuthPageLayout
:app-name="appName"
:logo="logo"
:logo-dark="logoDark"
:page-description="$t('authentication.pageDesc')"
:page-title="$t('authentication.pageTitle')"
>
<!-- 自定义工具栏 -->
<!-- <template #toolbar></template> -->
</AuthPageLayout>
</template>

View File

@@ -0,0 +1,187 @@
<script lang="ts" setup>
import type { NotificationItem } from '@vben/layouts';
import { computed, ref, watch } from 'vue';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { useWatermark } from '@vben/hooks';
import {
BasicLayout,
LockScreen,
Notification,
UserDropdown,
} from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { useAuthStore } from '#/store';
import LoginForm from '#/views/_core/authentication/login.vue';
import ProfileInfoModal from '#/components/profile/ProfileInfoModal.vue';
import ChangePasswordModal from '#/components/profile/ChangePasswordModal.vue';
const notifications = ref<NotificationItem[]>([
{
id: 1,
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
date: '3小时前',
isRead: true,
message: '描述信息描述信息描述信息',
title: '收到了 14 份新周报',
},
{
id: 2,
avatar: 'https://avatar.vercel.sh/1',
date: '刚刚',
isRead: false,
message: '描述信息描述信息描述信息',
title: '朱偏右 回复了你',
},
{
id: 3,
avatar: 'https://avatar.vercel.sh/1',
date: '2024-01-01',
isRead: false,
message: '描述信息描述信息描述信息',
title: '曲丽丽 评论了你',
},
{
id: 4,
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '代办提醒',
},
{
id: 5,
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '跳转Workspace示例',
link: '/workspace',
},
{
id: 6,
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '跳转外部链接示例',
link: 'https://doc.vben.pro',
},
]);
const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark();
const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
const profileInfoModalOpen = ref(false);
const changePasswordModalOpen = ref(false);
const menus = computed(() => [
{
handler: () => {
profileInfoModalOpen.value = true;
},
icon: 'lucide:user',
text: '个人信息',
},
{
handler: () => {
changePasswordModalOpen.value = true;
},
icon: 'lucide:lock',
text: '修改密码',
},
]);
const avatar = computed(() => {
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
});
async function handleLogout() {
await authStore.logout(false);
}
function handleNoticeClear() {
notifications.value = [];
}
function markRead(id: number | string) {
const item = notifications.value.find((item) => item.id === id);
if (item) {
item.isRead = true;
}
}
function remove(id: number | string) {
notifications.value = notifications.value.filter((item) => item.id !== id);
}
function handleMakeAll() {
notifications.value.forEach((item) => (item.isRead = true));
}
watch(
() => ({
enable: preferences.app.watermark,
content: preferences.app.watermarkContent,
}),
async ({ enable, content }) => {
if (enable) {
await updateWatermark({
content:
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"
@read="(item) => item.id && markRead(item.id)"
@remove="(item) => item.id && remove(item.id)"
@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>
<ProfileInfoModal v-model:open="profileInfoModalOpen" />
<ChangePasswordModal v-model:open="changePasswordModalOpen" />
</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,13 @@
{
"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",
"tdesign": "TDesign Vue Version"
}
}

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
{
"auth": {
"login": "登录",
"register": "注册",
"codeLogin": "验证码登录",
"qrcodeLogin": "二维码登录",
"forgetPassword": "忘记密码",
"profile": "个人中心"
},
"dashboard": {
"title": "概览",
"analytics": "系统概览"
},
"classroom": {
"title": "教室管理",
"list": "教室列表",
"detail": "教室详情"
},
"booking": {
"title": "预订管理",
"list": "预订列表"
},
"student": {
"title": "学生管理",
"list": "学生列表",
"detail": "学生详情"
},
"class": {
"title": "班级管理",
"list": "班级列表",
"detail": "班级详情"
},
"teacher": {
"title": "教师管理",
"list": "教师列表",
"detail": "教师详情"
},
"user": {
"title": "用户管理",
"list": "用户列表"
},
"school": {
"title": "学校管理",
"list": "学校列表",
"detail": "学校详情"
},
"setting": {
"title": "系统设置",
"index": "系统设置"
}
}

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

@@ -0,0 +1,40 @@
import { initPreferences, updatePreferences } 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,
});
// 强制更新 widget 配置,确保时区和语言切换菜单被禁用
// 这样可以覆盖 localStorage 中可能存在的旧配置
updatePreferences({
widget: {
timezone: false,
languageToggle: false,
},
});
// 启动应用并挂载
// vue应用主要逻辑及视图
const { bootstrap } = await import('./bootstrap');
await bootstrap(namespace);
// 移除并销毁loading
unmountGlobalLoading();
}
initApplication();

View File

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

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,252 @@
import type { RouteRecordRaw, Router } from 'vue-router';
import { nextTick } from 'vue';
import { 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((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) {
const redirectPath = decodeURIComponent(
(to.query?.redirect as string) ||
userStore.userInfo?.homePath ||
'/dashboard/analytics',
);
return redirectPath;
}
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 === preferences.app.defaultHomePath
? {}
: { redirect: encodeURIComponent(to.fullPath) },
// 携带当前跳转的页面,登录后重新跳转该页面
replace: true,
};
}
return to;
}
// 是否已经生成过动态路由
if (accessStore.isAccessChecked) {
// 即使已经检查过,也需要验证当前路径是否能正确解析
// 如果路径无法解析(比如直接访问 /admin/dashboard/analytics需要重新解析
try {
const resolved = router.resolve(to.path);
// 如果路由无法解析或者是 404尝试重新解析或重定向
if (!resolved.name || resolved.name === 'FallbackNotFound') {
// 如果路径包含 /admin 前缀,尝试去掉前缀
let normalizedPath = to.path;
if (normalizedPath.startsWith('/admin')) {
normalizedPath = normalizedPath.replace(/^\/admin/, '');
}
// 尝试解析去掉前缀后的路径
const normalizedResolved = router.resolve(normalizedPath);
if (normalizedResolved.name && normalizedResolved.name !== 'FallbackNotFound') {
return {
...normalizedResolved,
replace: true,
};
}
// 如果还是无法解析,尝试解析默认首页
const defaultResolved = router.resolve('/dashboard/analytics');
if (defaultResolved.name && defaultResolved.name !== 'FallbackNotFound') {
return {
...defaultResolved,
replace: true,
};
}
}
} catch (error) {
console.error('路由解析失败:', error);
}
return true;
}
// 生成路由表
// 当前登录用户拥有的角色标识列表
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
const userRoles = userInfo?.roles ?? [];
// 生成菜单和路由
let accessibleMenus: any[] = [];
let accessibleRoutes: RouteRecordRaw[] = [];
try {
const result = await generateAccess({
roles: userRoles,
router,
// 则会在菜单中显示但是访问会被重定向到403
routes: accessRoutes,
});
accessibleMenus = result.accessibleMenus;
accessibleRoutes = result.accessibleRoutes;
} catch (error) {
console.error('生成路由失败:', error);
// 如果生成路由失败,使用静态路由并手动添加到 router
accessibleRoutes = accessRoutes;
const root = router.getRoutes().find((item) => item.path === '/');
if (root) {
const names = root?.children?.map((item) => item.name) ?? [];
accessibleRoutes.forEach((route) => {
if (root && !route.meta?.noBasicLayout) {
if (route.children && route.children.length > 0) {
delete route.component;
}
if (!names?.includes(route.name)) {
root.children?.push(route);
}
} else {
router.addRoute(route);
}
});
if (root.name) {
router.removeRoute(root.name);
}
router.addRoute(root);
}
accessibleMenus = [];
}
// 保存菜单信息和路由信息
accessStore.setAccessMenus(accessibleMenus);
accessStore.setAccessRoutes(accessibleRoutes);
accessStore.setIsAccessChecked(true);
// 等待路由添加到 router 中
await nextTick();
// 确定跳转路径
let redirectPath: string;
// 优先使用 to.query.redirect从登录页跳转过来
if (to.query?.redirect) {
redirectPath = decodeURIComponent(to.query.redirect as string);
} else if (from.query?.redirect) {
redirectPath = decodeURIComponent(from.query.redirect as string);
} else if (to.path === '/dashboard/analytics' || to.path === preferences.app.defaultHomePath || to.path === '/') {
redirectPath = userInfo?.homePath || '/dashboard/analytics';
} else {
redirectPath = to.fullPath;
}
// 尝试解析路由
try {
const resolved = router.resolve(redirectPath);
// 检查路由是否存在(有 name 或者匹配到了路由)
if (resolved.name && resolved.name !== 'FallbackNotFound') {
return {
...resolved,
replace: true,
};
}
// 如果解析的路由是404说明路由不存在尝试查找默认首页
if (resolved.name === 'FallbackNotFound') {
// 尝试解析默认首页
const defaultResolved = router.resolve('/dashboard/analytics');
if (defaultResolved.name && defaultResolved.name !== 'FallbackNotFound') {
return {
...defaultResolved,
replace: true,
};
}
}
} catch (error) {
console.error('路由解析失败:', error);
}
// 如果解析失败,尝试通过路由名称查找
// 查找 Analytics 路由dashboard/analytics 的路由名称)
const analyticsRoute = router.getRoutes().find(
(route) => route.name === 'Analytics' || route.path === '/dashboard/analytics',
);
if (analyticsRoute) {
return {
name: analyticsRoute.name || undefined,
path: analyticsRoute.path,
replace: true,
};
}
// 如果还是找不到,跳转到默认首页路径
return {
path: '/dashboard/analytics',
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,97 @@
import type { RouteRecordRaw } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
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: preferences.app.defaultHomePath,
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,37 @@
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];
export { accessRoutes, coreRouteNames, routes };

View File

@@ -0,0 +1,30 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:calendar-check',
order: 2,
title: $t('page.booking.title'),
},
name: 'Booking',
path: '/booking',
redirect: '/booking/list',
children: [
{
name: 'BookingList',
path: 'list',
component: () => import('#/views/booking/list.vue'),
meta: {
icon: 'lucide:list',
title: $t('page.booking.list'),
},
},
],
},
];
export default routes;

View File

@@ -0,0 +1,39 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:graduation-cap',
order: 4,
title: $t('page.class.title'),
},
name: 'Class',
path: '/class',
redirect: '/class/list',
children: [
{
name: 'ClassList',
path: 'list',
component: () => import('#/views/class/list.vue'),
meta: {
icon: 'lucide:list',
title: $t('page.class.list'),
},
},
{
name: 'ClassDetail',
path: 'detail/:id?',
component: () => import('#/views/class/detail.vue'),
meta: {
hideInMenu: true,
title: $t('page.class.detail'),
},
},
],
},
];
export default routes;

View File

@@ -0,0 +1,39 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:school',
order: 1,
title: $t('page.classroom.title'),
},
name: 'Classroom',
path: '/classroom',
redirect: '/classroom/list',
children: [
{
name: 'ClassroomList',
path: 'list',
component: () => import('#/views/classroom/list.vue'),
meta: {
icon: 'lucide:list',
title: $t('page.classroom.list'),
},
},
{
name: 'ClassroomDetail',
path: 'detail/:id?',
component: () => import('#/views/classroom/detail.vue'),
meta: {
hideInMenu: true,
title: $t('page.classroom.detail'),
},
},
],
},
];
export default routes;

View File

@@ -0,0 +1,30 @@
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',
redirect: '/dashboard/analytics',
children: [
{
name: 'Analytics',
path: 'analytics',
component: () => import('#/views/dashboard/index.vue'),
meta: {
affixTab: true,
icon: 'lucide:area-chart',
title: $t('page.dashboard.analytics'),
},
},
],
},
];
export default routes;

View File

@@ -0,0 +1,40 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:building-2',
order: 7,
title: $t('page.school.title'),
roles: ['super_admin'], // 仅超级管理员可见
},
name: 'School',
path: '/school',
redirect: '/school/list',
children: [
{
name: 'SchoolList',
path: 'list',
component: () => import('#/views/school/list.vue'),
meta: {
icon: 'lucide:list',
title: $t('page.school.list'),
},
},
{
name: 'SchoolDetail',
path: 'detail/:id?',
component: () => import('#/views/school/detail.vue'),
meta: {
hideInMenu: true,
title: $t('page.school.detail'),
},
},
],
},
];
export default routes;

View File

@@ -0,0 +1,31 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:settings',
order: 99,
title: $t('page.setting.title'),
roles: ['super_admin'], // 仅超级管理员可见
},
name: 'Setting',
path: '/setting',
redirect: '/setting/index',
children: [
{
name: 'SettingIndex',
path: 'index',
component: () => import('#/views/setting/index.vue'),
meta: {
icon: 'lucide:settings',
title: $t('page.setting.index'),
},
},
],
},
];
export default routes;

View File

@@ -0,0 +1,39 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:users',
order: 3,
title: $t('page.student.title'),
},
name: 'Student',
path: '/student',
redirect: '/student/list',
children: [
{
name: 'StudentList',
path: 'list',
component: () => import('#/views/student/list.vue'),
meta: {
icon: 'lucide:list',
title: $t('page.student.list'),
},
},
{
name: 'StudentDetail',
path: 'detail/:id?',
component: () => import('#/views/student/detail.vue'),
meta: {
hideInMenu: true,
title: $t('page.student.detail'),
},
},
],
},
];
export default routes;

View File

@@ -0,0 +1,39 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:user-check',
order: 5,
title: $t('page.teacher.title'),
},
name: 'Teacher',
path: '/teacher',
redirect: '/teacher/list',
children: [
{
name: 'TeacherList',
path: 'list',
component: () => import('#/views/teacher/list.vue'),
meta: {
icon: 'lucide:list',
title: $t('page.teacher.list'),
},
},
{
name: 'TeacherDetail',
path: 'detail/:id?',
component: () => import('#/views/teacher/detail.vue'),
meta: {
hideInMenu: true,
title: $t('page.teacher.detail'),
},
},
],
},
];
export default routes;

View File

@@ -0,0 +1,151 @@
import type { Recordable, UserInfo } from '@vben/types';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
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 loginResult = await loginApi(params as { username: string; password: string });
// 登录成功(根据 API.mdcode 200 表示成功)
// 如果返回数据中有 accessToken则使用它否则使用 Session 认证
const accessToken = loginResult?.accessToken || loginResult?.token || 'session';
// 设置 accessTokenSession 认证时可以使用固定值)
accessStore.setAccessToken(accessToken);
// 获取用户信息并存储到 accessStore 中
try {
const [fetchUserInfoResult, accessCodesResult] = await Promise.allSettled([
fetchUserInfo(),
getAccessCodesApi().catch(() => []), // 如果接口不存在,返回空数组
]);
userInfo = fetchUserInfoResult.status === 'fulfilled'
? fetchUserInfoResult.value
: null;
if (userInfo) {
userStore.setUserInfo(userInfo);
}
if (accessCodesResult.status === 'fulfilled') {
accessStore.setAccessCodes(accessCodesResult.value);
} else {
accessStore.setAccessCodes([]);
}
// 重置路由检查状态,让路由守卫重新生成路由
accessStore.setIsAccessChecked(false);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
} else {
if (onSuccess) {
await onSuccess?.();
} else {
// 跳转到首页,让路由守卫处理路由生成和跳转
const homePath = userInfo?.homePath || '/dashboard/analytics';
await router.push(homePath);
}
}
if (userInfo?.realName) {
notification.success({
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
duration: 3,
message: $t('authentication.loginSuccess'),
});
} else {
notification.success({
description: $t('authentication.loginSuccessDesc'),
duration: 3,
message: $t('authentication.loginSuccess'),
});
}
} catch (error) {
console.error('获取用户信息失败:', error);
// 重置路由检查状态
accessStore.setIsAccessChecked(false);
// 即使获取用户信息失败,也允许跳转
await router.push('/dashboard/analytics');
}
} catch (error) {
console.error('登录失败:', error);
throw error;
} 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,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,53 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { BasicOption } from '@vben/types';
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"
@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,65 @@
<script setup lang="ts">
import type { BasicOption } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import { computed, onMounted, ref } from 'vue';
import { ProfileBaseSetting } from '@vben/common-ui';
import { getUserInfoApi } from '#/api';
const profileBaseSettingRef = ref();
const MOCK_ROLES_OPTIONS: BasicOption[] = [
{
label: '管理员',
value: 'super',
},
{
label: '用户',
value: 'user',
},
{
label: '测试',
value: 'test',
},
];
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'realName',
component: 'Input',
label: '姓名',
},
{
fieldName: 'username',
component: 'Input',
label: '用户名',
},
{
fieldName: 'roles',
component: 'Select',
componentProps: {
mode: 'tags',
options: MOCK_ROLES_OPTIONS,
},
label: '角色',
},
{
fieldName: 'introduction',
component: 'Textarea',
label: '个人简介',
},
];
});
onMounted(async () => {
const data = await getUserInfoApi();
profileBaseSettingRef.value.getFormApi().setValues(data);
});
</script>
<template>
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Profile } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import ProfileBase from './base-setting.vue';
import ProfileNotificationSetting from './notification-setting.vue';
import ProfilePasswordSetting from './password-setting.vue';
import ProfileSecuritySetting from './security-setting.vue';
const userStore = useUserStore();
const tabsValue = ref<string>('basic');
const tabs = ref([
{
label: '基本设置',
value: 'basic',
},
{
label: '安全设置',
value: 'security',
},
{
label: '修改密码',
value: 'password',
},
{
label: '新消息提醒',
value: 'notice',
},
]);
</script>
<template>
<Profile
v-model:model-value="tabsValue"
title="个人中心"
:user-info="userStore.userInfo"
:tabs="tabs"
>
<template #content>
<ProfileBase v-if="tabsValue === 'basic'" />
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
</template>
</Profile>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileNotificationSetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '其他用户的消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'systemMessage',
label: '系统消息',
description: '系统消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'todoTask',
label: '待办任务',
description: '待办任务将以站内信的形式通知',
},
];
});
</script>
<template>
<ProfileNotificationSetting :form-schema="formSchema" />
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { computed, ref } from 'vue';
import { ProfilePasswordSetting, z } from '@vben/common-ui';
import { message } from 'ant-design-vue';
const profilePasswordSettingRef = ref();
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'oldPassword',
label: '旧密码',
component: 'VbenInputPassword',
componentProps: {
placeholder: '请输入旧密码',
},
},
{
fieldName: 'newPassword',
label: '新密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请输入新密码',
},
},
{
fieldName: 'confirmPassword',
label: '确认密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请再次输入新密码',
},
dependencies: {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',
});
},
triggerFields: ['newPassword'],
},
},
];
});
function handleSubmit() {
message.success('密码修改成功');
}
</script>
<template>
<ProfilePasswordSetting
ref="profilePasswordSettingRef"
class="w-1/3"
:form-schema="formSchema"
@submit="handleSubmit"
/>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileSecuritySetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '当前密码强度:强',
},
{
value: true,
fieldName: 'securityPhone',
label: '密保手机',
description: '已绑定手机138****8293',
},
{
value: true,
fieldName: 'securityQuestion',
label: '密保问题',
description: '未设置密保问题,密保问题可有效保护账户安全',
},
{
value: true,
fieldName: 'securityEmail',
label: '备用邮箱',
description: '已绑定邮箱ant***sign.com',
},
{
value: false,
fieldName: 'securityMfa',
label: 'MFA 设备',
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
},
];
});
</script>
<template>
<ProfileSecuritySetting :form-schema="formSchema" />
</template>

View File

@@ -0,0 +1,291 @@
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, Table, Space, message, Modal, Input, DatePicker, Select, Tag } from 'ant-design-vue';
import {
getBookingListApi,
approveBookingApi,
rejectBookingApi,
type BookingApi
} from '#/api';
defineOptions({ name: 'BookingList' });
const loading = ref(false);
const tableData = ref<BookingApi.BookingInfo[]>([]);
const total = ref(0);
const pagination = reactive({
current: 1,
pageSize: 10,
});
const searchForm = reactive({
classroom_id: undefined as number | undefined,
booking_date: '',
status: undefined as number | undefined,
student_id: undefined as number | undefined,
});
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '教室名称',
dataIndex: 'classroom_name',
key: 'classroom_name',
width: 180,
},
{
title: '座位号',
dataIndex: 'seat_number',
key: 'seat_number',
width: 100,
},
{
title: '学生ID',
dataIndex: 'student_id',
key: 'student_id',
width: 100,
},
{
title: '预订日期',
dataIndex: 'booking_date',
key: 'booking_date',
width: 120,
},
{
title: '时间范围',
dataIndex: 'time_range',
key: 'time_range',
width: 180,
customRender: ({ text }: { text: string }) => {
if (!text || text === '00:00:00 - 00:00:00') {
return '-';
}
return text;
},
},
{
title: '用途',
dataIndex: 'purpose',
key: 'purpose',
width: 150,
customRender: ({ text }: { text: string | null }) => {
return text || '-';
},
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
},
{
title: '创建时间',
dataIndex: 'create_time',
key: 'create_time',
width: 180,
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right' as const,
},
];
const fetchData = async () => {
loading.value = true;
try {
const params: BookingApi.ListParams = {
page: pagination.current,
limit: pagination.pageSize,
...searchForm,
};
const res = await getBookingListApi(params);
// 根据实际返回格式code 为 0 表示成功
if (res && (res.code === 0 || res.code === 200)) {
tableData.value = Array.isArray(res.data) ? res.data : [];
total.value = res.count || 0;
} else {
tableData.value = [];
total.value = 0;
}
} catch (error) {
console.error('获取预订列表失败:', error);
tableData.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.current = 1;
fetchData();
};
const handleReset = () => {
searchForm.classroom_id = undefined;
searchForm.booking_date = '';
searchForm.status = undefined;
searchForm.student_id = undefined;
handleSearch();
};
const getStatusText = (status?: number) => {
const statusMap: Record<number, string> = {
0: '待审核',
1: '已通过',
2: '已拒绝',
};
return statusMap[status ?? -1] || '未知';
};
const getStatusColor = (status?: number) => {
const colorMap: Record<number, string> = {
0: 'orange',
1: 'green',
2: 'red',
};
return colorMap[status ?? -1] || 'default';
};
const handleApprove = (record: BookingApi.BookingInfo) => {
Modal.confirm({
title: '确认同意',
content: '确定要同意这个座位变更申请吗?',
onOk: async () => {
try {
await approveBookingApi({ id: record.id! });
message.success('操作成功');
fetchData();
} catch (error) {
console.error('操作失败:', error);
}
},
});
};
const handleReject = (record: BookingApi.BookingInfo) => {
Modal.confirm({
title: '拒绝申请',
content: '请输入拒绝原因',
onOk: async () => {
try {
await rejectBookingApi({ id: record.id!, reason: '管理员拒绝' });
message.success('操作成功');
fetchData();
} catch (error) {
console.error('操作失败:', error);
}
},
});
};
const handleTableChange = (pag: any) => {
pagination.current = pag.current;
pagination.pageSize = pag.pageSize;
fetchData();
};
onMounted(() => {
fetchData();
});
</script>
<template>
<Page title="预订列表">
<Card>
<div class="mb-4">
<Space wrap>
<Input
v-model:value="searchForm.classroom_id"
placeholder="教室ID"
style="width: 150px"
type="number"
allow-clear
@press-enter="handleSearch"
/>
<Input
v-model:value="searchForm.student_id"
placeholder="学生ID"
style="width: 150px"
type="number"
allow-clear
@press-enter="handleSearch"
/>
<DatePicker
v-model:value="searchForm.booking_date"
placeholder="预订日期"
style="width: 180px"
format="YYYY-MM-DD"
allow-clear
/>
<Select
v-model:value="searchForm.status"
placeholder="状态"
style="width: 120px"
allow-clear
>
<Select.Option :value="0">待审核</Select.Option>
<Select.Option :value="1">已通过</Select.Option>
<Select.Option :value="2">已拒绝</Select.Option>
</Select>
<Button type="primary" @click="handleSearch">搜索</Button>
<Button @click="handleReset">重置</Button>
</Space>
</div>
<Table
:columns="columns"
:data-source="tableData"
:loading="loading"
:scroll="{ x: 1400 }"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total: total,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
}"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<Tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</Tag>
</template>
<template v-else-if="column.key === 'action'">
<Space>
<Button
v-if="record.status === 0"
type="link"
size="small"
@click="handleApprove(record)"
>
同意
</Button>
<Button
v-if="record.status === 0"
type="link"
danger
size="small"
@click="handleReject(record)"
>
拒绝
</Button>
<span v-else class="text-gray-400">-</span>
</Space>
</template>
</template>
</Table>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,250 @@
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Card, Form, Input, Button, message, Select, Radio } from 'ant-design-vue';
import {
getClassDetailApi,
saveClassApi,
getTeachersApi,
getClassroomsApi,
type ClassApi
} from '#/api';
defineOptions({ name: 'ClassDetail' });
const route = useRoute();
const router = useRouter();
const formRef = ref();
const loading = ref(false);
const teacherList = ref<any[]>([]);
const classroomList = ref<any[]>([]);
const teacherListLoading = ref(false);
const classroomListLoading = ref(false);
const formData = ref<Partial<ClassApi.SaveParams>>({
name: '',
grade: '',
teacher_id: undefined,
class_room_id: undefined,
description: '',
status: 1,
});
const isEdit = computed(() => !!route.params.id);
const handleSubmit = async () => {
try {
await formRef.value.validate();
loading.value = true;
const data = { ...formData.value };
if (isEdit.value) {
data.id = Number(route.params.id);
}
await saveClassApi(data);
message.success(isEdit.value ? '更新成功' : '创建成功');
router.back();
} catch (error: any) {
console.error('保存失败:', error);
message.error(error?.response?.data?.message || error?.response?.data?.msg || '保存失败');
} finally {
loading.value = false;
}
};
const fetchDetail = async () => {
if (!isEdit.value) return;
loading.value = true;
try {
const res = await getClassDetailApi({ id: Number(route.params.id) });
// 支持 code 为 0 或 200 的成功响应
if (res && (res.code === 0 || res.code === 200)) {
// 根据实际返回格式,数据在 res.data 中
const data = res.data;
if (data) {
formData.value = {
name: data.name || '',
grade: data.grade || '',
teacher_id: data.teacher_id || undefined,
class_room_id: data.class_room_id || undefined,
description: data.description || '',
status: data.status !== undefined ? data.status : 1,
};
} else {
message.error('获取班级详情失败:数据为空');
}
} else {
message.error(res?.message || res?.msg || '获取班级详情失败');
}
} catch (error: any) {
console.error('获取详情失败:', error);
message.error(error?.response?.data?.message || error?.response?.data?.msg || '获取班级详情失败');
} finally {
loading.value = false;
}
};
// 获取班主任列表
const fetchTeacherList = async () => {
teacherListLoading.value = true;
try {
const res: any = await getTeachersApi();
if (Array.isArray(res)) {
teacherList.value = res;
} else if (res && (res.code === 0 || res.code === 200)) {
teacherList.value = Array.isArray(res.data) ? res.data : [];
} else {
teacherList.value = [];
}
} catch (error) {
console.error('获取班主任列表失败:', error);
teacherList.value = [];
} finally {
teacherListLoading.value = false;
}
};
// 获取教室列表
const fetchClassroomList = async () => {
classroomListLoading.value = true;
try {
const res = await getClassroomsApi();
if (res && (res.code === 0 || res.code === 200)) {
// 根据实际返回格式,教室列表在 res.data.all_classrooms 中
if (res.data && res.data.all_classrooms) {
classroomList.value = Array.isArray(res.data.all_classrooms) ? res.data.all_classrooms : [];
} else if (Array.isArray(res.data)) {
// 兼容直接返回数组的情况
classroomList.value = res.data;
} else {
classroomList.value = [];
}
} else if (Array.isArray(res)) {
// 兼容直接返回数组的情况
classroomList.value = res;
} else {
classroomList.value = [];
}
} catch (error) {
console.error('获取教室列表失败:', error);
classroomList.value = [];
} finally {
classroomListLoading.value = false;
}
};
onMounted(async () => {
// 先加载下拉列表数据,确保在填充表单时选项已存在
await Promise.all([fetchTeacherList(), fetchClassroomList()]);
// 然后再加载详情数据
if (isEdit.value) {
fetchDetail();
}
});
</script>
<template>
<Page :title="isEdit ? '编辑班级' : '新增班级'">
<Card>
<Form
ref="formRef"
:model="formData"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<Form.Item
label="班级名称"
name="name"
:rules="[{ required: true, message: '请输入班级名称' }]"
>
<Input v-model:value="formData.name" placeholder="请输入班级名称" />
</Form.Item>
<Form.Item
label="年级"
name="grade"
:rules="[{ required: true, message: '请输入年级' }]"
>
<Input v-model:value="formData.grade" placeholder="请输入年级" />
</Form.Item>
<Form.Item
label="班主任"
name="teacher_id"
:rules="[{ required: true, message: '请选择班主任' }]"
>
<Select
v-model:value="formData.teacher_id"
placeholder="请选择班主任"
:loading="teacherListLoading"
allow-clear
show-search
:filter-option="(input, option) => {
const label = option?.label || '';
return label.toLowerCase().includes(input.toLowerCase());
}"
>
<template v-if="teacherList.length === 0 && !teacherListLoading">
<Select.Option disabled value="">暂无班主任数据</Select.Option>
</template>
<Select.Option
v-for="item in teacherList"
:key="item.id"
:value="item.id"
:label="item.name || ''"
>
{{ item.name || `班主任 ${item.id}` }}
</Select.Option>
</Select>
<div v-if="teacherList.length > 0" style="margin-top: 4px; font-size: 12px; color: #999;">
共 {{ teacherList.length }} 个班主任可选
</div>
</Form.Item>
<Form.Item
label="教室"
name="class_room_id"
:rules="[{ required: true, message: '请选择教室' }]"
>
<Select
v-model:value="formData.class_room_id"
placeholder="请选择教室"
:loading="classroomListLoading"
allow-clear
show-search
:filter-option="(input, option) => {
const label = option?.label || '';
return label.toLowerCase().includes(input.toLowerCase());
}"
>
<template v-if="classroomList.length === 0 && !classroomListLoading">
<Select.Option disabled value="">暂无教室数据</Select.Option>
</template>
<Select.Option
v-for="item in classroomList"
:key="item.id"
:value="item.id"
:label="item.name || ''"
>
{{ item.name || `教室 ${item.id}` }}
</Select.Option>
</Select>
<div v-if="classroomList.length > 0" style="margin-top: 4px; font-size: 12px; color: #999;">
共 {{ classroomList.length }} 个教室可选
</div>
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea v-model:value="formData.description" placeholder="请输入描述" :rows="3" />
</Form.Item>
<Form.Item label="状态" name="status">
<Radio.Group v-model:value="formData.status">
<Radio :value="1">启用</Radio>
<Radio :value="0">禁用</Radio>
</Radio.Group>
</Form.Item>
<Form.Item :wrapper-col="{ offset: 4, span: 20 }">
<Button type="primary" :loading="loading" @click="handleSubmit">保存</Button>
<Button class="ml-2" @click="router.back()">取消</Button>
</Form.Item>
</Form>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,203 @@
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Button, Card, Table, Space, message, Modal, Input } from 'ant-design-vue';
import {
getClassListApi,
deleteClassApi,
type ClassApi
} from '#/api';
defineOptions({ name: 'ClassList' });
const router = useRouter();
const loading = ref(false);
const tableData = ref<ClassApi.ClassInfo[]>([]);
const total = ref(0);
const pagination = reactive({
current: 1,
pageSize: 10,
});
const searchForm = reactive({
name: '',
teacher_name: '',
school_id: undefined as number | undefined,
});
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '班级名称',
dataIndex: 'name',
key: 'name',
},
{
title: '年级',
dataIndex: 'grade',
key: 'grade',
width: 100,
},
{
title: '班主任',
key: 'teacher_name',
width: 120,
},
{
title: '教室',
key: 'classroom_name',
width: 150,
},
{
title: '学生人数',
dataIndex: 'student_count',
key: 'student_count',
width: 100,
},
{
title: '操作',
key: 'action',
width: 200,
},
];
const fetchData = async () => {
loading.value = true;
try {
const params: ClassApi.ListParams = {
page: pagination.current,
limit: pagination.pageSize,
...searchForm,
};
const res = await getClassListApi(params);
// 根据实际返回格式code 为 0 表示成功,返回格式为 {code, msg, count, data}
if (res && (res.code === 0 || res.code === 200)) {
// 处理数据,将嵌套的 teacher 和 classroom 信息映射到表格需要的字段
const data = Array.isArray(res.data) ? res.data : [];
tableData.value = data.map((item: ClassApi.ClassInfo) => ({
...item,
// 映射 teacher_name 字段
teacher_name: item.teacher?.name || '',
// 映射 classroom_name 字段
classroom_name: item.classroom?.name || '',
// 确保 classroom_id 字段存在(使用 class_room_id
classroom_id: item.class_room_id || item.classroom?.id,
}));
total.value = res.count || 0;
} else {
tableData.value = [];
total.value = 0;
}
} catch (error) {
console.error('获取班级列表失败:', error);
tableData.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.current = 1;
fetchData();
};
const handleAdd = () => {
router.push({ name: 'ClassDetail' });
};
const handleEdit = (record: ClassApi.ClassInfo) => {
router.push({
name: 'ClassDetail',
params: { id: record.id }
});
};
const handleDelete = (record: ClassApi.ClassInfo) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除班级"${record.name}"吗?`,
onOk: async () => {
try {
await deleteClassApi({ id: record.id! });
message.success('删除成功');
fetchData();
} catch (error) {
console.error('删除失败:', error);
}
},
});
};
const handleTableChange = (pag: any) => {
pagination.current = pag.current;
pagination.pageSize = pag.pageSize;
fetchData();
};
onMounted(() => {
fetchData();
});
</script>
<template>
<Page title="班级列表">
<Card>
<div class="mb-4">
<Space>
<Input
v-model:value="searchForm.name"
placeholder="班级名称"
style="width: 200px"
@press-enter="handleSearch"
/>
<Input
v-model:value="searchForm.teacher_name"
placeholder="班主任姓名"
style="width: 200px"
@press-enter="handleSearch"
/>
<Button type="primary" @click="handleSearch">搜索</Button>
<Button type="primary" @click="handleAdd">添加班级</Button>
</Space>
</div>
<Table
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total: total,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
}"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'teacher_name'">
{{ record.teacher?.name || '-' }}
</template>
<template v-else-if="column.key === 'classroom_name'">
{{ record.classroom?.name || '-' }}
</template>
<template v-else-if="column.key === 'action'">
<Space>
<Button type="link" size="small" @click="handleEdit(record)">编辑</Button>
<Button type="link" danger size="small" @click="handleDelete(record)">删除</Button>
</Space>
</template>
</template>
</Table>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,289 @@
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Card, Form, Input, Select, InputNumber, Button, message, Tabs, Radio } from 'ant-design-vue';
import {
getClassroomDetailApi,
saveClassroomApi,
getSchoolListApi,
getTeacherListApi,
type ClassroomApi,
type SchoolApi,
type TeacherApi
} from '#/api';
import SeatLayoutEditor from '#/components/classroom/SeatLayoutEditor.vue';
defineOptions({ name: 'ClassroomDetail' });
const route = useRoute();
const router = useRouter();
const formRef = ref();
const loading = ref(false);
const activeTab = ref('basic');
const schoolList = ref<SchoolApi.SchoolInfo[]>([]);
const teacherList = ref<TeacherApi.TeacherInfo[]>([]);
const schoolListLoading = ref(false);
const teacherListLoading = ref(false);
const formData = ref<ClassroomApi.SaveParams>({
name: '',
building: '',
floor: '',
room: '',
type: 'normal',
capacity: 0,
school_id: undefined,
teacher_id: undefined,
description: '',
status: 1,
});
const layout = ref<ClassroomApi.ClassroomLayout | null>(null);
const isEdit = computed(() => !!route.params.id);
const classroomId = computed(() => (isEdit.value ? Number(route.params.id) : 0));
const handleSubmit = async () => {
try {
await formRef.value.validate();
loading.value = true;
const data: any = { ...formData.value };
if (isEdit.value) {
data.id = Number(route.params.id);
}
// 将 building, floor, room 组合成 location 对象
if (data.building || data.floor || data.room) {
data.location = {
building: data.building || '',
floor: data.floor || '',
room: data.room || '',
};
// 删除单独的字段,避免重复
delete data.building;
delete data.floor;
delete data.room;
}
await saveClassroomApi(data);
message.success(isEdit.value ? '更新成功' : '创建成功');
router.back();
} catch (error: any) {
console.error('保存失败:', error);
message.error(error?.response?.data?.message || error?.response?.data?.msg || '保存失败');
} finally {
loading.value = false;
}
};
const fetchDetail = async () => {
if (!isEdit.value) return;
loading.value = true;
try {
const res = await getClassroomDetailApi({ id: Number(route.params.id) });
// 支持 code 为 0 或 200 的成功响应
if (res && (res.code === 0 || res.code === 200)) {
// 根据实际返回格式,数据在 res.data 中
const data = res.data || res;
// 解析 location 对象
const location = data.location || {};
formData.value = {
name: data.name || '',
building: location.building || data.building || '',
floor: location.floor || data.floor || '',
room: location.room || data.room || '',
type: data.type || 'normal',
capacity: data.capacity || 0,
school_id: data.school_id,
teacher_id: data.teacher_id,
description: data.description || '',
status: data.status !== undefined ? data.status : 1,
};
// 加载布局数据
if (data.layout) {
layout.value = data.layout;
}
} else {
message.error(res?.message || res?.msg || '获取教室详情失败');
}
} catch (error: any) {
console.error('获取详情失败:', error);
message.error(error?.response?.data?.message || error?.response?.data?.msg || '获取教室详情失败');
} finally {
loading.value = false;
}
};
// 获取学校列表
const fetchSchoolList = async () => {
schoolListLoading.value = true;
try {
const res = await getSchoolListApi({ page: 1, limit: 1000 });
if (res && (res.code === 0 || res.code === 200)) {
schoolList.value = Array.isArray(res.data) ? res.data : [];
} else {
schoolList.value = [];
}
} catch (error) {
console.error('获取学校列表失败:', error);
schoolList.value = [];
} finally {
schoolListLoading.value = false;
}
};
// 获取教师列表
const fetchTeacherList = async () => {
teacherListLoading.value = true;
try {
const res = await getTeacherListApi({ page: 1, limit: 1000 });
if (res && (res.code === 0 || res.code === 200)) {
teacherList.value = Array.isArray(res.data) ? res.data : [];
} else {
teacherList.value = [];
}
} catch (error) {
console.error('获取教师列表失败:', error);
teacherList.value = [];
} finally {
teacherListLoading.value = false;
}
};
const handleLayoutSaved = (newLayout: ClassroomApi.ClassroomLayout) => {
layout.value = newLayout;
// 更新容量(座位数量)
const seatCount = newLayout.cells.filter((cell) => cell.type === 'seat').length;
if (seatCount > 0) {
formData.value.capacity = seatCount;
}
};
onMounted(() => {
fetchSchoolList();
fetchTeacherList();
if (isEdit.value) {
fetchDetail();
}
});
</script>
<template>
<Page :title="isEdit ? '编辑教室' : '新增教室'">
<Card>
<Tabs v-model:activeKey="activeTab">
<Tabs.TabPane key="basic" tab="基本信息">
<Form
ref="formRef"
:model="formData"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<Form.Item
label="教室名称"
name="name"
:rules="[{ required: true, message: '请输入教室名称' }]"
>
<Input v-model:value="formData.name" placeholder="请输入教室名称" />
</Form.Item>
<Form.Item
label="楼栋"
name="building"
:rules="[{ required: true, message: '请输入楼栋' }]"
>
<Input v-model:value="formData.building" placeholder="请输入楼栋" />
</Form.Item>
<Form.Item label="楼层" name="floor">
<Input v-model:value="formData.floor" placeholder="请输入楼层" />
</Form.Item>
<Form.Item label="房间号" name="room">
<Input v-model:value="formData.room" placeholder="请输入房间号" />
</Form.Item>
<Form.Item
label="教室类型"
name="type"
:rules="[{ required: true, message: '请选择教室类型' }]"
>
<Select v-model:value="formData.type" placeholder="请选择教室类型">
<Select.Option value="normal">普通教室</Select.Option>
<Select.Option value="multimedia">多媒体教室</Select.Option>
<Select.Option value="lab">实验室</Select.Option>
</Select>
</Form.Item>
<Form.Item label="容量" name="capacity">
<InputNumber v-model:value="formData.capacity" :min="0" placeholder="请输入容量" />
</Form.Item>
<Form.Item label="学校" name="school_id">
<Select
v-model:value="formData.school_id"
placeholder="请选择学校"
:loading="schoolListLoading"
allow-clear
show-search
:filter-option="(input, option) => {
const label = option?.label || '';
return label.toLowerCase().includes(input.toLowerCase());
}"
>
<template v-if="schoolList.length === 0 && !schoolListLoading">
<Select.Option disabled value="">暂无学校数据</Select.Option>
</template>
<Select.Option
v-for="item in schoolList"
:key="item.id"
:value="item.id"
:label="item.name || ''"
>
{{ item.name || `学校 ${item.id}` }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="负责教师" name="teacher_id">
<Select
v-model:value="formData.teacher_id"
placeholder="请选择负责教师"
:loading="teacherListLoading"
allow-clear
show-search
:filter-option="(input, option) => {
const label = option?.label || '';
return label.toLowerCase().includes(input.toLowerCase());
}"
>
<template v-if="teacherList.length === 0 && !teacherListLoading">
<Select.Option disabled value="">暂无教师数据</Select.Option>
</template>
<Select.Option
v-for="item in teacherList"
:key="item.id"
:value="item.id"
:label="item.name || ''"
>
{{ item.name || `教师 ${item.id}` }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea v-model:value="formData.description" placeholder="请输入描述" :rows="3" />
</Form.Item>
<Form.Item label="状态" name="status">
<Radio.Group v-model:value="formData.status">
<Radio :value="1">启用</Radio>
<Radio :value="0">禁用</Radio>
</Radio.Group>
</Form.Item>
<Form.Item :wrapper-col="{ offset: 4, span: 20 }">
<Button type="primary" :loading="loading" @click="handleSubmit">保存</Button>
<Button class="ml-2" @click="router.back()">取消</Button>
</Form.Item>
</Form>
</Tabs.TabPane>
<Tabs.TabPane v-if="isEdit" key="layout" tab="座位布局">
<SeatLayoutEditor
:classroom-id="classroomId"
:layout="layout"
@saved="handleLayoutSaved"
/>
</Tabs.TabPane>
</Tabs>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,258 @@
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Button, Card, Table, Space, message, Modal, Input, Select } from 'ant-design-vue';
import {
getClassroomListApi,
deleteClassroomApi,
batchDeleteClassroomApi,
type ClassroomApi
} from '#/api';
import { $t } from '#/locales';
defineOptions({ name: 'ClassroomList' });
const router = useRouter();
const loading = ref(false);
const tableData = ref<ClassroomApi.ClassroomInfo[]>([]);
const total = ref(0);
const pagination = reactive({
current: 1,
pageSize: 10,
});
const searchForm = reactive({
name: '',
building: '',
type: undefined as 'normal' | 'multimedia' | 'lab' | undefined,
school_id: undefined as number | undefined,
});
const selectedRowKeys = ref<number[]>([]);
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '教室名称',
dataIndex: 'name',
key: 'name',
},
{
title: '楼栋',
dataIndex: 'building',
key: 'building',
customRender: ({ record }: { record: ClassroomApi.ClassroomInfo }) => {
return record.location?.building || record.building || '-';
},
},
{
title: '楼层',
dataIndex: 'floor',
key: 'floor',
customRender: ({ record }: { record: ClassroomApi.ClassroomInfo }) => {
return record.location?.floor || record.floor || '-';
},
},
{
title: '房间号',
dataIndex: 'room',
key: 'room',
customRender: ({ record }: { record: ClassroomApi.ClassroomInfo }) => {
return record.location?.room || '-';
},
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
customRender: ({ text }: { text: string }) => {
const typeMap: Record<string, string> = {
normal: '普通教室',
multimedia: '多媒体教室',
lab: '实验室',
};
return typeMap[text] || text;
},
},
{
title: '容量',
dataIndex: 'capacity',
key: 'capacity',
},
{
title: '操作',
key: 'action',
width: 200,
},
];
const fetchData = async () => {
loading.value = true;
try {
const params: ClassroomApi.ListParams = {
page: pagination.current,
limit: pagination.pageSize,
...searchForm,
};
const res = await getClassroomListApi(params);
// 根据实际返回格式code 为 0 表示成功,返回格式为 {code, msg, count, data}
// 使用 responseReturn: 'body' 获取完整响应体
if (res && (res.code === 0 || res.code === 200)) {
tableData.value = Array.isArray(res.data) ? res.data : [];
total.value = res.count || 0;
} else {
tableData.value = [];
total.value = 0;
}
} catch (error) {
console.error('获取教室列表失败:', error);
tableData.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.current = 1;
fetchData();
};
const handleReset = () => {
searchForm.name = '';
searchForm.building = '';
searchForm.type = undefined;
searchForm.school_id = undefined;
handleSearch();
};
const handleAdd = () => {
router.push({ name: 'ClassroomDetail' });
};
const handleEdit = (record: ClassroomApi.ClassroomInfo) => {
router.push({ name: 'ClassroomDetail', params: { id: record.id } });
};
const handleDelete = (record: ClassroomApi.ClassroomInfo) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除教室"${record.name}"吗?`,
onOk: async () => {
try {
await deleteClassroomApi({ id: record.id! });
message.success('删除成功');
fetchData();
} catch (error) {
console.error('删除失败:', error);
}
},
});
};
const handleBatchDelete = () => {
if (selectedRowKeys.value.length === 0) {
message.warning('请选择要删除的教室');
return;
}
Modal.confirm({
title: '确认批量删除',
content: `确定要删除选中的 ${selectedRowKeys.value.length} 个教室吗?`,
onOk: async () => {
try {
await batchDeleteClassroomApi({ ids: selectedRowKeys.value });
message.success('批量删除成功');
selectedRowKeys.value = [];
fetchData();
} catch (error) {
console.error('批量删除失败:', error);
}
},
});
};
const handleTableChange = (pag: any) => {
pagination.current = pag.current;
pagination.pageSize = pag.pageSize;
fetchData();
};
onMounted(() => {
fetchData();
});
</script>
<template>
<Page title="教室列表">
<Card>
<div class="mb-4">
<Space>
<Input
v-model:value="searchForm.name"
placeholder="教室名称"
style="width: 200px"
@press-enter="handleSearch"
/>
<Input
v-model:value="searchForm.building"
placeholder="楼栋/房间号"
style="width: 200px"
@press-enter="handleSearch"
/>
<Select
v-model:value="searchForm.type"
placeholder="教室类型"
style="width: 150px"
allow-clear
>
<Select.Option value="normal">普通教室</Select.Option>
<Select.Option value="multimedia">多媒体教室</Select.Option>
<Select.Option value="lab">实验室</Select.Option>
</Select>
<Button type="primary" @click="handleSearch">搜索</Button>
<Button @click="handleReset">重置</Button>
<Button type="primary" @click="handleAdd">新增</Button>
<Button danger :disabled="selectedRowKeys.length === 0" @click="handleBatchDelete">
批量删除
</Button>
</Space>
</div>
<Table
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total: total,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
}"
:row-selection="{
selectedRowKeys: selectedRowKeys,
onChange: (keys: number[]) => {
selectedRowKeys = keys;
},
}"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<Space>
<Button type="link" size="small" @click="handleEdit(record)">编辑</Button>
<Button type="link" danger size="small" @click="handleDelete(record)">删除</Button>
</Space>
</template>
</template>
</Table>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,78 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { Page } from '@vben/common-ui';
import { Card, Descriptions, Spin } from 'ant-design-vue';
import { getSystemInfoApi, type IndexApi } from '#/api';
defineOptions({ name: 'Dashboard' });
const loading = ref(false);
const systemInfo = ref<IndexApi.SystemInfo>({});
const fetchSystemInfo = async () => {
loading.value = true;
try {
const res = await getSystemInfoApi();
// 支持 code 为 0 或 200 的成功响应
if (res && (res.code === 0 || res.code === 200)) {
// 根据实际返回格式,数据在 res.data 中
systemInfo.value = res.data || {};
} else {
console.error('获取系统信息失败:', res?.message || res?.msg);
}
} catch (error) {
console.error('获取系统信息失败:', error);
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchSystemInfo();
});
</script>
<template>
<Page title="系统概览">
<Card title="系统信息">
<Spin :spinning="loading">
<Descriptions :column="2" bordered>
<Descriptions.Item label="操作系统">
{{ systemInfo.os || '-' }}
</Descriptions.Item>
<Descriptions.Item label="PHP版本">
{{ systemInfo.php || '-' }}
</Descriptions.Item>
<Descriptions.Item label="Web服务器">
{{ systemInfo.server || '-' }}
</Descriptions.Item>
<Descriptions.Item label="MySQL版本">
{{ systemInfo.mysql || '-' }}
</Descriptions.Item>
<Descriptions.Item label="框架版本">
{{ systemInfo.framework_version || '-' }}
</Descriptions.Item>
<Descriptions.Item label="上传限制">
{{ systemInfo.upload_max || '-' }}
</Descriptions.Item>
<Descriptions.Item label="最大执行时间">
{{ systemInfo.max_execution_time || '-' }}
</Descriptions.Item>
<Descriptions.Item label="运行目录">
{{ systemInfo.runtime_path || '-' }}
</Descriptions.Item>
<Descriptions.Item label="磁盘总空间" :span="2">
{{ systemInfo.disk_total_space || '-' }}
</Descriptions.Item>
<Descriptions.Item label="磁盘可用空间">
{{ systemInfo.disk_free_space || '-' }}
</Descriptions.Item>
<Descriptions.Item label="磁盘使用率">
{{ systemInfo.disk_usage || '-' }}
</Descriptions.Item>
</Descriptions>
</Spin>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,317 @@
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Card, Form, Input, Button, message, Tabs, Table, Space, Modal, Radio } from 'ant-design-vue';
import {
getSchoolDetailApi,
saveSchoolApi,
getSchoolAccountListApi,
saveSchoolAccountApi,
deleteSchoolAccountApi,
type SchoolApi
} from '#/api';
defineOptions({ name: 'SchoolDetail' });
const route = useRoute();
const router = useRouter();
const formRef = ref();
const accountFormRef = ref();
const loading = ref(false);
const accountLoading = ref(false);
const activeTab = ref('basic');
const formData = ref<Partial<SchoolApi.SaveParams>>({
name: '',
address: '',
contact: '',
phone: '',
description: '',
});
// 账号管理相关
const accountList = ref<SchoolApi.AccountInfo[]>([]);
const accountFormData = ref<Partial<SchoolApi.SaveAccountParams>>({
username: '',
password: '',
status: 1,
});
const accountModalVisible = ref(false);
const isAccountEdit = ref(false);
const isEdit = computed(() => !!route.params.id);
const schoolId = computed(() => isEdit.value ? Number(route.params.id) : 0);
const handleSubmit = async () => {
try {
await formRef.value.validate();
loading.value = true;
const data = { ...formData.value };
if (isEdit.value) {
data.id = Number(route.params.id);
}
await saveSchoolApi(data);
message.success(isEdit.value ? '更新成功' : '创建成功');
router.back();
} catch (error: any) {
console.error('保存失败:', error);
message.error(error?.response?.data?.message || error?.response?.data?.msg || '保存失败');
} finally {
loading.value = false;
}
};
const fetchDetail = async () => {
if (!isEdit.value) return;
loading.value = true;
try {
const res = await getSchoolDetailApi({ id: Number(route.params.id) });
// 支持 code 为 0 或 200 的成功响应
if (res && (res.code === 0 || res.code === 200)) {
// 根据实际返回格式,数据在 res.data 中
const data = res.data;
if (data) {
formData.value = {
name: data.name || '',
address: data.address || '',
contact: data.contact || '',
phone: data.phone || '',
description: data.description || '',
};
} else {
message.error('获取学校详情失败:数据为空');
}
} else {
message.error(res?.message || res?.msg || '获取学校详情失败');
}
} catch (error: any) {
console.error('获取详情失败:', error);
message.error(error?.response?.data?.message || error?.response?.data?.msg || '获取学校详情失败');
} finally {
loading.value = false;
}
};
// 账号管理相关方法
const accountColumns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '用户名',
dataIndex: 'username',
key: 'username',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
},
{
title: '操作',
key: 'action',
width: 200,
},
];
const fetchAccountList = async () => {
if (!isEdit.value) return;
accountLoading.value = true;
try {
const res = await getSchoolAccountListApi({ school_id: schoolId.value });
// 支持 code 为 0 或 200 的成功响应
if (res && (res.code === 0 || res.code === 200)) {
// 根据实际返回格式,数据在 res.data 数组中
const data = Array.isArray(res.data) ? res.data : [];
accountList.value = data;
} else {
accountList.value = [];
}
} catch (error) {
console.error('获取账号列表失败:', error);
accountList.value = [];
} finally {
accountLoading.value = false;
}
};
const handleAddAccount = () => {
isAccountEdit.value = false;
accountFormData.value = {
username: '',
password: '',
status: 1,
};
accountModalVisible.value = true;
};
const handleEditAccount = (record: SchoolApi.AccountInfo) => {
isAccountEdit.value = true;
accountFormData.value = {
id: record.id,
username: record.username || '',
password: '', // 编辑时不显示密码
status: record.status !== undefined ? record.status : 1,
};
accountModalVisible.value = true;
};
const handleDeleteAccount = (record: SchoolApi.AccountInfo) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除账号"${record.username}"吗?`,
onOk: async () => {
try {
await deleteSchoolAccountApi({ id: record.id! });
message.success('删除成功');
fetchAccountList();
} catch (error) {
console.error('删除失败:', error);
}
},
});
};
const handleAccountSubmit = async () => {
try {
await accountFormRef.value.validate();
accountLoading.value = true;
const data = {
...accountFormData.value,
school_id: schoolId.value,
};
await saveSchoolAccountApi(data);
message.success(isAccountEdit.value ? '更新成功' : '创建成功');
accountModalVisible.value = false;
fetchAccountList();
} catch (error: any) {
console.error('保存账号失败:', error);
message.error(error?.response?.data?.message || error?.response?.data?.msg || '保存账号失败');
} finally {
accountLoading.value = false;
}
};
onMounted(() => {
if (isEdit.value) {
fetchDetail();
fetchAccountList();
}
});
</script>
<template>
<Page :title="isEdit ? '编辑学校' : '新增学校'">
<Card>
<Tabs v-model:activeKey="activeTab">
<Tabs.TabPane key="basic" tab="基本信息">
<Form
ref="formRef"
:model="formData"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<Form.Item
label="学校名称"
name="name"
:rules="[{ required: true, message: '请输入学校名称' }]"
>
<Input v-model:value="formData.name" placeholder="请输入学校名称" />
</Form.Item>
<Form.Item label="地址" name="address">
<Input v-model:value="formData.address" placeholder="请输入地址" />
</Form.Item>
<Form.Item label="联系人" name="contact">
<Input v-model:value="formData.contact" placeholder="请输入联系人" />
</Form.Item>
<Form.Item label="联系电话" name="phone">
<Input v-model:value="formData.phone" placeholder="请输入联系电话" />
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea v-model:value="formData.description" placeholder="请输入描述" :rows="3" />
</Form.Item>
<Form.Item :wrapper-col="{ offset: 4, span: 20 }">
<Button type="primary" :loading="loading" @click="handleSubmit">保存</Button>
<Button class="ml-2" @click="router.back()">取消</Button>
</Form.Item>
</Form>
</Tabs.TabPane>
<Tabs.TabPane key="account" tab="账号管理" :disabled="!isEdit">
<div class="mb-4">
<Button type="primary" @click="handleAddAccount">添加账号</Button>
</div>
<Table
:columns="accountColumns"
:data-source="accountList"
:loading="accountLoading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
{{ record.status === 1 ? '启用' : '禁用' }}
</template>
<template v-else-if="column.key === 'action'">
<Space>
<Button type="link" size="small" @click="handleEditAccount(record)">编辑</Button>
<Button type="link" danger size="small" @click="handleDeleteAccount(record)">删除</Button>
</Space>
</template>
</template>
</Table>
</Tabs.TabPane>
</Tabs>
</Card>
<!-- 账号编辑弹窗 -->
<Modal
v-model:open="accountModalVisible"
:title="isAccountEdit ? '编辑账号' : '新增账号'"
:confirm-loading="accountLoading"
@ok="handleAccountSubmit"
@cancel="accountModalVisible = false"
>
<Form
ref="accountFormRef"
:model="accountFormData"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<Form.Item
label="用户名"
name="username"
:rules="[{ required: true, message: '请输入用户名' }]"
>
<Input
v-model:value="accountFormData.username"
placeholder="请输入用户名"
:disabled="isAccountEdit"
/>
</Form.Item>
<Form.Item
label="密码"
name="password"
:rules="[{ required: !isAccountEdit, message: '请输入密码' }]"
>
<Input.Password
v-model:value="accountFormData.password"
placeholder="请输入密码"
/>
<div v-if="isAccountEdit" style="margin-top: 4px; font-size: 12px; color: #999;">
留空则不修改密码
</div>
</Form.Item>
<Form.Item label="状态" name="status">
<Radio.Group v-model:value="accountFormData.status">
<Radio :value="1">启用</Radio>
<Radio :value="0">禁用</Radio>
</Radio.Group>
</Form.Item>
</Form>
</Modal>
</Page>
</template>

View File

@@ -0,0 +1,183 @@
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Button, Card, Table, Space, message, Modal, Input } from 'ant-design-vue';
import {
getSchoolListApi,
deleteSchoolApi,
type SchoolApi
} from '#/api';
defineOptions({ name: 'SchoolList' });
const router = useRouter();
const loading = ref(false);
const tableData = ref<SchoolApi.SchoolInfo[]>([]);
const total = ref(0);
const pagination = reactive({
current: 1,
pageSize: 10,
});
const searchForm = reactive({
name: '',
});
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '学校名称',
dataIndex: 'name',
key: 'name',
width: 150,
},
{
title: '地址',
dataIndex: 'address',
key: 'address',
width: 250,
},
{
title: '联系人',
dataIndex: 'contact',
key: 'contact',
width: 120,
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
width: 130,
},
{
title: '操作',
key: 'action',
width: 200,
},
];
const fetchData = async () => {
loading.value = true;
try {
const params: SchoolApi.ListParams = {
page: pagination.current,
limit: pagination.pageSize,
...searchForm,
};
const res = await getSchoolListApi(params);
// 根据实际返回格式code 为 0 表示成功,返回格式为 {code, msg, count, data}
if (res && (res.code === 0 || res.code === 200)) {
const data = Array.isArray(res.data) ? res.data : [];
tableData.value = data;
total.value = res.count || 0;
} else {
tableData.value = [];
total.value = 0;
}
} catch (error) {
console.error('获取学校列表失败:', error);
tableData.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.current = 1;
fetchData();
};
const handleEdit = (record: SchoolApi.SchoolInfo) => {
router.push({
name: 'SchoolDetail',
params: { id: record.id }
});
};
const handleDelete = (record: SchoolApi.SchoolInfo) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除学校"${record.name}"吗?`,
onOk: async () => {
try {
await deleteSchoolApi({ id: record.id! });
message.success('删除成功');
fetchData();
} catch (error) {
console.error('删除失败:', error);
}
},
});
};
const handleTableChange = (pag: any) => {
pagination.current = pag.current;
pagination.pageSize = pag.pageSize;
fetchData();
};
onMounted(() => {
fetchData();
});
</script>
<template>
<Page title="学校列表">
<Card>
<div class="mb-4">
<Space>
<Input
v-model:value="searchForm.name"
placeholder="学校名称"
style="width: 200px"
@press-enter="handleSearch"
/>
<Button type="primary" @click="handleSearch">搜索</Button>
<Button type="primary" @click="router.push({ name: 'SchoolDetail' })">新增</Button>
</Space>
</div>
<Table
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total: total,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
}"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'address'">
<span :title="record.address || '-'">
{{ record.address || '-' }}
</span>
</template>
<template v-else-if="column.key === 'contact'">
{{ record.contact || '-' }}
</template>
<template v-else-if="column.key === 'phone'">
{{ record.phone || '-' }}
</template>
<template v-else-if="column.key === 'action'">
<Space>
<Button type="link" size="small" @click="handleEdit(record)">编辑</Button>
<Button type="link" danger size="small" @click="handleDelete(record)">删除</Button>
</Space>
</template>
</template>
</Table>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,170 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { Page } from '@vben/common-ui';
import { Card, Form, Input, Button, message, Tabs, InputNumber } from 'ant-design-vue';
import {
getSettingApi,
saveSettingApi,
type SettingApi
} from '#/api';
defineOptions({ name: 'SettingIndex' });
const formRef = ref();
const loading = ref(false);
const activeTab = ref('basic');
const formData = ref<Partial<SettingApi.SettingInfo>>({
site_name: '',
site_desc: '',
site_icp: '',
default_avatar: '',
upload_allowed_ext: '',
upload_max_size: '',
upload_path: '',
upload_image_size: '',
upload_image_ext: '',
mail_host: '',
mail_port: '',
mail_username: '',
mail_password: '',
wxapp_appid: '',
wxapp_secret: '',
wxapp_name: '',
wxapp_original: '',
});
const fetchData = async () => {
loading.value = true;
try {
const res = await getSettingApi();
// 支持 code 为 0 或 200 的成功响应
if (res && (res.code === 0 || res.code === 200)) {
// 根据实际返回格式,数据在 res.data 中
const data = res.data || res;
formData.value = {
site_name: data.site_name || '',
site_desc: data.site_desc || '',
site_icp: data.site_icp || '',
site_banners: data.site_banners || '',
default_avatar: data.default_avatar || '',
upload_allowed_ext: data.upload_allowed_ext || '',
upload_max_size: data.upload_max_size || '',
upload_path: data.upload_path || '',
upload_image_size: data.upload_image_size || '',
upload_image_ext: data.upload_image_ext || '',
mail_host: data.mail_host || '',
mail_port: data.mail_port || '',
mail_username: data.mail_username || '',
mail_password: data.mail_password || '',
wxapp_appid: data.wxapp_appid || '',
wxapp_secret: data.wxapp_secret || '',
wxapp_name: data.wxapp_name || '',
wxapp_original: data.wxapp_original || '',
};
} else {
message.error(res?.message || res?.msg || '获取系统设置失败');
}
} catch (error: any) {
console.error('获取系统设置失败:', error);
message.error(error?.response?.data?.message || error?.response?.data?.msg || '获取系统设置失败');
} finally {
loading.value = false;
}
};
const handleSubmit = async () => {
try {
await formRef.value.validate();
loading.value = true;
await saveSettingApi(formData.value);
message.success('保存成功');
} catch (error) {
console.error('保存失败:', error);
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchData();
});
</script>
<template>
<Page title="系统设置">
<Card>
<Form
ref="formRef"
:model="formData"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<Tabs v-model:activeKey="activeTab">
<Tabs.TabPane key="basic" tab="基本信息">
<Form.Item label="站点名称" name="site_name">
<Input v-model:value="formData.site_name" placeholder="请输入站点名称" />
</Form.Item>
<Form.Item label="站点描述" name="site_desc">
<Input v-model:value="formData.site_desc" placeholder="请输入站点描述" />
</Form.Item>
<Form.Item label="ICP备案号" name="site_icp">
<Input v-model:value="formData.site_icp" placeholder="请输入ICP备案号" />
</Form.Item>
<Form.Item label="默认头像" name="default_avatar">
<Input v-model:value="formData.default_avatar" placeholder="默认头像路径" />
</Form.Item>
</Tabs.TabPane>
<Tabs.TabPane key="upload" tab="上传设置">
<Form.Item label="上传路径" name="upload_path">
<Input v-model:value="formData.upload_path" placeholder="请输入上传路径" />
</Form.Item>
<Form.Item label="允许上传扩展名" name="upload_allowed_ext">
<Input v-model:value="formData.upload_allowed_ext" placeholder="如jpg,png" />
</Form.Item>
<Form.Item label="上传最大大小(MB)" name="upload_max_size">
<InputNumber v-model:value="formData.upload_max_size" :min="0" placeholder="请输入上传最大大小" style="width: 100%" />
</Form.Item>
<Form.Item label="图片扩展名" name="upload_image_ext">
<Input v-model:value="formData.upload_image_ext" placeholder="如jpg,jpeg,png,gif,webp" />
</Form.Item>
<Form.Item label="图片大小限制(MB)" name="upload_image_size">
<InputNumber v-model:value="formData.upload_image_size" :min="0" placeholder="请输入图片大小限制" style="width: 100%" />
</Form.Item>
</Tabs.TabPane>
<Tabs.TabPane key="mail" tab="邮件设置">
<Form.Item label="邮件主机" name="mail_host">
<Input v-model:value="formData.mail_host" placeholder="请输入邮件主机" />
</Form.Item>
<Form.Item label="邮件端口" name="mail_port">
<InputNumber v-model:value="formData.mail_port" :min="0" :max="65535" placeholder="请输入邮件端口" style="width: 100%" />
</Form.Item>
<Form.Item label="邮件用户名" name="mail_username">
<Input v-model:value="formData.mail_username" placeholder="请输入邮件用户名" />
</Form.Item>
<Form.Item label="邮件密码" name="mail_password">
<Input.Password v-model:value="formData.mail_password" placeholder="请输入邮件密码" />
</Form.Item>
</Tabs.TabPane>
<Tabs.TabPane key="wxapp" tab="微信小程序">
<Form.Item label="小程序AppID" name="wxapp_appid">
<Input v-model:value="formData.wxapp_appid" placeholder="请输入小程序AppID" />
</Form.Item>
<Form.Item label="小程序Secret" name="wxapp_secret">
<Input.Password v-model:value="formData.wxapp_secret" placeholder="请输入小程序Secret" />
</Form.Item>
<Form.Item label="小程序名称" name="wxapp_name">
<Input v-model:value="formData.wxapp_name" placeholder="请输入小程序名称" />
</Form.Item>
<Form.Item label="原始ID" name="wxapp_original">
<Input v-model:value="formData.wxapp_original" placeholder="请输入原始ID" />
</Form.Item>
</Tabs.TabPane>
</Tabs>
<Form.Item :wrapper-col="{ offset: 4, span: 20 }">
<Button type="primary" :loading="loading" @click="handleSubmit">保存</Button>
</Form.Item>
</Form>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,201 @@
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Card, Form, Input, Button, message, Select, Radio } from 'ant-design-vue';
import {
getStudentDetailApi,
saveStudentApi,
type StudentApi
} from '#/api';
import { getClassListApi, type ClassApi } from '#/api';
defineOptions({ name: 'StudentDetail' });
const route = useRoute();
const router = useRouter();
const formRef = ref();
const loading = ref(false);
const classList = ref<ClassApi.ClassInfo[]>([]);
const classListLoading = ref(false);
const formData = ref<Partial<StudentApi.SaveParams>>({
student_id: '',
name: '',
gender: undefined,
class_id: undefined,
phone: '',
email: '',
avatar: null,
remark: '',
status: 1,
});
const isEdit = computed(() => !!route.params.id);
const handleSubmit = async () => {
try {
await formRef.value.validate();
loading.value = true;
const data = { ...formData.value };
if (isEdit.value) {
data.id = Number(route.params.id);
}
await saveStudentApi(data);
message.success(isEdit.value ? '更新成功' : '创建成功');
router.back();
} catch (error: any) {
console.error('保存失败:', error);
message.error(error?.response?.data?.message || error?.response?.data?.msg || '保存失败');
} finally {
loading.value = false;
}
};
const fetchDetail = async () => {
if (!isEdit.value) return;
loading.value = true;
try {
const res = await getStudentDetailApi({ id: Number(route.params.id) });
// 支持 code 为 0 或 200 的成功响应
if (res && (res.code === 0 || res.code === 200)) {
// 根据实际返回格式,数据在 res.data 中
const data = res.data || res;
formData.value = {
student_id: (data.student_id || data.id?.toString() || '') as string,
name: data.name || '',
class_id: data.class_id,
status: data.status !== undefined ? data.status : 1,
gender: data.gender || undefined,
phone: data.phone || '',
email: data.email || '',
avatar: data.avatar || null,
remark: data.remark || '',
};
} else {
message.error(res?.message || res?.msg || '获取学生详情失败');
}
} catch (error: any) {
console.error('获取详情失败:', error);
message.error(error?.response?.data?.message || error?.response?.data?.msg || '获取学生详情失败');
} finally {
loading.value = false;
}
};
// 获取班级列表
const fetchClassList = async () => {
classListLoading.value = true;
try {
const res = await getClassListApi({ page: 1, limit: 1000 });
if (res && (res.code === 0 || res.code === 200)) {
// 根据实际返回格式data 是数组
const data = res.data;
if (Array.isArray(data)) {
classList.value = data;
} else {
classList.value = [];
}
} else {
classList.value = [];
}
} catch (error) {
console.error('获取班级列表失败:', error);
classList.value = [];
} finally {
classListLoading.value = false;
}
};
onMounted(() => {
fetchClassList();
if (isEdit.value) {
fetchDetail();
}
});
</script>
<template>
<Page :title="isEdit ? '编辑学生' : '新增学生'">
<Card>
<Form
ref="formRef"
:model="formData"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<Form.Item
label="学员ID"
name="student_id"
:rules="[{ required: true, message: '请输入学员ID' }]"
>
<Input
v-model:value="formData.student_id as string"
:placeholder="isEdit ? '学员ID' : '请输入学员ID'"
:disabled="isEdit"
/>
</Form.Item>
<Form.Item
label="姓名"
name="name"
:rules="[{ required: true, message: '请输入姓名' }]"
>
<Input v-model:value="formData.name" placeholder="请输入姓名" />
</Form.Item>
<Form.Item label="性别" name="gender">
<Radio.Group v-model:value="formData.gender">
<Radio value="男"></Radio>
<Radio value="女"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item label="电话" name="phone">
<Input v-model:value="formData.phone" placeholder="请输入电话" />
</Form.Item>
<Form.Item label="邮箱" name="email">
<Input v-model:value="formData.email" placeholder="请输入邮箱" />
</Form.Item>
<Form.Item label="班级" name="class_id">
<Select
v-model:value="formData.class_id"
placeholder="请选择班级"
:loading="classListLoading"
allow-clear
show-search
:filter-option="(input, option) => {
const label = option?.label || '';
return label.toLowerCase().includes(input.toLowerCase());
}"
>
<template v-if="classList.length === 0 && !classListLoading">
<Select.Option disabled value="">暂无班级数据</Select.Option>
</template>
<Select.Option
v-for="item in classList"
:key="item.id"
:value="item.id"
:label="item.name || ''"
>
{{ item.name || `班级 ${item.id}` }}
</Select.Option>
</Select>
<div v-if="classList.length > 0" style="margin-top: 4px; font-size: 12px; color: #999;">
共 {{ classList.length }} 个班级可选
</div>
</Form.Item>
<Form.Item label="备注" name="remark">
<Input.TextArea v-model:value="formData.remark" placeholder="请输入备注" :rows="3" />
</Form.Item>
<Form.Item label="状态" name="status">
<Radio.Group v-model:value="formData.status">
<Radio :value="1">启用</Radio>
<Radio :value="0">禁用</Radio>
</Radio.Group>
</Form.Item>
<Form.Item :wrapper-col="{ offset: 4, span: 20 }">
<Button type="primary" :loading="loading" @click="handleSubmit">保存</Button>
<Button class="ml-2" @click="router.back()">取消</Button>
</Form.Item>
</Form>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,496 @@
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Button, Card, Table, Space, message, Modal, Input, Select, Tag, Upload } from 'ant-design-vue';
import { IconifyIcon } from '@vben/icons';
import {
getStudentListApi,
deleteStudentApi,
uploadStudentFileApi,
importStudentDataApi,
type StudentApi
} from '#/api';
import { getClassListApi, type ClassApi } from '#/api';
defineOptions({ name: 'StudentList' });
const router = useRouter();
const loading = ref(false);
const tableData = ref<StudentApi.StudentInfo[]>([]);
const total = ref(0);
const pagination = reactive({
current: 1,
pageSize: 10,
});
const searchForm = reactive({
student_id: '',
name: '',
phone: '',
class_id: undefined as number | undefined,
school_id: undefined as number | undefined,
bind_status: undefined as string | undefined,
});
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
width: 120,
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
width: 80,
},
{
title: '电话',
dataIndex: 'phone',
key: 'phone',
width: 130,
},
{
title: '学校',
dataIndex: 'school_name',
key: 'school_name',
width: 150,
},
{
title: '班级',
dataIndex: 'class_name',
key: 'class_name',
width: 200,
},
{
title: '绑定状态',
dataIndex: 'bind_status',
key: 'bind_status',
width: 100,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
},
{
title: '创建时间',
dataIndex: 'create_time',
key: 'create_time',
width: 180,
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right' as const,
},
];
const fetchData = async () => {
loading.value = true;
try {
const params: StudentApi.ListParams = {
page: pagination.current,
limit: pagination.pageSize,
...searchForm,
};
const res = await getStudentListApi(params);
console.log('学生列表API返回:', res); // 调试用
// 根据实际返回格式code 为 0 表示成功
if (res && (res.code === 0 || res.code === 200)) {
const data = res.data;
console.log('学生列表数据:', data); // 调试用
console.log('学生列表总数:', res.count); // 调试用
if (Array.isArray(data)) {
tableData.value = data;
total.value = res.count || data.length;
console.log('设置学生列表,数量:', tableData.value.length, '总数:', total.value); // 调试用
} else {
console.warn('学生列表数据不是数组:', data);
tableData.value = [];
total.value = 0;
}
} else {
console.warn('获取学生列表失败,响应码:', res?.code, res);
tableData.value = [];
total.value = 0;
}
} catch (error) {
console.error('获取学生列表失败:', error);
tableData.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.current = 1;
fetchData();
};
const handleReset = () => {
searchForm.student_id = '';
searchForm.name = '';
searchForm.phone = '';
searchForm.class_id = undefined;
searchForm.school_id = undefined;
searchForm.bind_status = undefined;
handleSearch();
};
const handleAdd = () => {
router.push({ name: 'StudentDetail' });
};
const handleEdit = (record: StudentApi.StudentInfo) => {
router.push({ name: 'StudentDetail', params: { id: record.id } });
};
const handleDelete = (record: StudentApi.StudentInfo) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除学生"${record.name}"吗?`,
onOk: async () => {
try {
await deleteStudentApi({ id: record.id! });
message.success('删除成功');
fetchData();
} catch (error) {
console.error('删除失败:', error);
}
},
});
};
const handleTableChange = (pag: any) => {
pagination.current = pag.current;
pagination.pageSize = pag.pageSize;
fetchData();
};
// 导入相关
const importModalVisible = ref(false);
const importLoading = ref(false);
const uploadedFile = ref<string>('');
const selectedClassId = ref<number | undefined>(undefined);
const classList = ref<ClassApi.ClassInfo[]>([]);
const fileList = ref<any[]>([]);
// 获取班级列表
const fetchClassList = async () => {
try {
const res = await getClassListApi({ page: 1, limit: 1000 });
console.log('导入对话框-班级列表API返回:', res); // 调试用
if (res && (res.code === 0 || res.code === 200)) {
// 根据实际返回格式data 是数组
const data = res.data;
console.log('导入对话框-班级列表数据:', data); // 调试用
if (Array.isArray(data)) {
classList.value = data;
console.log('导入对话框-设置班级列表,数量:', classList.value.length); // 调试用
} else {
console.warn('导入对话框-班级列表数据不是数组:', data);
classList.value = [];
}
} else {
console.warn('导入对话框-获取班级列表失败:', res);
classList.value = [];
}
} catch (error) {
console.error('导入对话框-获取班级列表失败:', error);
classList.value = [];
}
};
// 打开导入对话框
const handleImport = () => {
importModalVisible.value = true;
uploadedFile.value = '';
selectedClassId.value = undefined;
fileList.value = [];
fetchClassList();
};
// 文件上传前验证
const beforeUpload = (file: File) => {
const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|| file.type === 'application/vnd.ms-excel'
|| file.name.endsWith('.xlsx')
|| file.name.endsWith('.xls');
if (!isExcel) {
message.error('只能上传 Excel 文件(.xlsx 或 .xls');
return false;
}
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isLt10M) {
message.error('文件大小不能超过 10MB');
return false;
}
return false; // 阻止自动上传
};
// 文件变化处理
const handleFileChange = async (info: any) => {
const { file, fileList: newFileList } = info;
fileList.value = newFileList;
// 文件被移除
if (file.status === 'removed') {
uploadedFile.value = '';
return;
}
// 文件选择后,手动上传
if (file.status === 'ready' && file.originFileObj) {
importLoading.value = true;
try {
const res = await uploadStudentFileApi(file.originFileObj);
// 根据API上传接口返回文件路径
if (res && (res.code === 0 || res.code === 200)) {
uploadedFile.value = res.data || res.file || '';
message.success('文件上传成功');
// 更新文件状态为已完成
fileList.value = [{
uid: file.uid,
name: file.name,
status: 'done',
}];
} else {
message.error(res?.msg || '文件上传失败');
fileList.value = [];
uploadedFile.value = '';
}
} catch (error: any) {
console.error('文件上传失败:', error);
message.error(error?.response?.data?.msg || '文件上传失败');
fileList.value = [];
uploadedFile.value = '';
} finally {
importLoading.value = false;
}
}
};
// 确认导入
const handleConfirmImport = async () => {
if (!uploadedFile.value) {
message.warning('请先上传 Excel 文件');
return;
}
if (!selectedClassId.value) {
message.warning('请选择班级');
return;
}
importLoading.value = true;
try {
await importStudentDataApi({
class_id: selectedClassId.value,
file: uploadedFile.value,
});
message.success('学生数据导入成功');
importModalVisible.value = false;
uploadedFile.value = '';
selectedClassId.value = undefined;
fileList.value = [];
fetchData(); // 刷新列表
} catch (error) {
console.error('导入失败:', error);
message.error('学生数据导入失败');
} finally {
importLoading.value = false;
}
};
// 取消导入
const handleCancelImport = () => {
importModalVisible.value = false;
uploadedFile.value = '';
selectedClassId.value = undefined;
fileList.value = [];
};
onMounted(() => {
fetchData();
});
</script>
<template>
<Page title="学生列表">
<Card>
<div class="mb-4">
<Space wrap>
<Input
v-model:value="searchForm.name"
placeholder="姓名"
style="width: 150px"
allow-clear
@press-enter="handleSearch"
/>
<Input
v-model:value="searchForm.phone"
placeholder="电话"
style="width: 150px"
allow-clear
@press-enter="handleSearch"
/>
<Input
v-model:value="searchForm.student_id"
placeholder="学号"
style="width: 150px"
allow-clear
@press-enter="handleSearch"
/>
<Select
v-model:value="searchForm.bind_status"
placeholder="绑定状态"
style="width: 120px"
allow-clear
>
<Select.Option value="已绑定">已绑定</Select.Option>
<Select.Option value="未绑定">未绑定</Select.Option>
</Select>
<Button type="primary" @click="handleSearch">搜索</Button>
<Button @click="handleReset">重置</Button>
<Button type="primary" @click="handleAdd">新增</Button>
<Button @click="handleImport">导入学生</Button>
</Space>
</div>
<Table
:columns="columns"
:data-source="tableData"
:loading="loading"
:scroll="{ x: 1400 }"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total: total,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
}"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'bind_status'">
<Tag :color="record.bind_status === '已绑定' ? 'green' : 'default'">
{{ record.bind_status || '-' }}
</Tag>
</template>
<template v-else-if="column.key === 'status'">
<Tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '启用' : '禁用' }}
</Tag>
</template>
<template v-else-if="column.key === 'action'">
<Space>
<Button type="link" size="small" @click="handleEdit(record)">编辑</Button>
<Button type="link" danger size="small" @click="handleDelete(record)">删除</Button>
</Space>
</template>
</template>
</Table>
</Card>
<!-- 导入学生对话框 -->
<Modal
v-model:open="importModalVisible"
title="导入学生"
:confirm-loading="importLoading"
@ok="handleConfirmImport"
@cancel="handleCancelImport"
width="600px"
>
<div class="import-form">
<div class="form-item">
<div class="form-label">上传 Excel 文件</div>
<Upload
v-model:file-list="fileList"
:before-upload="beforeUpload"
@change="handleFileChange"
:max-count="1"
accept=".xlsx,.xls"
>
<Button :loading="importLoading">
<template #icon>
<IconifyIcon icon="lucide:upload" />
</template>
选择文件
</Button>
</Upload>
<div class="form-hint">
支持 .xlsx .xls 格式文件大小不超过 10MB
</div>
</div>
<div class="form-item">
<div class="form-label">选择班级</div>
<Select
v-model:value="selectedClassId"
placeholder="请选择班级"
style="width: 100%"
:loading="importLoading"
show-search
:filter-option="(input, option) => {
const label = option?.label || '';
return label.toLowerCase().includes(input.toLowerCase());
}"
>
<Select.Option
v-for="item in classList"
:key="item.id"
:value="item.id"
:label="item.name"
>
{{ item.name }}
</Select.Option>
</Select>
</div>
<div class="form-hint">
<p><strong>导入说明</strong></p>
<p>1. 请先上传 Excel 文件文件上传成功后再选择班级</p>
<p>2. Excel 文件格式请参考系统要求</p>
<p>3. 导入过程中请勿关闭此对话框</p>
</div>
</div>
</Modal>
</Page>
</template>
<style scoped>
.import-form {
padding: 20px 0;
}
.form-item {
margin-bottom: 24px;
}
.form-label {
margin-bottom: 8px;
font-weight: 500;
color: var(--ant-color-text);
}
.form-hint {
margin-top: 8px;
font-size: 12px;
color: var(--ant-color-text-secondary);
line-height: 1.6;
}
.form-hint p {
margin: 4px 0;
}
</style>

View File

@@ -0,0 +1,139 @@
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Card, Form, Input, InputNumber, Button, message, Radio } from 'ant-design-vue';
import {
getTeacherDetailApi,
saveTeacherApi,
type TeacherApi
} from '#/api';
defineOptions({ name: 'TeacherDetail' });
const route = useRoute();
const router = useRouter();
const formRef = ref();
const loading = ref(false);
const formData = ref<Partial<TeacherApi.SaveParams>>({
teacher_id: null,
name: '',
gender: undefined,
nickname: '',
phone: '',
email: '',
remark: '',
status: 1,
});
const isEdit = computed(() => !!route.params.id);
const handleSubmit = async () => {
try {
await formRef.value.validate();
loading.value = true;
const data = { ...formData.value };
if (isEdit.value) {
data.id = Number(route.params.id);
}
await saveTeacherApi(data);
message.success(isEdit.value ? '更新成功' : '创建成功');
router.back();
} catch (error: any) {
console.error('保存失败:', error);
message.error(error?.response?.data?.message || error?.response?.data?.msg || '保存失败');
} finally {
loading.value = false;
}
};
const fetchDetail = async () => {
if (!isEdit.value) return;
loading.value = true;
try {
const res = await getTeacherDetailApi({ id: Number(route.params.id) });
// 支持 code 为 0 或 200 的成功响应
if (res && (res.code === 0 || res.code === 200)) {
// 根据实际返回格式,数据在 res.data 中
const data = res.data;
if (data) {
formData.value = {
teacher_id: data.teacher_id ?? null,
name: data.name || '',
gender: data.gender || undefined,
nickname: data.nickname || '',
phone: data.phone || '',
email: data.email || '',
remark: data.remark || '',
status: data.status !== undefined ? data.status : 1,
};
} else {
message.error('获取教师详情失败:数据为空');
}
} else {
message.error(res?.message || res?.msg || '获取教师详情失败');
}
} catch (error: any) {
console.error('获取详情失败:', error);
message.error(error?.response?.data?.message || error?.response?.data?.msg || '获取教师详情失败');
} finally {
loading.value = false;
}
};
onMounted(() => {
if (isEdit.value) {
fetchDetail();
}
});
</script>
<template>
<Page :title="isEdit ? '编辑教师' : '新增教师'">
<Card>
<Form
ref="formRef"
:model="formData"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<Form.Item
label="姓名"
name="name"
:rules="[{ required: true, message: '请输入姓名' }]"
>
<Input v-model:value="formData.name" placeholder="请输入姓名" />
</Form.Item>
<Form.Item label="性别" name="gender">
<Radio.Group v-model:value="formData.gender">
<Radio value="男"></Radio>
<Radio value="女"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item label="昵称" name="nickname">
<Input v-model:value="formData.nickname" placeholder="请输入昵称" />
</Form.Item>
<Form.Item label="电话" name="phone">
<Input v-model:value="formData.phone" placeholder="请输入电话" />
</Form.Item>
<Form.Item label="邮箱" name="email">
<Input v-model:value="formData.email" placeholder="请输入邮箱" />
</Form.Item>
<Form.Item label="备注" name="remark">
<Input.TextArea v-model:value="formData.remark" placeholder="请输入备注" :rows="3" />
</Form.Item>
<Form.Item label="状态" name="status">
<Radio.Group v-model:value="formData.status">
<Radio :value="1">启用</Radio>
<Radio :value="0">禁用</Radio>
</Radio.Group>
</Form.Item>
<Form.Item :wrapper-col="{ offset: 4, span: 20 }">
<Button type="primary" :loading="loading" @click="handleSubmit">保存</Button>
<Button class="ml-2" @click="router.back()">取消</Button>
</Form.Item>
</Form>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,191 @@
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Button, Card, Table, Space, message, Modal, Input } from 'ant-design-vue';
import {
getTeacherListApi,
deleteTeacherApi,
type TeacherApi
} from '#/api';
defineOptions({ name: 'TeacherList' });
const router = useRouter();
const loading = ref(false);
const tableData = ref<TeacherApi.TeacherInfo[]>([]);
const total = ref(0);
const pagination = reactive({
current: 1,
pageSize: 10,
});
const searchForm = reactive({
teacher_id: '',
name: '',
nickname: '',
});
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
width: 120,
},
{
title: '性别',
dataIndex: 'gender',
key: 'gender',
width: 80,
},
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 120,
},
{
title: '电话',
dataIndex: 'phone',
key: 'phone',
width: 130,
},
{
title: '管理的教室',
key: 'classroom_names',
width: 200,
},
{
title: '操作',
key: 'action',
width: 200,
},
];
const fetchData = async () => {
loading.value = true;
try {
const params: TeacherApi.ListParams = {
page: pagination.current,
limit: pagination.pageSize,
...searchForm,
};
const res = await getTeacherListApi(params);
// 根据实际返回格式code 为 0 表示成功,返回格式为 {code, msg, count, data}
if (res && (res.code === 0 || res.code === 200)) {
const data = Array.isArray(res.data) ? res.data : [];
tableData.value = data;
total.value = res.count || 0;
} else {
tableData.value = [];
total.value = 0;
}
} catch (error) {
console.error('获取教师列表失败:', error);
tableData.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.current = 1;
fetchData();
};
const handleEdit = (record: TeacherApi.TeacherInfo) => {
router.push({
name: 'TeacherDetail',
params: { id: record.id }
});
};
const handleDelete = (record: TeacherApi.TeacherInfo) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除教师"${record.name}"吗?`,
onOk: async () => {
try {
await deleteTeacherApi({ id: record.id! });
message.success('删除成功');
fetchData();
} catch (error) {
console.error('删除失败:', error);
}
},
});
};
const handleTableChange = (pag: any) => {
pagination.current = pag.current;
pagination.pageSize = pag.pageSize;
fetchData();
};
onMounted(() => {
fetchData();
});
</script>
<template>
<Page title="教师列表">
<Card>
<div class="mb-4">
<Space>
<Input
v-model:value="searchForm.teacher_id"
placeholder="工号"
style="width: 200px"
@press-enter="handleSearch"
/>
<Input
v-model:value="searchForm.name"
placeholder="姓名"
style="width: 200px"
@press-enter="handleSearch"
/>
<Button type="primary" @click="handleSearch">搜索</Button>
<Button type="primary" @click="router.push({ name: 'TeacherDetail' })">新增</Button>
</Space>
</div>
<Table
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total: total,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
}"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'classroom_names'">
<span v-if="record.classroom_names && record.classroom_names.length > 0">
{{ record.classroom_names.join('') }}
</span>
<span v-else style="color: #999;">-</span>
</template>
<template v-else-if="column.key === 'action'">
<Space>
<Button type="link" size="small" @click="handleEdit(record)">编辑</Button>
<Button type="link" danger size="small" @click="handleDelete(record)">删除</Button>
</Space>
</template>
</template>
</Table>
</Card>
</Page>
</template>

View File

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

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web-app.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"#/*": ["./src/*"]
}
},
"references": [{ "path": "./tsconfig.node.json" }],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/node.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"noEmit": false
},
"include": ["vite.config.mts"]
}

View File

@@ -0,0 +1,21 @@
import { defineConfig } from '@vben/vite-config';
export default defineConfig(async () => {
return {
application: {},
vite: {
server: {
proxy: {
'/api': {
target: 'https://xuanzuo.dhdjy.com',
changeOrigin: true,
secure: true, // https 接口设为 true
ws: true,
// 不需要 rewrite因为 API 定义中已经包含了 /api 前缀
// 请求 /api/admin/login 会转发到 https://xuanzuo.dhdjy.com/api/admin/login
},
},
},
},
};
});