Compare commits

...

4 Commits

Author SHA1 Message Date
lbw
5ebf40101d refactor(class): 优化班级页面布局与删除功能
- 调整学生查询区域样式,使其占用两行空间
- 移除年级和单元列表中的删除按钮及新增操作相关代码
- 新增删除学生与班级前的确认弹窗提示,防止误操作
- 捕获删除确认框的取消或关闭事件,避免错误提示
- 主动导入 Element Plus 组件库样式文件,保证样式完整
- 修正角色同步代码中 Redis 写入方式,避免二次序列化问题
2025-12-29 15:55:36 +08:00
lbw
5858bf2ecc feat(admin): 新增邀请码生成及注册校验功能
- 在管理员页面新增邀请码生成面板,支持限制使用次数和有效期
- 新增后端接口支持创建邀请码,邀请码存储在Redis并设置过期时间
- 用户注册接口新增邀请码参数,校验邀请码有效性和剩余使用次数
- 注册时成功使用邀请码后,Redis中对应邀请码的使用次数减1
- 登录接口及相关服务层逻辑新增邀请码字段支持
- 后端权限配置增加/admin路径的root角色校验
- 优化角色权限同步时Redis存储格式为列表类型
- 调整SaToken相关接口实现以支持角色ID转换逻辑
2025-12-29 15:44:05 +08:00
lbw
bddf6c0936 feat(student): 添加学习分析生成进度显示
- 在分析生成期间显示进度条和提示信息
- 引入 analyzeProgress 变量动态更新进度百分比
- 使用定时器模拟进度增长,达到 90% 后等待完成
- 分析完成后将进度设置为 100% 并清除定时器
- 调整模板逻辑,区分加载中和结果显示状态
2025-12-29 14:50:27 +08:00
lbw
340bc5b5e3 feat(auth): 实现登录注册功能和路由权限控制
- 新增 Login.vue 实现登录与注册界面,支持手机号、密码、验证码等验证
- 添加登录状态保持并在登录成功后设置 token
- 修改路由配置,新增 /login 路由,并调整默认班级页路由为 /class
- 移除 Header 组件中原有登录按钮,改为通过路由控制访问权限
- 实现路由前置守卫,根据 token 自动跳转登录页或班级页
- 添加验证码发送功能及倒计时禁用按钮逻辑
- 完善表单校验规则,区分登录和注册模式验证字段
2025-12-29 14:45:44 +08:00
20 changed files with 424 additions and 81 deletions

View File

@@ -24,24 +24,12 @@ public class SaTokenConfigure implements WebMvcConfigurer {
.back(); .back();
SaRouter.match("/**") SaRouter.match("/**")
.notMatch("/class/list")
.notMatch("/exam/words/get")
.notMatch("/exam/words/detail")
.notMatch("/exam/words/student/history")
.notMatch("grade/list")
.notMatch("/student/list")
.notMatch("/student/detail")
.notMatch("/studentLessonPlans/list")
.notMatch("/studentLessonPlans/history")
.notMatch("/student/analyze")
.notMatch("/student/mastery/detail")
.notMatch("/unit/list")
.notMatch("/vocabulary/list")
.notMatch("/vocabulary/student/detail")
.notMatch("/plan/download")
.notMatch("/login/**") .notMatch("/login/**")
.check(r -> StpUtil.checkLogin()); .check(r -> StpUtil.checkLogin());
SaRouter.match("/admin/**")
.check(r -> StpUtil.checkRole("root"));
})) }))
.addPathPatterns("/**") .addPathPatterns("/**")
.excludePathPatterns("/error"); .excludePathPatterns("/error");

View File

