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
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:
5
apps/web-antd/.env
Normal file
5
apps/web-antd/.env
Normal file
@@ -0,0 +1,5 @@
|
||||
# 应用标题
|
||||
VITE_APP_TITLE=教室预订管理系统
|
||||
|
||||
# 应用命名空间
|
||||
VITE_APP_NAMESPACE=booking-admin
|
||||
7
apps/web-antd/.env.analyze
Normal file
7
apps/web-antd/.env.analyze
Normal file
@@ -0,0 +1,7 @@
|
||||
# public path
|
||||
VITE_BASE=/
|
||||
|
||||
# Basic interface address SPA
|
||||
VITE_GLOB_API_URL=/api
|
||||
|
||||
VITE_VISUALIZER=true
|
||||
18
apps/web-antd/.env.development
Normal file
18
apps/web-antd/.env.development
Normal 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
|
||||
8
apps/web-antd/.env.production
Normal file
8
apps/web-antd/.env.production
Normal 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
35
apps/web-antd/index.html
Normal 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>
|
||||
50
apps/web-antd/package.json
Normal file
50
apps/web-antd/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
1
apps/web-antd/postcss.config.mjs
Normal file
1
apps/web-antd/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
||||
BIN
apps/web-antd/public/favicon.ico
Normal file
BIN
apps/web-antd/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
441
apps/web-antd/src/adapter/component/index.ts
Normal file
441
apps/web-antd/src/adapter/component/index.ts
Normal 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 };
|
||||
49
apps/web-antd/src/adapter/form.ts
Normal file
49
apps/web-antd/src/adapter/form.ts
Normal 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 };
|
||||
69
apps/web-antd/src/adapter/vxe-table.ts
Normal file
69
apps/web-antd/src/adapter/vxe-table.ts
Normal 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';
|
||||
78
apps/web-antd/src/api/admin/booking.ts
Normal file
78
apps/web-antd/src/api/admin/booking.ts
Normal 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);
|
||||
}
|
||||
|
||||
143
apps/web-antd/src/api/admin/class.ts
Normal file
143
apps/web-antd/src/api/admin/class.ts
Normal 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);
|
||||
}
|
||||
|
||||
237
apps/web-antd/src/api/admin/classroom.ts
Normal file
237
apps/web-antd/src/api/admin/classroom.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
19
apps/web-antd/src/api/admin/common.ts
Normal file
19
apps/web-antd/src/api/admin/common.ts
Normal 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');
|
||||
}
|
||||
|
||||
31
apps/web-antd/src/api/admin/index-page.ts
Normal file
31
apps/web-antd/src/api/admin/index-page.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
|
||||
10
apps/web-antd/src/api/admin/index.ts
Normal file
10
apps/web-antd/src/api/admin/index.ts
Normal 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';
|
||||
75
apps/web-antd/src/api/admin/profile.ts
Normal file
75
apps/web-antd/src/api/admin/profile.ts
Normal 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);
|
||||
}
|
||||
|
||||
134
apps/web-antd/src/api/admin/school.ts
Normal file
134
apps/web-antd/src/api/admin/school.ts
Normal 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);
|
||||
}
|
||||
|
||||
65
apps/web-antd/src/api/admin/setting.ts
Normal file
65
apps/web-antd/src/api/admin/setting.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
154
apps/web-antd/src/api/admin/student.ts
Normal file
154
apps/web-antd/src/api/admin/student.ts
Normal 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);
|
||||
}
|
||||
|
||||
92
apps/web-antd/src/api/admin/teacher.ts
Normal file
92
apps/web-antd/src/api/admin/teacher.ts
Normal 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);
|
||||
}
|
||||
|
||||
58
apps/web-antd/src/api/core/auth.ts
Normal file
58
apps/web-antd/src/api/core/auth.ts
Normal 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');
|
||||
}
|
||||
4
apps/web-antd/src/api/core/index.ts
Normal file
4
apps/web-antd/src/api/core/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './auth';
|
||||
export * from './menu';
|
||||
export * from './user';
|
||||
export * from '../admin';
|
||||
17
apps/web-antd/src/api/core/menu.ts
Normal file
17
apps/web-antd/src/api/core/menu.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
11
apps/web-antd/src/api/core/user.ts
Normal file
11
apps/web-antd/src/api/core/user.ts
Normal 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');
|
||||
}
|
||||
1
apps/web-antd/src/api/index.ts
Normal file
1
apps/web-antd/src/api/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './core';
|
||||
129
apps/web-antd/src/api/request.ts
Normal file
129
apps/web-antd/src/api/request.ts
Normal 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
39
apps/web-antd/src/app.vue
Normal 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>
|
||||
76
apps/web-antd/src/bootstrap.ts
Normal file
76
apps/web-antd/src/bootstrap.ts
Normal 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 };
|
||||
904
apps/web-antd/src/components/classroom/SeatLayoutEditor.vue
Normal file
904
apps/web-antd/src/components/classroom/SeatLayoutEditor.vue
Normal 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>
|
||||
|
||||
126
apps/web-antd/src/components/profile/ChangePasswordModal.vue
Normal file
126
apps/web-antd/src/components/profile/ChangePasswordModal.vue
Normal 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>
|
||||
|
||||
259
apps/web-antd/src/components/profile/ProfileInfoModal.vue
Normal file
259
apps/web-antd/src/components/profile/ProfileInfoModal.vue
Normal 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>
|
||||
|
||||
25
apps/web-antd/src/layouts/auth.vue
Normal file
25
apps/web-antd/src/layouts/auth.vue
Normal 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>
|
||||
187
apps/web-antd/src/layouts/basic.vue
Normal file
187
apps/web-antd/src/layouts/basic.vue
Normal 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>
|
||||
6
apps/web-antd/src/layouts/index.ts
Normal file
6
apps/web-antd/src/layouts/index.ts
Normal 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 };
|
||||
3
apps/web-antd/src/locales/README.md
Normal file
3
apps/web-antd/src/locales/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# locale
|
||||
|
||||
每个app使用的国际化可能不同,这里用于扩展国际化的功能,例如扩展 dayjs、antd组件库的多语言切换,以及app本身的国际化文件。
|
||||
102
apps/web-antd/src/locales/index.ts
Normal file
102
apps/web-antd/src/locales/index.ts
Normal 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 };
|
||||
13
apps/web-antd/src/locales/langs/en-US/demos.json
Normal file
13
apps/web-antd/src/locales/langs/en-US/demos.json
Normal 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"
|
||||
}
|
||||
}
|
||||
15
apps/web-antd/src/locales/langs/en-US/page.json
Normal file
15
apps/web-antd/src/locales/langs/en-US/page.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
apps/web-antd/src/locales/langs/zh-CN/demos.json
Normal file
13
apps/web-antd/src/locales/langs/zh-CN/demos.json
Normal 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 版本"
|
||||
}
|
||||
}
|
||||
51
apps/web-antd/src/locales/langs/zh-CN/page.json
Normal file
51
apps/web-antd/src/locales/langs/zh-CN/page.json
Normal 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
40
apps/web-antd/src/main.ts
Normal 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();
|
||||
19
apps/web-antd/src/preferences.ts
Normal file
19
apps/web-antd/src/preferences.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
42
apps/web-antd/src/router/access.ts
Normal file
42
apps/web-antd/src/router/access.ts
Normal 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 };
|
||||
252
apps/web-antd/src/router/guard.ts
Normal file
252
apps/web-antd/src/router/guard.ts
Normal 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 };
|
||||
37
apps/web-antd/src/router/index.ts
Normal file
37
apps/web-antd/src/router/index.ts
Normal 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 };
|
||||
97
apps/web-antd/src/router/routes/core.ts
Normal file
97
apps/web-antd/src/router/routes/core.ts
Normal 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 };
|
||||
37
apps/web-antd/src/router/routes/index.ts
Normal file
37
apps/web-antd/src/router/routes/index.ts
Normal 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 };
|
||||
30
apps/web-antd/src/router/routes/modules/booking.ts
Normal file
30
apps/web-antd/src/router/routes/modules/booking.ts
Normal 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;
|
||||
|
||||
39
apps/web-antd/src/router/routes/modules/class.ts
Normal file
39
apps/web-antd/src/router/routes/modules/class.ts
Normal 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;
|
||||
|
||||
39
apps/web-antd/src/router/routes/modules/classroom.ts
Normal file
39
apps/web-antd/src/router/routes/modules/classroom.ts
Normal 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;
|
||||
|
||||
30
apps/web-antd/src/router/routes/modules/dashboard.ts
Normal file
30
apps/web-antd/src/router/routes/modules/dashboard.ts
Normal 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;
|
||||
40
apps/web-antd/src/router/routes/modules/school.ts
Normal file
40
apps/web-antd/src/router/routes/modules/school.ts
Normal 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;
|
||||
|
||||
31
apps/web-antd/src/router/routes/modules/setting.ts
Normal file
31
apps/web-antd/src/router/routes/modules/setting.ts
Normal 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;
|
||||
|
||||
39
apps/web-antd/src/router/routes/modules/student.ts
Normal file
39
apps/web-antd/src/router/routes/modules/student.ts
Normal 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;
|
||||
|
||||
39
apps/web-antd/src/router/routes/modules/teacher.ts
Normal file
39
apps/web-antd/src/router/routes/modules/teacher.ts
Normal 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;
|
||||
|
||||
151
apps/web-antd/src/store/auth.ts
Normal file
151
apps/web-antd/src/store/auth.ts
Normal 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.md,code 200 表示成功)
|
||||
// 如果返回数据中有 accessToken,则使用它;否则使用 Session 认证
|
||||
const accessToken = loginResult?.accessToken || loginResult?.token || 'session';
|
||||
|
||||
// 设置 accessToken(Session 认证时可以使用固定值)
|
||||
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,
|
||||
};
|
||||
});
|
||||
1
apps/web-antd/src/store/index.ts
Normal file
1
apps/web-antd/src/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './auth';
|
||||
3
apps/web-antd/src/views/_core/README.md
Normal file
3
apps/web-antd/src/views/_core/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# \_core
|
||||
|
||||
此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。
|
||||
69
apps/web-antd/src/views/_core/authentication/code-login.vue
Normal file
69
apps/web-antd/src/views/_core/authentication/code-login.vue
Normal 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>
|
||||
@@ -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>
|
||||
53
apps/web-antd/src/views/_core/authentication/login.vue
Normal file
53
apps/web-antd/src/views/_core/authentication/login.vue
Normal 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>
|
||||
@@ -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>
|
||||
96
apps/web-antd/src/views/_core/authentication/register.vue
Normal file
96
apps/web-antd/src/views/_core/authentication/register.vue
Normal 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>
|
||||
7
apps/web-antd/src/views/_core/fallback/coming-soon.vue
Normal file
7
apps/web-antd/src/views/_core/fallback/coming-soon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="coming-soon" />
|
||||
</template>
|
||||
9
apps/web-antd/src/views/_core/fallback/forbidden.vue
Normal file
9
apps/web-antd/src/views/_core/fallback/forbidden.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback403Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="403" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback500Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="500" />
|
||||
</template>
|
||||
9
apps/web-antd/src/views/_core/fallback/not-found.vue
Normal file
9
apps/web-antd/src/views/_core/fallback/not-found.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback404Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="404" />
|
||||
</template>
|
||||
9
apps/web-antd/src/views/_core/fallback/offline.vue
Normal file
9
apps/web-antd/src/views/_core/fallback/offline.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'FallbackOfflineDemo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="offline" />
|
||||
</template>
|
||||
65
apps/web-antd/src/views/_core/profile/base-setting.vue
Normal file
65
apps/web-antd/src/views/_core/profile/base-setting.vue
Normal 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>
|
||||
49
apps/web-antd/src/views/_core/profile/index.vue
Normal file
49
apps/web-antd/src/views/_core/profile/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
66
apps/web-antd/src/views/_core/profile/password-setting.vue
Normal file
66
apps/web-antd/src/views/_core/profile/password-setting.vue
Normal 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>
|
||||
43
apps/web-antd/src/views/_core/profile/security-setting.vue
Normal file
43
apps/web-antd/src/views/_core/profile/security-setting.vue
Normal 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>
|
||||
291
apps/web-antd/src/views/booking/list.vue
Normal file
291
apps/web-antd/src/views/booking/list.vue
Normal 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>
|
||||
|
||||
250
apps/web-antd/src/views/class/detail.vue
Normal file
250
apps/web-antd/src/views/class/detail.vue
Normal 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>
|
||||
|
||||
203
apps/web-antd/src/views/class/list.vue
Normal file
203
apps/web-antd/src/views/class/list.vue
Normal 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>
|
||||
|
||||
289
apps/web-antd/src/views/classroom/detail.vue
Normal file
289
apps/web-antd/src/views/classroom/detail.vue
Normal 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>
|
||||
|
||||
258
apps/web-antd/src/views/classroom/list.vue
Normal file
258
apps/web-antd/src/views/classroom/list.vue
Normal 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>
|
||||
|
||||
78
apps/web-antd/src/views/dashboard/index.vue
Normal file
78
apps/web-antd/src/views/dashboard/index.vue
Normal 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>
|
||||
|
||||
317
apps/web-antd/src/views/school/detail.vue
Normal file
317
apps/web-antd/src/views/school/detail.vue
Normal 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>
|
||||
|
||||
183
apps/web-antd/src/views/school/list.vue
Normal file
183
apps/web-antd/src/views/school/list.vue
Normal 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>
|
||||
|
||||
170
apps/web-antd/src/views/setting/index.vue
Normal file
170
apps/web-antd/src/views/setting/index.vue
Normal 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>
|
||||
|
||||
201
apps/web-antd/src/views/student/detail.vue
Normal file
201
apps/web-antd/src/views/student/detail.vue
Normal 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>
|
||||
|
||||
496
apps/web-antd/src/views/student/list.vue
Normal file
496
apps/web-antd/src/views/student/list.vue
Normal 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>
|
||||
|
||||
139
apps/web-antd/src/views/teacher/detail.vue
Normal file
139
apps/web-antd/src/views/teacher/detail.vue
Normal 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>
|
||||
|
||||
191
apps/web-antd/src/views/teacher/list.vue
Normal file
191
apps/web-antd/src/views/teacher/list.vue
Normal 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>
|
||||
|
||||
1
apps/web-antd/tailwind.config.mjs
Normal file
1
apps/web-antd/tailwind.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config';
|
||||
12
apps/web-antd/tsconfig.json
Normal file
12
apps/web-antd/tsconfig.json
Normal 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"]
|
||||
}
|
||||
10
apps/web-antd/tsconfig.node.json
Normal file
10
apps/web-antd/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
21
apps/web-antd/vite.config.mts
Normal file
21
apps/web-antd/vite.config.mts
Normal 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
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user