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

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>