@@ -28,7 +28,11 @@ public class StpInterfaceImpl implements StpInterface {
@Override @Override
public List<String> getRoleList(Object loginId, String loginType) { public List<String> getRoleList(Object loginId, String loginType) {
return userToRole((Long) loginId) ; long l = 0L;
if (loginId instanceof String loginIdStr) {
l = Long.parseLong(loginIdStr);
}
return userToRole(l);
} }
private List<String> userToRole(Long userId) { private List<String> userToRole(Long userId) {

View File

@@ -4,7 +4,13 @@ public class UserRedisConstants {
public static final String USER_LOGIN_CODE = "user:login:code:"; public static final String USER_LOGIN_CODE = "user:login:code:";
public static final String USER_INVITATION_CODE = "user:invitation:code:";
public static String buildUserLoginCode(String phone) { public static String buildUserLoginCode(String phone) {
return USER_LOGIN_CODE + phone; return USER_LOGIN_CODE + phone;
} }
public static String buildUserInvitationCode(String code) {
return USER_INVITATION_CODE + code;
}
} }

View File

@@ -1,7 +1,10 @@
package com.yinlihupo.enlish.service.controller; package com.yinlihupo.enlish.service.controller;
import com.yinlihupo.enlish.service.constant.UserRedisConstants;
import com.yinlihupo.enlish.service.domain.dataobject.RoleDO; import com.yinlihupo.enlish.service.domain.dataobject.RoleDO;
import com.yinlihupo.enlish.service.domain.dataobject.UserDO; import com.yinlihupo.enlish.service.domain.dataobject.UserDO;
import com.yinlihupo.enlish.service.model.vo.admin.CreateInvitationCodeReqVO;
import com.yinlihupo.enlish.service.model.vo.admin.CreateInvitationCodeRspVO;
import com.yinlihupo.enlish.service.model.vo.user.CreateUserReqVO; import com.yinlihupo.enlish.service.model.vo.user.CreateUserReqVO;
import com.yinlihupo.enlish.service.model.vo.user.FindUserListRepVO; import com.yinlihupo.enlish.service.model.vo.user.FindUserListRepVO;
import com.yinlihupo.enlish.service.model.vo.user.FindUserListRspVO; import com.yinlihupo.enlish.service.model.vo.user.FindUserListRspVO;
@@ -11,6 +14,7 @@ import com.yinlihupo.framework.biz.operationlog.aspect.ApiOperationLog;
import com.yinlihupo.framework.common.response.PageResponse; import com.yinlihupo.framework.common.response.PageResponse;
import com.yinlihupo.framework.common.response.Response; import com.yinlihupo.framework.common.response.Response;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@@ -19,6 +23,8 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@RequestMapping("/admin/") @RequestMapping("/admin/")
@RestController @RestController
@@ -30,6 +36,8 @@ public class AdminController {
private PasswordEncoder passwordEncoder; private PasswordEncoder passwordEncoder;
@Resource @Resource
private RoleService roleService; private RoleService roleService;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@PostMapping("user/list") @PostMapping("user/list")
@ApiOperationLog(description = "查询用户列表") @ApiOperationLog(description = "查询用户列表")
@@ -66,4 +74,18 @@ public class AdminController {
return Response.success(); return Response.success();
} }
@PostMapping("user/create/invitation/code")
@ApiOperationLog(description = "创建邀请码")
public Response<CreateInvitationCodeRspVO> createInvitationCode(@RequestBody CreateInvitationCodeReqVO createInvitationCodeReqVO) {
String code = String.valueOf(new Random().nextInt(1000000));
Integer limit = createInvitationCodeReqVO.getLimit();
Integer expire = createInvitationCodeReqVO.getExpire();
redisTemplate.opsForValue().set(UserRedisConstants.buildUserInvitationCode(code), limit);
redisTemplate.expire(UserRedisConstants.buildUserInvitationCode(code), expire, TimeUnit.DAYS);
return Response.success(CreateInvitationCodeRspVO.builder().invitationCode(code).build());
}
} }

View File

