Compare commits

...

14 Commits
main ... dev

33 changed files with 1066 additions and 239 deletions

View File

@ -80,7 +80,7 @@
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.25</version>
<version>2.0.53</version>
</dependency>
<!-- Jwt令牌生成校验框架 -->
<dependency>

View File

@ -1,7 +1,9 @@
package com.example.config;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.QueueBuilder;
import org.springframework.amqp.core.*;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -10,10 +12,42 @@ import org.springframework.context.annotation.Configuration;
*/
@Configuration
public class RabbitConfiguration {
@Bean
public MessageConverter jsonMessageConverter() {
return new Jackson2JsonMessageConverter();
}
@Bean("errorQueue")
public Queue dlQueue(){
return QueueBuilder
.durable("error")
.ttl(24 * 60 * 60 * 1000)
.build();
}
@Bean("errorExchange")
public Exchange dlExchange(){
return ExchangeBuilder.directExchange("dlx.direct").build();
}
@Bean("dlBinding") //死信交换机和死信队列进绑定
public Binding dlBinding(@Qualifier("errorExchange") Exchange exchange,
@Qualifier("errorQueue") Queue queue){
return BindingBuilder
.bind(queue)
.to(exchange)
.with("error-message")
.noargs();
}
@Bean("mailQueue")
public Queue queue(){
return QueueBuilder
.durable("mail")
.deadLetterExchange("dlx.direct")
.deadLetterRoutingKey("error-message")
.ttl(3 * 60 * 1000)
.build();
}
}

View File