@@ -25,7 +25,7 @@ public class LoginController {
@ApiOperationLog(description = "登录") @ApiOperationLog(description = "登录")
public Response<String> login(@RequestBody LoginReqVO loginReqVO) { public Response<String> login(@RequestBody LoginReqVO loginReqVO) {
try { try {
loginService.login(loginReqVO.getPhone(), loginReqVO.getName(), loginReqVO.getPassword(), loginReqVO.getCode()); loginService.login(loginReqVO.getPhone(), loginReqVO.getName(), loginReqVO.getPassword(), loginReqVO.getCode(), loginReqVO.getInvitationCode());
return Response.success(StpUtil.getTokenInfo().getTokenValue()); return Response.success(StpUtil.getTokenInfo().getTokenValue());
} catch (Exception e) { } catch (Exception e) {
log.error("注册或登录失败 {}", e.getMessage()); log.error("注册或登录失败 {}", e.getMessage());

View File

@@ -0,0 +1,18 @@
package com.yinlihupo.enlish.service.model.vo.admin;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class CreateInvitationCodeReqVO {
// 限制人数
private Integer limit;
// 有效期
private Integer expire;
}

View File

@@ -0,0 +1,16 @@
package com.yinlihupo.enlish.service.model.vo.admin;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class CreateInvitationCodeRspVO {
private String invitationCode;
}

View File

@@ -15,4 +15,5 @@ public class LoginReqVO {
private String name; private String name;
private String password; private String password;
private String code; private String code;
private String invitationCode;
} }

View File

@@ -2,7 +2,7 @@ package com.yinlihupo.enlish.service.service;
public interface LoginService { public interface LoginService {
void login(String phone, String name, String reqPassword, String reqCode); void login(String phone, String name, String reqPassword, String reqCode, String invitationCode);
void sendVerificationCode(String phone); void sendVerificationCode(String phone);
} }

View File

@@ -13,11 +13,14 @@ import com.yinlihupo.enlish.service.service.LoginService;
import com.yinlihupo.framework.common.util.JsonUtils; import com.yinlihupo.framework.common.util.JsonUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration; import java.time.Duration;
import java.util.Objects;
@Service @Service
@Slf4j @Slf4j
@@ -28,20 +31,32 @@ public class LoginServiceImpl implements LoginService {
@Resource @Resource
private PasswordEncoder passwordEncoder; private PasswordEncoder passwordEncoder;
@Resource @Resource
private StringRedisTemplate stringRedisTemplate; private RedisTemplate<String, Object> redisTemplate;
@Resource @Resource
private Client client; private Client client;
@Override @Override
public void login(String phone, String name, String reqPassword, String reqCode) { @Transactional(rollbackFor = Exception.class)
public void login(String phone, String name, String reqPassword, String reqCode, String invitationCode) {
UserDO userDO = userDOMapper.selectByPhone(phone); UserDO userDO = userDOMapper.selectByPhone(phone);
log.info("userDO:{}", userDO); log.info("userDO:{}", userDO);
String code = stringRedisTemplate.opsForValue().get(UserRedisConstants.buildUserLoginCode(phone)); String code = JsonUtils.toJsonString(redisTemplate.opsForValue().get(UserRedisConstants.buildUserLoginCode(phone)));
if (userDO == null) { if (userDO == null) {
if (code == null || !code.equals(reqCode)) { if (code == null || !code.equals(reqCode)) {
throw new RuntimeException("验证码错误"); throw new RuntimeException("验证码错误");
} }
Object invitationObj = redisTemplate.opsForValue().get(UserRedisConstants.buildUserInvitationCode(invitationCode));
if (invitationObj == null) {
throw new RuntimeException("邀请码错误");
}
int invitationLimit = Integer.parseInt(JsonUtils.toJsonString(invitationObj));
if (invitationLimit <= 0) {
throw new RuntimeException("邀请码已使用完毕");
}
redisTemplate.opsForValue().set(UserRedisConstants.buildUserInvitationCode(invitationCode), invitationLimit - 1);
userDO = UserDO.builder() userDO = UserDO.builder()
.phone(phone) .phone(phone)
.name(name) .name(name)
@@ -68,7 +83,7 @@ public class LoginServiceImpl implements LoginService {
@Override @Override
public void sendVerificationCode(String phone) { public void sendVerificationCode(String phone) {
String code = RandomUtil.randomNumbers(6); String code = RandomUtil.randomNumbers(6);
stringRedisTemplate.opsForValue().set(UserRedisConstants.buildUserLoginCode(phone), code, Duration.ofSeconds(60)); redisTemplate.opsForValue().set(UserRedisConstants.buildUserLoginCode(phone), code, Duration.ofSeconds(60));
String signName = "短信测试"; String signName = "短信测试";
String templateCode = "SMS_154950909"; String templateCode = "SMS_154950909";
String templateParam = String.format("{\"code\":\"%s\"}", code); String templateParam = String.format("{\"code\":\"%s\"}", code);

View File

@@ -48,7 +48,8 @@ public class RoleServiceImpl implements RoleService {
List<RoleDO> roleDOs = roleIds.stream().map(roleId2RoleDO::get).toList(); List<RoleDO> roleDOs = roleIds.stream().map(roleId2RoleDO::get).toList();
List<String> user2RoleKeys = roleDOs.stream().map(RoleDO::getRoleKey).toList(); List<String> user2RoleKeys = roleDOs.stream().map(RoleDO::getRoleKey).toList();
log.info("将用户 {} 的角色同步到 redis 中, {}", userId, roleKeys); log.info("将用户 {} 的角色同步到 redis 中, {}", userId, roleKeys);
redisTemplate.opsForValue().set(RoleConstants.buildUserRoleKey(userId), JsonUtils.toJsonString(user2RoleKeys)); // 不要使用 JsonUtils.toJsonString(user2RoleKeys); 会造成二次序列化
redisTemplate.opsForValue().set(RoleConstants.buildUserRoleKey(userId), user2RoleKeys);
}); });
} }

View File

@@ -8,3 +8,6 @@ export function createUser(data) {
return axios.post('/admin/user/create', data) return axios.post('/admin/user/create', data)
} }
export function createInvitationCode(data) {
return axios.post('/admin/user/create/invitation/code', data)
}

View File

@@ -34,12 +34,6 @@
</div> </div>
</div> </div>
</template> </template>
<template v-else>
<a href="#" @click.prevent="showLogin = true"
class="text-gray-800 dark:text-white hover:bg-gray-50 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-4 lg:px-5 py-2 lg:py-2.5 mr-2 dark:hover:bg-gray-700 focus:outline-none dark:focus:ring-gray-800">
Login
</a>
</template>
<button data-collapse-toggle="mobile-menu-2" type="button" <button data-collapse-toggle="mobile-menu-2" type="button"
class="inline-flex items-center p-2 ml-1 text-sm text-gray-500 rounded-lg lg:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600" class="inline-flex items-center p-2 ml-1 text-sm text-gray-500 rounded-lg lg:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="mobile-menu-2" aria-expanded="false"> aria-controls="mobile-menu-2" aria-expanded="false">
@@ -65,7 +59,7 @@
aria-current="page">Home</a> aria-current="page">Home</a>
</li> </li>
<li> <li>
<router-link to="/" <router-link to="/class"
class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-primary-700 lg:p-0 dark:text-gray-400 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700"> class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-primary-700 lg:p-0 dark:text-gray-400 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700">
班级 班级
</router-link> </router-link>

View File

@@ -1,5 +1,6 @@
import '@/assets/main.css' import '@/assets/main.css'
import 'nprogress/nprogress.css' import 'nprogress/nprogress.css'
import 'element-plus/dist/index.css'
// 导入路由 // 导入路由
import router from '@/router' import router from '@/router'
// 导入全局路由守卫 // 导入全局路由守卫

View File

@@ -0,0 +1,203 @@
<template>
<div class="w-screen h-screen overflow-hidden flex">
<div class="flex-1 bg-black">
<el-image fit="cover" src="https://cube.elemecdn.com/6/94/4d3ea53c084bad6931a56d5158a48jpeg.jpeg"
style="width: 100%; height: 100%" />
</div>
<div class="w-[500px] z-10 bg-white dark:bg-gray-800 p-8">
<div class="mb-4 grid grid-cols-2 gap-2 justify-center pt-64">
<button type="button" @click="switchMode('login')" class="w-full px-3 py-1.5 rounded text-sm"
:class="mode === 'login' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'">
登录
</button>
<button type="button" @click="switchMode('register')" class="w-full px-3 py-1.5 rounded text-sm"
:class="mode === 'register' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'">
注册
</button>
</div>
<div class="mb-4">
<h1 class="text-2xl font-bold" v-if="mode === 'login'">登录</h1>
<h1 class="text-2xl font-bold" v-else>注册新用户</h1>
<p class="text-sm text-gray-500" v-if="mode === 'login'">请输入手机号和密码进行登录</p>
<p class="text-sm text-gray-500" v-else>请填写以下信息进行注册</p>
</div>
<el-form :model="form" :rules="rules" ref="formRef" class="space-y-4">
<el-form-item prop="phone">
<el-input v-model="form.phone" maxlength="11" placeholder="手机号" />
</el-form-item>
<template v-if="mode === 'login'">
<el-form-item prop="password">
<el-input v-model="form.password" type="password" maxlength="20" placeholder="密码" />
</el-form-item>
</template>
<template v-else>
<el-form-item prop="name">
<el-input v-model="form.name" maxlength="20" placeholder="姓名" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" maxlength="20" placeholder="设置密码" />
</el-form-item>
<el-form-item prop="password_repeat">
<el-input v-model="form.password_repeat" type="password" maxlength="20" placeholder="重复密码" />
</el-form-item>
<el-form-item prop="invitationCode">
<el-input v-model="form.invitationCode" maxlength="6" placeholder="邀请码" />
</el-form-item>
<el-form-item prop="code">
<div class="flex items-center gap-2 w-full">
<el-input v-model="form.code" maxlength="6" placeholder="验证码" />
<el-button type="primary" @click="sendCode" :disabled="codeDisabled || !form.phone">
{{ codeBtnText }}
</el-button>
</div>
</el-form-item>
</template>
</el-form>
<div class="mt-6">
<el-button :type="mode === 'login' ? 'success' : 'warning'" plain class="w-full" :disabled="loading"
@click="userLogin">
<span v-if="!loading && mode === 'login'">立即登录</span>
<span v-if="loading && mode === 'login'">登录中...</span>
<span v-if="!loading && mode === 'register'">立即注册</span>
<span v-if="loading && mode === 'register'">处理中...</span>
</el-button>
</div>
</div>
<div class="absolute bottom-2 left-6 text-white drop-shadow">
<h2 class="text-xl">欢迎来到智慧英语</h2>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import router from '@/router'
import { ElMessage } from 'element-plus'
import { login, getVerificationCode } from '@/api/user'
import { setToken } from '@/composables/auth'
const loading = ref(false)
const formRef = ref()
const mode = ref('login')
const codeDisabled = ref(false)
const codeBtnText = ref('发送验证码')
let timer = null
const form = reactive({
phone: '',
name: '',
password: '',
password_repeat: '',
code: '',
invitationCode: '',
})
const rules = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入合法的手机号', trigger: ['blur', 'change'] },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度为6-20位', trigger: ['blur', 'change'] },
],
name: [
{
validator: (_r, v, cb) => {
if (mode.value !== 'register') return cb()
if (!v?.trim()) return cb(new Error('请输入姓名'))
cb()
}, trigger: ['blur', 'change']
},
],
password_repeat: [
{
validator: (_r, v, cb) => {
if (mode.value !== 'register') return cb()
if (!v) return cb(new Error('请再次输入密码'))
if (v !== form.password) return cb(new Error('两次密码不一致'))
cb()
}, trigger: ['blur', 'change']
},
],
code: [
{
validator: (_r, v, cb) => {
if (mode.value !== 'register') return cb()
if (!v?.trim()) return cb(new Error('请输入验证码'))
cb()
}, trigger: ['blur', 'change']
},
],
}
async function userLogin() {
if (loading.value) return
formRef.value?.validate(async (valid) => {
if (!valid) return
loading.value = true
try {
const payload = mode.value === 'login'
? {
phone: form.phone.trim(),
password: form.password.trim(),
}
: {
phone: form.phone.trim(),
name: form.name.trim(),
password: form.password.trim(),
code: form.code.trim(),
invitationCode: form.invitationCode.trim()
}
const res = await login(payload)
const data = res.data
if (data?.success) {
if (mode.value === 'login') {
try { setToken(data.data) } catch { }
ElMessage.success('登录成功')
router.push('/class')
} else {
ElMessage.success('注册成功')
mode.value = 'login'
}
} else {
ElMessage.error(data?.message || '登录失败')
}
} finally {
loading.value = false
}
})
}
function switchMode(m) {
mode.value = m
formRef.value?.clearValidate()
}
async function sendCode() {
if (!form.phone || codeDisabled.value) return
codeDisabled.value = true
try {
await getVerificationCode({ phone: form.phone.trim() })
ElMessage.success('验证码已发送')
let count = 60
codeBtnText.value = `${count}s`
timer = setInterval(() => {
count -= 1
if (count <= 0) {
clearInterval(timer)
timer = null
codeDisabled.value = false
codeBtnText.value = '发送验证码'
} else {
codeBtnText.value = `${count}s`
}
}, 1000)
} catch {
codeDisabled.value = false
codeBtnText.value = '发送验证码'
}
}
</script>
<style scoped></style>

View File

@@ -31,6 +31,29 @@
</div> </div>
</el-card> </el-card>
<el-card class="mt-4">
<template #header>
<div class="flex items-center justify-between">
<span>生成邀请码</span>
</div>
</template>
<el-form :model="inviteForm" :rules="inviteRules" ref="inviteFormRef" label-width="120px">
<el-form-item label="使用次数限制" prop="limit">
<el-input-number v-model="inviteForm.limit" :min="1" :max="9999" />
</el-form-item>
<el-form-item label="有效期(天)" prop="expire">
<el-input-number v-model="inviteForm.expire" :min="1" :max="3650" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="inviteLoading" @click="submitInvite">生成邀请码</el-button>
<el-button class="ml-2" @click="resetInvite">重置</el-button>
</el-form-item>
</el-form>
<div v-if="inviteCode" class="mt-2">
<el-alert type="success" :closable="false" :title="`邀请码:${inviteCode}`" show-icon />
</div>
</el-card>
<el-dialog v-model="createVisible" title="新增用户" width="420px"> <el-dialog v-model="createVisible" title="新增用户" width="420px">
<el-form :model="createForm" :rules="rules" ref="createFormRef" label-width="80px"> <el-form :model="createForm" :rules="rules" ref="createFormRef" label-width="80px">
<el-form-item label="姓名" prop="name"> <el-form-item label="姓名" prop="name">
@@ -57,7 +80,7 @@
<script setup> <script setup>
import Header from '@/layouts/components/Header.vue' import Header from '@/layouts/components/Header.vue'
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { getUserList, createUser } from '@/api/admin' import { getUserList, createUser, createInvitationCode } from '@/api/admin'
import { showMessage } from '@/composables/util.js' import { showMessage } from '@/composables/util.js'
const loading = ref(false) const loading = ref(false)
@@ -144,4 +167,42 @@ onMounted(() => {
fetchList() fetchList()
}) })
const inviteForm = reactive({ limit: 10, expire: 3 })
const inviteFormRef = ref()
const inviteLoading = ref(false)
const inviteCode = ref('')
const inviteRules = {
limit: [{ required: true, message: '请输入使用次数限制', trigger: 'change' }],
expire: [{ required: true, message: '请输入有效期', trigger: 'change' }],
}
function resetInvite() {
inviteForm.limit = 10
inviteForm.expire = 3
inviteCode.value = ''
}
function submitInvite() {
inviteFormRef.value?.validate(async (valid) => {
if (!valid) return
inviteLoading.value = true
try {
const r = await createInvitationCode({ limit: inviteForm.limit, expire: inviteForm.expire })
const d = r?.data
if (d?.success) {
inviteCode.value = d?.data?.invitationCode || ''
if (inviteCode.value) {
showMessage('邀请码生成成功', 'success')
} else {
showMessage('生成成功,但未返回邀请码', 'warning')
}
} else {
showMessage(d?.message || '邀请码生成失败', 'error')
}
} finally {
inviteLoading.value = false
}
})
}
</script> </script>

View File

@@ -35,7 +35,7 @@
</div> </div>
</div> </div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 lg:col-span-1 lg:row-span-1"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 lg:col-span-1 lg:row-span-2">
<div class="text-lg font-semibold mb-4">学生查询</div> <div class="text-lg font-semibold mb-4">学生查询</div>
<div class="flex flex-wrap items-center gap-3 mb-4"> <div class="flex flex-wrap items-center gap-3 mb-4">
<el-input v-model="studentName" placeholder="按姓名查询" clearable style="max-width: 220px" /> <el-input v-model="studentName" placeholder="按姓名查询" clearable style="max-width: 220px" />
@@ -96,56 +96,12 @@
row-key="id" :current-row-key="selectedGradeId" @row-click="onGradeRowClick"> row-key="id" :current-row-key="selectedGradeId" @row-click="onGradeRowClick">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="年级名称" min-width="160" /> <el-table-column prop="title" label="年级名称" min-width="160" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="danger" size="small"
@click.stop="onDeleteGrade(row)">删除</el-button>
</template>
</el-table-column>
</el-table> </el-table>
<div class="mt-4 flex justify-end"> <div class="mt-4 flex justify-end">
<el-pagination background layout="prev, pager, next, sizes, total" :total="gradeTotalCount" <el-pagination background layout="prev, pager, next, sizes, total" :total="gradeTotalCount"
:page-size="gradePageSize" :current-page="gradePageNo" :page-size="gradePageSize" :current-page="gradePageNo"
@current-change="handleGradePageChange" @size-change="handleGradeSizeChange" /> @current-change="handleGradePageChange" @size-change="handleGradeSizeChange" />
</div> </div>
<div class="mt-3 flex justify-end">
<el-button type="primary" @click="showAddGradeDialog = true">新增年级</el-button>
</div>
<AddGradeDialog v-model="showAddGradeDialog" @success="fetchGrades" />
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6" v-loading="unitLoading">
<div class="text-lg font-semibold mb-4">单元列表</div>
<el-table ref="unitTableRef" :data="units" border class="w-full">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="单元名称" min-width="200" />
<el-table-column prop="version" label="版本" min-width="120" />
<el-table-column prop="createAt" label="创建时间" min-width="160" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="danger" size="small" @click.stop="onDeleteUnit(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt-4 flex justify-end">
<el-pagination
background
layout="prev, pager, next, sizes, total"
:total="unitTotalCount"
:page-size="unitPageSize"
:current-page="unitPageNo"
@current-change="handleUnitPageChange"
@size-change="handleUnitSizeChange"
/>
</div>
<div class="mt-3 flex justify-end">
<el-button type="primary" :disabled="!selectedGradeId" @click="showAddUnitDialog = true">新增单元</el-button>
</div>
<AddUnitDialog
v-model="showAddUnitDialog"
:default-grade-id="selectedGradeId"
@success="fetchUnits"
/>
</div> </div>
</div> </div>
@@ -169,6 +125,7 @@ import LessonPlanDialog from '@/layouts/components/LessonPlanDialog.vue'
import { getUnitList, deleteUnit } from '@/api/unit' import { getUnitList, deleteUnit } from '@/api/unit'
import AddUnitDialog from '@/layouts/components/AddUnitDialog.vue' import AddUnitDialog from '@/layouts/components/AddUnitDialog.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
const classes = ref([]) const classes = ref([])
const pageNo = ref(1) const pageNo = ref(1)
@@ -311,6 +268,11 @@ function onGradeRowClick(row) {
} }
async function onDeleteStudent(row) { async function onDeleteStudent(row) {
try { try {
await ElMessageBox.confirm('确认删除该学生?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await deleteStudent(row.id) await deleteStudent(row.id)
ElMessage.success('删除成功') ElMessage.success('删除成功')
if (selectedStudentIds.value?.length) { if (selectedStudentIds.value?.length) {
@@ -319,11 +281,17 @@ async function onDeleteStudent(row) {
} }
await fetchStudents() await fetchStudents()
} catch (e) { } catch (e) {
if (e === 'cancel' || e === 'close') return
ElMessage.error('删除失败') ElMessage.error('删除失败')
} }
} }
async function onDeleteClass(row) { async function onDeleteClass(row) {
try { try {
await ElMessageBox.confirm('确认删除该班级?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await deleteClass(row.id) await deleteClass(row.id)
ElMessage.success('删除成功') ElMessage.success('删除成功')
if (selectedClassId.value === row.id) { if (selectedClassId.value === row.id) {
@@ -333,6 +301,7 @@ async function onDeleteClass(row) {
} }
await fetchClasses() await fetchClasses()
} catch (e) { } catch (e) {
if (e === 'cancel' || e === 'close') return
ElMessage.error('删除失败') ElMessage.error('删除失败')
} }
} }

View File

@@ -56,7 +56,13 @@
生成学习分析 生成学习分析
</el-button> </el-button>
</div> </div>
<template v-if="analysisHtml"> <template v-if="analyzeLoading">
<div class="space-y-2">
<el-progress :percentage="analyzeProgress" :stroke-width="10" />
<div class="text-sm text-gray-500 dark:text-gray-400">正在生成学习分析请稍候</div>
</div>
</template>
<template v-else-if="analysisHtml">
<div class="leading-7 text-gray-700 dark:text-gray-200" v-html="analysisHtml"></div> <div class="leading-7 text-gray-700 dark:text-gray-200" v-html="analysisHtml"></div>
</template> </template>
<template v-else> <template v-else>
@@ -87,6 +93,8 @@ const detail = ref(null)
const route = useRoute() const route = useRoute()
const history = ref([]) const history = ref([])
const analyzeLoading = ref(false) const analyzeLoading = ref(false)
const analyzeProgress = ref(0)
let analyzeTimer = null
const analysisText = ref('') const analysisText = ref('')
const wordStat = ref(null) const wordStat = ref(null)
const md = new MarkdownIt({ const md = new MarkdownIt({
@@ -125,6 +133,16 @@ async function fetchStudyAnalyze() {
const id = route.params.id const id = route.params.id
if (!id) return if (!id) return
analyzeLoading.value = true analyzeLoading.value = true
analyzeProgress.value = 0
if (analyzeTimer) {
clearInterval(analyzeTimer)
analyzeTimer = null
}
analyzeTimer = setInterval(() => {
const inc = Math.floor(Math.random() * 8) + 3
const next = analyzeProgress.value + inc
analyzeProgress.value = next >= 90 ? 90 : next
}, 300)
try { try {
const res = await getStudentStudyAnalyze({ const res = await getStudentStudyAnalyze({
studentId: Number(id) studentId: Number(id)
@@ -133,6 +151,11 @@ async function fetchStudyAnalyze() {
const raw = typeof d?.data === 'string' ? d.data : '' const raw = typeof d?.data === 'string' ? d.data : ''
analysisText.value = raw.replace(/\\n/g, '\n') analysisText.value = raw.replace(/\\n/g, '\n')
} finally { } finally {
analyzeProgress.value = 100
if (analyzeTimer) {
clearInterval(analyzeTimer)
analyzeTimer = null
}
analyzeLoading.value = false analyzeLoading.value = false
} }
} }

View File

@@ -2,12 +2,22 @@ import router from '@/router/index'
import { showMessage } from '@/composables/util' import { showMessage } from '@/composables/util'
import { showPageLoading, hidePageLoading } from '@/composables/util' import { showPageLoading, hidePageLoading } from '@/composables/util'
import { getToken } from '@/composables/auth'
// 全局路由前置守卫 // 全局路由前置守卫
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
console.log('==> 全局路由前置守卫') console.log('==> 全局路由前置守卫')
// 展示页面加载 Loading // 展示页面加载 Loading
showPageLoading() showPageLoading()
const token = getToken()
if (!token && to.path !== '/login') {
next({ path: '/login' })
return
}
if (token && to.path === '/login') {
next({ path: '/class' })
return
}
next() next()
}) })

View File

@@ -5,14 +5,15 @@ import { createRouter, createWebHashHistory } from 'vue-router'
import Admid from '@/pages/admid/admid.vue' import Admid from '@/pages/admid/admid.vue'
import Student from '@/pages/student.vue' import Student from '@/pages/student.vue'
import PlanTTS from '@/pages/PlanTTS.vue' import PlanTTS from '@/pages/PlanTTS.vue'
import Login from '@/pages/Login.vue'
// 统一在这里声明所有路由 // 统一在这里声明所有路由
const routes = [ const routes = [
{ {
path: '/', // 路由地址 path: '/class',
component: Class, // 对应组件 component: Class,
meta: { // meta 信息 meta: {
title: '班级' // 页面标题 title: '班级'
} }
}, },
{ {
@@ -43,6 +44,13 @@ const routes = [
title: '管理员页面' // 页面标题 title: '管理员页面' // 页面标题
} }
}, },
{
path: '/login',
component: Login,
meta: {
title: '登录'
}
},
{ {
path: '/plan/tts', path: '/plan/tts',
component: PlanTTS, component: PlanTTS,