@ -56,7 +56,8 @@ public class SecurityConfiguration {
.requestMatchers("/api/auth/**", "/error").permitAll()
.requestMatchers("/images/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().hasAnyRole(Const.ROLE_DEFAULT)
.requestMatchers("/api/admin/**").hasRole(Const.ROLE_ADMIN)
.anyRequest().hasAnyRole(Const.ROLE_DEFAULT, Const.ROLE_ADMIN)
)
.formLogin(conf -> conf
.loginProcessingUrl("/api/auth/login")
@ -104,6 +105,10 @@ public class SecurityConfiguration {
} else if(exceptionOrAuthentication instanceof Authentication authentication){
User user = (User) authentication.getPrincipal();
Account account = service.findAccountByNameOrEmail(user.getUsername());
if(account.isBanned()){
writer.write(RestBean.forbidden("登录失败,此账户已被封禁,请俩系管理员").asJsonString());
return;
}
String jwt = utils.createJwt(user, account.getUsername(), account.getId());
if(jwt == null) {
writer.write(RestBean.forbidden("登录验证频繁,请稍后再试").asJsonString());

View File

@ -1,6 +1,5 @@
package com.example.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;

View File

@ -1,11 +1,13 @@
package com.example.controller;
import com.example.entity.RestBean;
import com.example.entity.dto.Account;
import com.example.entity.dto.Interact;
import com.example.entity.vo.request.AddCommentVO;
import com.example.entity.vo.request.TopicCreateVO;
import com.example.entity.vo.request.TopicUpdateVO;
import com.example.entity.vo.response.*;
import com.example.service.AccountService;
import com.example.service.TopicService;
import com.example.service.WeatherService;
import com.example.utils.Const;
@ -29,6 +31,9 @@ public class ForumController {
@Resource
TopicService topicService;
@Resource
AccountService accountService;
@Resource
ControllerUtils utils;
@ -51,6 +56,10 @@ public class ForumController {
@PostMapping("/create-topic")
public RestBean<Void> createTopic(@Valid @RequestBody TopicCreateVO vo,
@RequestAttribute(Const.ATTR_USER_ID) int id) {
Account account = accountService.findAccountById(id);
if (account.isMute()) {
return RestBean.forbidden("您已被禁言,无法创建新的主题");
}
return utils.messageHandle(() -> topicService.createTopic(id, vo));
}
@ -94,6 +103,10 @@ public class ForumController {
@PostMapping("/add-comment")
public RestBean<Void> addComment(@Valid @RequestBody AddCommentVO vo,
@RequestAttribute(Const.ATTR_USER_ID) int id){
Account account = accountService.findAccountById(id);
if (account.isMute()) {
return RestBean.forbidden("您已被禁言,无法创建新的回复");
}
return utils.messageHandle(() -> topicService.createComment(id, vo));
}

View File

@ -0,0 +1,86 @@
package com.example.controller.admin;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.entity.RestBean;
import com.example.entity.dto.Account;
import com.example.entity.dto.AccountDetails;
import com.example.entity.dto.AccountPrivacy;
import com.example.entity.vo.response.AccountVO;
import com.example.service.AccountDetailsService;
import com.example.service.AccountPrivacyService;
import com.example.service.AccountService;
import com.example.utils.Const;
import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/api/admin/user")
public class AccountAdminController {
@Resource
AccountService service;
@Resource
AccountDetailsService detailsService;
@Resource
AccountPrivacyService privacyService;
@Resource
StringRedisTemplate template;
@Value("${spring.security.jwt.expire}")
private int expire;
@GetMapping("/list")
public RestBean<JSONObject> accountList(int page, int size) {
JSONObject object = new JSONObject();
List<AccountVO> list = service.page(Page.of(page, size))
.getRecords()
.stream()
.map(a -> a.asViewObject(AccountVO.class))
.toList();
object.put("total", service.count());
object.put("list", list);
return RestBean.success(object);
}
@GetMapping("/detail")
public RestBean<JSONObject> accountDetail(int id) {
JSONObject object = new JSONObject();
object.put("detail", detailsService.findAccountDetailsById(id));
object.put("privacy", privacyService.accountPrivacy(id));
return RestBean.success(object);
}
@PostMapping("/save")
public RestBean<Void> saveAccount(@RequestBody JSONObject object) {
int id = object.getInteger("id");
Account account = service.findAccountById(id);
Account save = object.toJavaObject(Account.class);
handleBanned(account, save);
BeanUtils.copyProperties(save, account, "password", "registerTime");
service.saveOrUpdate(account);
AccountDetails saveDetails = object.getJSONObject("detail").toJavaObject(AccountDetails.class);
detailsService.saveOrUpdate(saveDetails);
AccountPrivacy savePrivacy = object.getJSONObject("privacy").toJavaObject(AccountPrivacy.class);
privacyService.saveOrUpdate(savePrivacy);
return RestBean.success();
}
private void handleBanned(Account old, Account current) {
String key = Const.BANNED_BLOCK + old.getId();
if(old.isBanned() && !current.isBanned()) {
template.delete(key);
} else if(!old.isBanned() && current.isBanned()) {
template.opsForValue().set(key, "true", expire, TimeUnit.HOURS);
}
}
}

View File

@ -0,0 +1,30 @@
package com.example.entity;
import lombok.Getter;
import lombok.ToString;
import java.util.HashMap;
import java.util.Map;
@Getter
@ToString
public class QueueMessage {
private String messageType;
private final Map<String, Object> data = new HashMap<>();
public static QueueMessage create(String messageType) {
QueueMessage queueMessage = new QueueMessage();
queueMessage.messageType = messageType;
return queueMessage;
}
public QueueMessage put(String key, Object value) {
data.put(key, value);
return this;
}
@SuppressWarnings("unchecked")
public <T> T get(String key) {
return (T) data.get(key);
}
}

View File

@ -24,4 +24,6 @@ public class Account implements BaseData {
String role;
String avatar;
Date registerTime;
boolean mute;
boolean banned;
}

View File

@ -0,0 +1,30 @@
package com.example.entity.dto;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.Date;
@Data
@Accessors(chain = true)
@TableName("db_verify_email")
public class VerifyEmail {
@TableId(type = IdType.AUTO)
Integer id;
String email;
String type;
String code;
Date time;
boolean success;
public static VerifyEmail success() {
return new VerifyEmail().setSuccess(true);
}
public static VerifyEmail failure() {
return new VerifyEmail().setSuccess(false);
}
}

View File

@ -12,4 +12,6 @@ public class AccountVO {
String role;
String avatar;
Date registerTime;
boolean mute;
boolean banned;
}

View File

@ -8,6 +8,7 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
@ -27,6 +28,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Resource
JwtUtils utils;
@Resource
private StringRedisTemplate template;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
@ -35,11 +39,15 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
DecodedJWT jwt = utils.resolveJwt(authorization);
if(jwt != null) {
UserDetails user = utils.toUser(jwt);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
request.setAttribute(Const.ATTR_USER_ID, utils.toId(jwt));
if(!template.hasKey(Const.BANNED_BLOCK + utils.toId(jwt))) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
request.setAttribute(Const.ATTR_USER_ID, utils.toId(jwt));
} else {
utils.invalidateJwt(authorization);
}
}
filterChain.doFilter(request, response);
}

View File

@ -0,0 +1,37 @@
package com.example.listener;
import com.example.entity.QueueMessage;
import com.example.entity.dto.VerifyEmail;
import com.example.mapper.VerifyEmailMapper;
import com.example.utils.Const;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.util.Date;
@Slf4j
@Component
@RabbitListener(queues = Const.MQ_ERROR)
public class ErrorQueueListener {
@Resource
VerifyEmailMapper mapper;
@RabbitHandler
public void saveErrorToDatabase(QueueMessage message) {
log.error("出现一条错误的队列消息: {}", message);
switch (message.getMessageType()) {
case "email" -> {
VerifyEmail error = VerifyEmail.failure()
.setCode(message.get("code").toString())
.setType(message.get("type"))
.setEmail(message.get("email"))
.setTime(new Date());
mapper.insert(error);
}
}
}
}

View File

@ -1,6 +1,11 @@
package com.example.listener;
import com.example.entity.QueueMessage;
import com.example.entity.dto.VerifyEmail;
import com.example.mapper.VerifyEmailMapper;
import com.example.utils.Const;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Value;
@ -8,30 +13,34 @@ import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Date;
/**
* 用于处理邮件发送的消息队列监听器
*/
@Slf4j
@Component
@RabbitListener(queues = "mail")
@RabbitListener(queues = Const.MQ_MAIL, concurrency = "10")
public class MailQueueListener {
@Resource
JavaMailSender sender;
@Resource
VerifyEmailMapper emailMapper;
@Value("${spring.mail.username}")
String username;
/**
* 处理邮件发送
* @param data 邮件信息
* @param message 邮件信息
*/
@RabbitHandler
public void sendMailMessage(Map<String, Object> data) {
String email = data.get("email").toString();
Integer code = (Integer) data.get("code");
SimpleMailMessage message = switch (data.get("type").toString()) {
public void sendMailMessage(QueueMessage message) {
String email = message.get("email"), type = message.get("type");
Integer code = message.get("code");
SimpleMailMessage mailMessage = switch (type) {
case "register" ->
createMessage("欢迎注册我们的网站",
"您的邮件注册验证码为: "+code+"有效时间3分钟为了保障您的账户安全请勿向他人泄露验证码信息。",
@ -46,8 +55,15 @@ public class MailQueueListener {
email);
default -> null;
};
if(message == null) return;
sender.send(message);
if(mailMessage == null) return;
log.info("正在向 {} 发送 {} 类型的电子邮件...", email, type);
sender.send(mailMessage);
VerifyEmail record = VerifyEmail.success()
.setCode(message.get("code").toString())
.setType(message.get("type"))
.setEmail(message.get("email"))
.setTime(new Date());
emailMapper.insert(record);
}
/**

View File

@ -0,0 +1,9 @@
package com.example.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.entity.dto.VerifyEmail;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface VerifyEmailMapper extends BaseMapper<VerifyEmail> {
}

View File

@ -2,6 +2,7 @@ package com.example.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.entity.QueueMessage;
import com.example.entity.dto.Account;
import com.example.entity.dto.AccountDetails;
import com.example.entity.dto.AccountPrivacy;
@ -23,7 +24,6 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@ -86,8 +86,9 @@ public class AccountServiceImpl extends ServiceImpl<AccountMapper, Account> impl
return "请求频繁,请稍后再试";
Random random = new Random();
int code = random.nextInt(899999) + 100000;
Map<String, Object> data = Map.of("type",type,"email", email, "code", code);
rabbitTemplate.convertAndSend(Const.MQ_MAIL, data);
QueueMessage message = QueueMessage.create("email");
message.put("type",type).put("email", email).put("code", code);
rabbitTemplate.convertAndSend(Const.MQ_MAIL, message);
stringRedisTemplate.opsForValue()
.set(Const.VERIFY_EMAIL_DATA + email, String.valueOf(code), 3, TimeUnit.MINUTES);
return null;
@ -109,7 +110,7 @@ public class AccountServiceImpl extends ServiceImpl<AccountMapper, Account> impl
if(this.existsAccountByUsername(username)) return "该用户名已被他人使用,请重新更换";
String password = passwordEncoder.encode(info.getPassword());
Account account = new Account(null, info.getUsername(),
password, email, Const.ROLE_DEFAULT, null, new Date());
password, email, Const.ROLE_DEFAULT, null, new Date(), false, false);
if(!this.save(account)) {
return "内部错误,注册失败";
} else {

View File

@ -10,6 +10,7 @@ public final class Const {
//请求频率限制
public final static String FLOW_LIMIT_COUNTER = "flow:counter:";
public final static String FLOW_LIMIT_BLOCK = "flow:block:";
public final static String BANNED_BLOCK = "banned:block:";
//邮件验证码
public final static String VERIFY_EMAIL_LIMIT = "verify:email:limit:";
public final static String VERIFY_EMAIL_DATA = "verify:email:data:";
@ -20,8 +21,10 @@ public final class Const {
public final static String ATTR_USER_ID = "userId";
//消息队列
public final static String MQ_MAIL = "mail";
public final static String MQ_ERROR = "error";
//用户角色
public final static String ROLE_DEFAULT = "user";
public final static String ROLE_ADMIN = "admin";
//论坛相关
public final static String FORUM_WEATHER_CACHE = "weather:cache:";
public final static String FORUM_IMAGE_COUNTER = "forum:image:";

View File

@ -7,12 +7,18 @@ spring:
mail:
host: smtp.163.com
username: javastudy111@163.com
password: QGLYGBVECIASGPWQ
password: SFxfE39P9Wf2icKi
rabbitmq:
addresses: localhost
username: admin
password: admin
virtual-host: /
listener:
simple:
retry:
enabled: true
max-attempts: 3
initial-interval: 1000ms
datasource:
url: jdbc:mysql://localhost:3306/study
username: root
@ -44,4 +50,4 @@ spring:
username: 'minio'
password: 'password'
weather:
key: 7abbc24d3b4443b597a3b3c676e0f221
key: 115bcaf74fb24f73844365290d85862b

View File

@ -1,5 +1,9 @@
<script setup>
import { useDark, useToggle } from '@vueuse/core'
import {onMounted, provide, ref} from "vue";
import {isUnauthorized} from "@/net";
import {apiUserInfo} from "@/net/api/user";
import zhCn from 'element-plus/es/locale/lang/zh-cn'
useDark({
selector: 'html',
@ -8,22 +12,30 @@ useDark({
valueLight: 'light'
})
const loading = ref()
provide('userLoading', loading)
useDark({
onChanged(dark) { useToggle(dark) }
})
onMounted(() => {
if(!isUnauthorized()) {
apiUserInfo(loading)
}
})
</script>
<template>
<header>
<div class="wrapper">
<router-view/>
</div>
</header>
<el-config-provider :locale="zhCn">
<div class="wrapper">
<router-view/>
</div>
</el-config-provider>
</template>
<style scoped>
header {
.wrapper {
line-height: 1.5;
}
</style>

View File

@ -0,0 +1,120 @@
<script setup>
import {EditPen} from "@element-plus/icons-vue";
import {reactive} from "vue";
import {apiUserDetailTotal, apiUserSave} from "@/net/api/user";
import {ElMessage} from "element-plus";
const editor = reactive({
id: 0,
display: false,
temp: {},
loading: false,
})
function loadUserEditor(user) {
editor.id = user.id
editor.display = true
editor.loading = true
apiUserDetailTotal(editor.id, data => {
editor.temp = { ...data, ...user }
editor.loading = false
})
}
defineExpose({ loadUserEditor })
function saveUserSettings() {
editor.display = false
apiUserSave(editor.temp, () => {
const user = userTable.data.find(user => user.id === editor.id)
Object.assign(user, editor.temp)
ElMessage.success('数据保存成功')
})
}
</script>
<template>
<el-drawer v-model="editor.display" size="380" :close-on-click-modal="false">
<template #header>
<div>
<div style="font-weight: bold">
<el-icon><EditPen/></el-icon>
编辑用户信息
</div>
<div style="font-size: 13px">编辑完成后请点击下方保存按钮</div>
</div>
</template>
<div v-loading="editor.loading" element-loading-text="数据加载中,请稍后..." style="height: 100%">
<el-form label-position="top" v-if="!editor.loading">
<el-form-item label="用户名">
<el-input v-model="editor.temp.username"/>
</el-form-item>
<el-form-item label="电子邮件">
<el-input v-model="editor.temp.email"/>
</el-form-item>
<div style="display: flex;font-size: 14px">
<div>
<span style="margin-right: 10px">禁言</span>
<el-switch v-model="editor.temp.mute"/>
</div>
<el-divider direction="vertical" style="height: 30px;margin: 0 20px"/>
<div>
<span style="margin-right: 10px">账号封禁</span>
<el-switch v-model="editor.temp.banned"/>
</div>
</div>
<div style="margin-top: 20px;color: #606266;font-size: 14px">
注册时间: {{ new Date(editor.temp.registerTime).toLocaleString() }}
</div>
<el-divider direction="horizontal"/>
<el-form-item label="性别">
<el-radio-group v-model="editor.temp.detail.gender">
<el-radio :label="0"></el-radio>
<el-radio :label="1"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="editor.temp.detail.phone"/>
</el-form-item>
<el-form-item label="QQ账号">
<el-input v-model="editor.temp.detail.qq"/>
</el-form-item>
<el-form-item label="微信账号">
<el-input v-model="editor.temp.detail.wx"/>
</el-form-item>
<el-form-item label="个人简介">
<el-input type="textarea" :rows="4" v-model="editor.temp.detail.desc"/>
</el-form-item>
<el-divider direction="horizontal"/>
<div style="padding-bottom: 20px">
<div style="margin-bottom: 10px;">隐私设置</div>
<el-checkbox v-model="editor.temp.privacy.phone">
公开展示用户的手机号
</el-checkbox>
<el-checkbox v-model="editor.temp.privacy.email">
公开展示用户的电子邮件地址
</el-checkbox>
<el-checkbox v-model="editor.temp.privacy.wx">
公开展示用户的微信号
</el-checkbox>
<el-checkbox v-model="editor.temp.privacy.qq">
公开展示用户的QQ号
</el-checkbox>
<el-checkbox v-model="editor.temp.privacy.gender">
公开展示用户的性别
</el-checkbox>
</div>
</el-form>
</div>
<template #footer>
<div style="text-align: center">
<el-button type="success" @click="saveUserSettings">保存</el-button>
<el-button type="info" @click="editor.display = false">取消</el-button>
</div>
</template>
</el-drawer>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,90 @@
<script setup>
import router from "@/router";
import {Back, Message, Operation, Right} from "@element-plus/icons-vue";
import {useStore} from "@/store";
import {logout} from "@/net";
defineProps({
admin: Boolean
})
const store = useStore()
function userLogout() {
logout(() => router.push("/"))
}
</script>
<template>
<div class="user-info">
<template v-if="store.isAdmin">
<el-button type="primary" size="small"
@click="router.push('/index')"
v-if="admin">
回到用户端
<el-icon style="margin-left: 5px">
<Right/>
</el-icon>
</el-button>
<el-button type="danger" size="small"
@click="router.push('/admin')"
v-else>
前往管理端
<el-icon style="margin-left: 5px">
<Right/>
</el-icon>
</el-button>
</template>
<template/>
<div class="profile">
<div>{{ store.user.username }}</div>
<div>{{ store.user.email }}</div>
</div>
<el-dropdown>
<el-avatar :src="store.avatarUrl"/>
<template #dropdown>
<el-dropdown-item>
<el-icon>
<Operation/>
</el-icon>
个人设置
</el-dropdown-item>
<el-dropdown-item>
<el-icon>
<Message/>
</el-icon>
消息列表
</el-dropdown-item>
<el-dropdown-item @click="userLogout" divided>
<el-icon>
<Back/>
</el-icon>
退出登录
</el-dropdown-item>
</template>
</el-dropdown>
</div>
</template>
<style scoped>
.user-info {
display: flex;
gap: 20px;
align-items: center;
.profile {
text-align: right;
:first-child {
font-size: 18px;
font-weight: bold;
line-height: 20px;
}
:last-child {
font-size: 10px;
color: grey;
}
}
}
</style>

View File

@ -0,0 +1,40 @@
import {get, post} from "@/net";
import {useStore} from "@/store";
export const apiUserInfo = (loadingRef) => {
if(loadingRef) loadingRef.value = true
get('/api/user/info', (data) => {
const store = useStore();
store.user = data
if(loadingRef) loadingRef.value = false
})
}
export const apiUserChangePassword = (form, success) =>
post('/api/user/change-password', form, success)
export const apiUserPrivacy = (success) =>
get('/api/user/privacy', success)
export const apiUserPrivacySave = (data, loadingRef, success) => {
loadingRef.value = true
post('/api/user/save-privacy', data, () => {
loadingRef.value = false
success()
})
}
export const apiUserDetailSave = (form, success, failure) =>
post('/api/user/save-details', form, success, failure)
export const apiUserDetail = (success) =>
get('/api/user/details', success)
export const apiUserList = (page, size, success) =>
get(`/api/admin/user/list?page=${page}&size=${size}`, success)
export const apiUserDetailTotal = (id, success) =>
get(`api/admin/user/detail?id=${id}`, success)
export const apiUserSave = (data, success) =>
post('/api/admin/user/save', data, success)

View File

@ -6,7 +6,7 @@ const authItemName = "authorize"
const accessHeader = () => {
return {
'Authorization': `Bearer ${takeAccessToken()}`
'Authorization': `Bearer ${takeAccessToken()?.token}`
}
}
@ -34,14 +34,11 @@ function takeAccessToken() {
ElMessage.warning("登录状态已过期,请重新登录!")
return null
}
return authObj.token
return authObj
}
function storeAccessToken(remember, token, expire){
const authObj = {
token: token,
expire: expire
}
function storeAccessToken(remember, token, expire, role){
const authObj = { token, expire, role }
const str = JSON.stringify(authObj)
if(remember)
localStorage.setItem(authItemName, str)
@ -90,7 +87,7 @@ function login(username, password, remember, success, failure = defaultFailure){
}, {
'Content-Type': 'application/x-www-form-urlencoded'
}, (data) => {
storeAccessToken(remember, data.token, data.expire)
storeAccessToken(remember, data.token, data.expire, data.role)
ElMessage.success(`登录成功,欢迎 ${data.username} 来到我们的系统`)
success(data)
}, failure)
@ -112,8 +109,12 @@ function get(url, success, failure = defaultFailure) {
internalGet(url, accessHeader(), success, failure)
}
function unauthorized() {
function isUnauthorized() {
return !takeAccessToken()
}
export { post, get, login, logout, unauthorized, accessHeader }
function isAdminRole() {
return takeAccessToken()?.role === 'admin'
}
export { post, get, login, logout, isUnauthorized, isAdminRole, accessHeader }

View File

@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { unauthorized } from "@/net";
import {isAdminRole, isUnauthorized} from "@/net";
import {useStore} from "@/store";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -53,15 +54,40 @@ const router = createRouter({
component: () => import('@/views/settings/PrivacySetting.vue')
}
]
}, {
path: '/admin',
name: 'admin',
component: () => import('@/views/AdminView.vue'),
children: [
{
path: '',
name: 'admin-welcome',
component: () => import('@/views/admin/WelcomeAdmin.vue')
}, {
path: 'user',
name: 'admin-user',
component: () => import('@/views/admin/UserAdmin.vue')
}, {
path: 'forum',
name: 'admin-forum',
component: () => import('@/views/admin/ForumAdmin.vue')
}, {
path: 'email',
name: 'admin-email',
component: () => import('@/views/admin/EmailAdmin.vue')
}
]
}
]
})
router.beforeEach((to, from, next) => {
const isUnauthorized = unauthorized()
if(to.name.startsWith('welcome') && !isUnauthorized) {
const unauthorized = isUnauthorized(), admin = isAdminRole()
if(to.name.startsWith('welcome') && !unauthorized) {
next('/index')
} else if(to.fullPath.startsWith('/index') && isUnauthorized) {
} else if(to.fullPath.startsWith('/admin') && !admin) {
next('/index')
} else if(to.fullPath.startsWith('/index') && unauthorized) {
next('/')
} else {
next()

View File

@ -17,6 +17,9 @@ export const useStore = defineStore('general', {
}
}
}, getters: {
isAdmin() {
return this.user.role === 'admin'
},
avatarUrl() {
if(this.user.avatar)
return `${axios.defaults.baseURL}/images${this.user.avatar}`

View File

@ -0,0 +1,192 @@
<script setup>
import {
Bell,
ChatDotSquare, Collection,
DataLine,
Document,
Files,
Location, Message,
Monitor, Notification, Position, School,
Umbrella,
User
} from "@element-plus/icons-vue";
import UserInfo from "@/components/UserInfo.vue";
import {inject, onMounted, ref} from "vue";
import {useRoute} from "vue-router";
import router from "@/router";
const adminMenu = [
{
title: '校园论坛管理', icon: Location, sub: [
{title: '用户管理', icon: User, index: '/admin/user' },
{title: '邮件发信管理', icon: Message, index: '/admin/email' },
{title: '帖子广场管理', icon: ChatDotSquare, index: '/admin/forum' },
{title: '失物招领管理', icon: Bell},
{title: '校园活动管理', icon: Notification},
{title: '表白墙管理', icon: Umbrella},
{title: '合作机构管理', icon: School}
]
}, {
title: '探索与发现管理', icon: Position, sub: [
{title: '成绩查询管理', icon: Document},
{title: '班级课程表管理', icon: Files},
{title: '教务通知管理', icon: Monitor},
{title: '在线图书馆管理', icon: Collection},
{title: '预约教室管理', icon: DataLine}
]
}
]
const route = useRoute()
const loading = inject('userLoading')
const pageTabs = ref([])
function handleTabsClick({ props }) {
router.push(props.name)
}
function handleTabRemove(name) {
const index = pageTabs.value.findIndex(tab => tab.name === name)
const isCurrent = name === route.fullPath
pageTabs.value.splice(index, 1)
if(pageTabs.value.length > 0) {
//
if(isCurrent) {
router.push(pageTabs.value[Math.max(0, index - 1)].name) //
}
} else { //
router.push('/admin')
}
}
function openAdminTab(menu) {
if(!menu.index) return
if(pageTabs.value.findIndex(tab => tab.name === menu.index) < 0) {
pageTabs.value.push({
title: menu.title,
name: menu.index
})
}
}
onMounted(() => {
const initPage = adminMenu
.flatMap(menu => menu.sub)
.find(sub => sub.index === route.fullPath)
if(initPage) {
openAdminTab(initPage)
}
})
</script>
<template>
<div class="admin-content" v-loading="loading" element-loading-text="正在进入,请稍后...">
<el-container style="height: 100%">
<el-aside width="230px" class="admin-content-aside">
<div style="text-align: center;padding: 15px 0 10px;height: 32px">
<el-image class="logo" src="https://element-plus.org/images/element-plus-logo.svg"/>
</div>
<el-scrollbar style="height: calc(100% - 60px)">
<el-menu
router
:default-active="$route.path"
:default-openeds="['1', '2', '3']"
style="height: calc(100% - 60px);border-right: none">
<el-sub-menu :index="(index + 1).toString()"
v-for="(menu, index) in adminMenu">
<template #title>
<el-icon>
<component :is="menu.icon"/>
</el-icon>
<span><b>{{ menu.title }}</b></span>
</template>
<el-menu-item :index="subMenu.index"
@click="openAdminTab(subMenu)"
v-for="subMenu in menu.sub">
<template #title>
<el-icon>
<component :is="subMenu.icon"/>
</el-icon>
{{ subMenu.title }}
<el-tag style="margin-left: 10px" size="small"
:type="subMenu.tag.type"
v-if="subMenu.tag">{{ subMenu.tag.name }}
</el-tag>
</template>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-scrollbar>
</el-aside>
<el-container>
<el-header class="admin-content-header">
<div style="flex: 1">
<el-tabs :model-value="route.fullPath"
type="card"
closable
@tab-remove="handleTabRemove"
@tab-click="handleTabsClick">
<el-tab-pane
v-for="item in pageTabs"
:key="item.name"
:label="item.title"
:name="item.name">
{{ item.content }}
</el-tab-pane>
</el-tabs>
</div>
<user-info admin/>
</el-header>
<el-main>
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
</el-main>
</el-container>
</el-container>
</div>
</template>
<style lang="less" scoped>
.admin-content {
height: 100vh;
width: 100vw;
}
.admin-content-aside {
border-right: solid 1px var(--el-border-color);
.logo {
height: 32px;
}
}
.admin-content-header {
border-bottom: solid 1px var(--el-border-color);
height: 55px;
display: flex;
align-items: center;
box-sizing: border-box;
:deep(.el-tabs__header) {
height: 32px;
margin-bottom: 0;
border-bottom: none;
}
:deep(.el-tabs__nav) {
gap: 10px;
border: none;
}
:deep(.el-tabs__item) {
height: 32px;
padding: 0 15px !important;
border-radius: 6px;
border: solid 1px var(--el-border-color) !important;
}
}
</style>

View File

@ -1,23 +1,44 @@
<script setup>
import {get, logout} from '@/net'
import router from "@/router";
import {useStore} from "@/store";
import {reactive, ref} from "vue";
import {get} from '@/net'
import {inject, reactive, ref} from "vue";
import {
Back,
Bell,
ChatDotSquare, Check, Collection, DataLine,
Document, Files,
Location, Lock, Message, Monitor,
Location, Lock, Monitor,
Notification, Operation,
Position,
School, Search,
Umbrella, User
} from "@element-plus/icons-vue";
import LightCard from "@/components/LightCard.vue";
import UserInfo from "@/components/UserInfo.vue";
const store = useStore()
const loading = ref(true)
const userMenu = [
{ title: '校园论坛', icon: Location, sub: [
{ title: '帖子广场', index: '/index', icon: ChatDotSquare },
{ title: '失物招领', icon: Bell },
{ title: '校园活动', icon: Notification },
{ title: '表白墙', icon: Umbrella },
{ title: '海文考研', icon: School, tag: { name: '合作机构', type: '' } }
]
}, {
title: '探索与发现', icon: Position, sub: [
{ title: '成绩查询', icon: Document },
{ title: '班级课程表', icon: Files },
{ title: '教务通知', icon: Monitor },
{ title: '在线图书馆', icon: Collection },
{ title: '预约教室', icon: DataLine }
]
}, {
title: '个人设置', icon: Operation, sub: [
{ title: '个人信息设置', icon: User, index: '/index/user-setting' },
{ title: '账号安全设置', icon: Lock, index: '/index/privacy-setting' }
]
}
]
const loading = inject('userLoading')
const searchInput = reactive({
type: '1',
@ -25,18 +46,10 @@ const searchInput = reactive({
})
const notification = ref([])
get('/api/user/info', (data) => {
store.user = data
loading.value = false
})
const loadNotification =
() => get('/api/notification/list', data => notification.value = data)
loadNotification()
function userLogout() {
logout(() => router.push("/"))
}
function confirmNotification(id, url) {
get(`/api/notification/delete?id=${id}`, () => {
loadNotification()
@ -72,10 +85,10 @@ function deleteAllNotification() {
</template>
</el-input>
</div>
<div class="user-info">
<user-info>
<el-popover placement="bottom" :width="350" trigger="click">
<template #reference>
<el-badge style="margin-right: 15px" is-dot :hidden="!notification.length">
<el-badge is-dot :hidden="!notification.length">
<div class="notification">
<el-icon><Bell/></el-icon>
<div style="font-size: 10px">消息</div>
@ -101,34 +114,7 @@ function deleteAllNotification() {
style="width: 100%" plain>清除全部未读消息</el-button>
</div>
</el-popover>
<div class="profile">
<div>{{ store.user.username }}</div>
<div>{{ store.user.email }}</div>
</div>
<el-dropdown>
<el-avatar :src="store.avatarUrl"/>
<template #dropdown>
<el-dropdown-item>
<el-icon>
<Operation/>
</el-icon>
个人设置
</el-dropdown-item>
<el-dropdown-item>
<el-icon>
<Message/>
</el-icon>
消息列表
</el-dropdown-item>
<el-dropdown-item @click="userLogout" divided>
<el-icon>
<Back/>
</el-icon>
退出登录
</el-dropdown-item>
</template>
</el-dropdown>
</div>
</user-info>
</el-header>
<el-container>
<el-aside width="230px">
@ -138,127 +124,26 @@ function deleteAllNotification() {
:default-active="$route.path"
:default-openeds="['1', '2', '3']"
style="min-height: calc(100vh - 55px)">
<el-sub-menu index="1">
<template #title>
<el-icon>
<Location/>
</el-icon>
<span><b>校园论坛</b></span>
</template>
<el-menu-item index="/index">
<template #title>
<el-icon>
<ChatDotSquare/>
</el-icon>
帖子广场
</template>
</el-menu-item>
<el-menu-item>
<template #title>
<el-icon>
<Bell/>
</el-icon>
失物招领
</template>
</el-menu-item>
<el-menu-item>
<template #title>
<el-icon>
<Notification/>
</el-icon>
校园活动
</template>
</el-menu-item>
<el-menu-item>
<template #title>
<el-icon>
<Umbrella/>
</el-icon>
表白墙
</template>
</el-menu-item>
<el-menu-item>
<template #title>
<el-icon>
<School/>
</el-icon>
海文考研
<el-tag style="margin-left: 10px" size="small">合作机构</el-tag>
</template>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="2">
<template #title>
<el-icon>
<Position/>
</el-icon>
<span><b>探索与发现</b></span>
</template>
<el-menu-item>
<template #title>
<el-icon>
<Document/>
</el-icon>
成绩查询
</template>
</el-menu-item>
<el-menu-item>
<template #title>
<el-icon>
<Files/>
</el-icon>
班级课程表
</template>
</el-menu-item>
<el-menu-item>
<template #title>
<el-icon>
<Monitor/>
</el-icon>
教务通知
</template>
</el-menu-item>
<el-menu-item>
<template #title>
<el-icon>
<Collection/>
</el-icon>
在线图书馆
</template>
</el-menu-item>
<el-menu-item>
<template #title>
<el-icon>
<DataLine/>
</el-icon>
预约教室
</template>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="3">
<template #title>
<el-icon>
<Operation/>
</el-icon>
<span><b>个人设置</b></span>
</template>
<el-menu-item index="/index/user-setting">
<template #title>
<el-icon>
<User/>
</el-icon>
个人信息设置
</template>
</el-menu-item>
<el-menu-item index="/index/privacy-setting">
<template #title>
<el-icon>
<Lock/>
</el-icon>
账号安全设置
</template>
</el-menu-item>
</el-sub-menu>
<el-sub-menu :index="(index + 1).toString()"
v-for="(menu, index) in userMenu">
<template #title>
<el-icon>
<component :is="menu.icon"/>
</el-icon>
<span><b>{{ menu.title }}</b></span>
</template>
<el-menu-item :index="subMenu.index" v-for="subMenu in menu.sub">
<template #title>
<el-icon>
<component :is="subMenu.icon"/>
</el-icon>
{{ subMenu.title }}
<el-tag style="margin-left: 10px" size="small"
:type="subMenu.tag.type"
v-if="subMenu.tag">{{ subMenu.tag.name }}</el-tag>
</template>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-scrollbar>
</el-aside>
@ -320,9 +205,17 @@ function deleteAllNotification() {
.logo {
height: 32px;
width: 340px;
text-align: left;
:deep(.el-image__inner) {
width: 120px;
}
}
.user-info {
gap: 20px;
width: 340px;
display: flex;
justify-content: flex-end;
align-items: center;
@ -333,7 +226,6 @@ function deleteAllNotification() {
.profile {
text-align: right;
margin-right: 20px;
:first-child {
font-size: 18px;

View File

@ -0,0 +1,32 @@
<script setup>
import {Message} from "@element-plus/icons-vue";
</script>
<template>
<div class="email-admin">
<div class="title">
<el-icon><Message/></el-icon>
邮件发信列表
</div>
<div class="desc">在这里查看所有发送的电子邮件列表失败邮件可以选择重新发送</div>
</div>
</template>
<style lang="less" scoped>
.email-admin {
height: 100%;
display: flex;
flex-direction: column;
.title {
font-weight: bold;
}
.desc {
color: #bababa;
font-size: 13px;
margin-bottom: 20px;
}
}
</style>

View File

@ -0,0 +1,13 @@
<script setup>
</script>
<template>
<div>
我是帖子管理
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,116 @@
<script setup>
import {EditPen, User} from "@element-plus/icons-vue";
import {apiUserList} from "@/net/api/user";
import {reactive, ref, watchEffect} from "vue";
import {useStore} from "@/store";
import UserEditor from "@/components/UserEditor.vue";
const store = useStore()
const userTable = reactive({
page: 1,
size: 10,
total: 0,
data: []
})
const editorRef = ref()
function userStatus(user) {
if(user.mute && user.banned)
return '禁言中、封禁中'
else if(user.mute)
return '禁言中'
else if(user.banned)
return '封禁中'
else
return '正常'
}
watchEffect(() => apiUserList(userTable.page, userTable.size, data => {
userTable.total = data.total
userTable.data = data.list
}))
</script>
<template>
<div class="user-admin">
<div class="title">
<el-icon><User/></el-icon>
论坛用户列表
</div>
<div class="desc">在这里管理论坛的所有用户包括账号信息封禁和禁言</div>
<el-table :data="userTable.data" style="width: 100%;flex: 1">
<el-table-column prop="id" label="编号" width="80" />
<el-table-column label="用户名" width="180">
<template #default="{ row }">
<div class="table-username">
<el-avatar :size="30" :src="store.avatarUserUrl(row.avatar)"/>
<div>{{ row.username }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="角色" width="100" align="center">
<template #default="{ row }">
<el-tag type="danger" v-if="row.role === 'admin'">管理员</el-tag>
<el-tag v-else>普通用户</el-tag>
</template>
</el-table-column>
<el-table-column prop="email" label="电子邮件" />
<el-table-column label="注册时间">
<template #default="{ row }">
{{ new Date(row.registerTime).toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<div>{{ userStatus(row) }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" :icon="EditPen"
@click="editorRef.loadUserEditor(row)"
:disabled="store.user.id === row.id">编辑</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 20px;display: flex;justify-content: right">
<el-pagination style="width: fit-content"
:total="userTable.total"
v-model:current-page="userTable.page"
v-model:page-size="userTable.size"
layout="total, sizes, prev, pager, next, jumper"/>
</div>
<user-editor ref="editorRef"/>
</div>
</template>
<style lang="less" scoped>
.user-admin {
height: 100%;
display: flex;
flex-direction: column;
.title {
font-weight: bold;
}
.desc {
color: #bababa;
font-size: 13px;
margin-bottom: 20px;
}
.table-username {
height: 30px;
display: flex;
align-items: center;
gap: 15px;
}
:deep(.el-drawer__header) {
margin-bottom: 0;
}
}
</style>

View File

@ -0,0 +1,13 @@
<script setup>
</script>
<template>
<div>
我是管理端欢迎页
</div>
</template>
<style scoped>
</style>

View File

@ -3,15 +3,15 @@
import Card from "@/components/Card.vue";
import {Setting, Switch, Lock} from "@element-plus/icons-vue";
import {reactive, ref} from "vue";
import {get, post} from "@/net";
import {ElMessage} from "element-plus";
import {apiUserChangePassword, apiUserPrivacy, apiUserPrivacySave} from "@/net/api/user";
const form = reactive({
password: '',
new_password: '',
new_password_repeat: ''
})
const validatePassword = (rule, value, callback) => {
const validatePassword = (_, value, callback) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== form.new_password) {
@ -35,12 +35,12 @@ const rules = {
}
const formRef = ref()
const valid = ref(false)
const onValidate = (prop, isValid) => valid.value = isValid
const onValidate = (_, isValid) => valid.value = isValid
function resetPassword(){
formRef.value.validate(valid => {
if(valid) {
post('/api/user/change-password', form, () => {
apiUserChangePassword(form, () => {
ElMessage.success('修改密码成功!')
formRef.value.resetFields();
})
@ -56,23 +56,15 @@ const privacy = reactive({
email: false,
gender: false
})
get('/api/user/privacy', data => {
privacy.phone = data.phone
privacy.email = data.email
privacy.wx = data.wx
privacy.qq = data.qq
privacy.gender = data.gender
apiUserPrivacy(data => {
Object.assign(privacy, data)
saving.value = false
})
function savePrivacy(type, status){
saving.value = true
post('/api/user/save-privacy', {
type: type,
status: status
}, () => {
ElMessage.success('隐私设置修改成功!')
saving.value = false
})
apiUserPrivacySave({ type, status }, saving,
() => ElMessage.success('隐私设置修改成功!'))
}
</script>

View File

@ -7,6 +7,7 @@ import {computed, reactive, ref} from "vue";
import {accessHeader, get, post} from "@/net";
import {ElMessage} from "element-plus";
import axios from "axios";
import {apiUserDetail, apiUserDetailSave} from "@/net/api/user";
const store = useStore()
@ -55,7 +56,7 @@ function saveDetails() {
baseFormRef.value.validate(isValid => {
if (isValid) {
loading.base = true
post('/api/user/save-details', baseForm, () => {
apiUserDetailSave(baseForm, () => {
ElMessage.success('用户信息保存成功')
store.user.usernamew = baseForm.username
desc.value = baseForm.desc
@ -68,12 +69,9 @@ function saveDetails() {
})
}
get('/api/user/details', data => {
apiUserDetail(data => {
baseForm.username = store.user.username
baseForm.gender = data.gender
baseForm.phone = data.phone
baseForm.wx = data.wx
baseForm.qq = data.qq
Object.assign(baseForm, data)
baseForm.desc = desc.value = data.desc
emailForm.email = store.user.email
loading.form = false

View File

@ -51,8 +51,9 @@
<script setup>
import {User, Lock} from '@element-plus/icons-vue'
import router from "@/router";
import {reactive, ref} from "vue";
import {inject, reactive, ref} from "vue";
import {login} from '@/net'
import {apiUserInfo} from "@/net/api/user";
const formRef = ref()
const form = reactive({
@ -61,6 +62,8 @@ const form = reactive({
remember: false
})
const loading = inject('userLoading')
const rules = {
username: [
{ required: true, message: '请输入用户名' }
@ -73,7 +76,10 @@ const rules = {
function userLogin() {
formRef.value.validate((isValid) => {
if(isValid) {
login(form.username, form.password, form.remember, () => router.push("/index"))
login(form.username, form.password, form.remember, () => {
apiUserInfo(loading)
router.push("/index")
})
}
});
}