mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-21 14:30:59 +08:00
Merge branch 'main' of github.com:nagisa77/OpenIsle
This commit is contained in:
@@ -30,7 +30,11 @@ public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<?> handleException(Exception ex) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage()));
|
||||
String message = ex.getMessage();
|
||||
if (message == null) {
|
||||
message = ex.getClass().getSimpleName();
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of("error", message));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ public class UserController {
|
||||
|
||||
@PutMapping("/me")
|
||||
public ResponseEntity<?> updateProfile(@RequestBody UpdateProfileDto dto,
|
||||
Authentication auth) {
|
||||
Authentication auth) {
|
||||
User user = userService.updateProfile(auth.getName(), dto.getUsername(), dto.getIntroduction());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(user.getUsername()),
|
||||
@@ -239,6 +239,7 @@ public class UserController {
|
||||
dto.setFollowing(subscriptionService.countSubscribed(user.getUsername()));
|
||||
dto.setCreatedAt(user.getCreatedAt());
|
||||
dto.setLastPostTime(postService.getLastPostTime(user.getUsername()));
|
||||
dto.setLastCommentTime(commentService.getLastCommentTimeOfUserByUserId(user.getId()));
|
||||
dto.setTotalViews(postService.getTotalViews(user.getUsername()));
|
||||
dto.setVisitedDays(userVisitService.countVisits(user.getUsername()));
|
||||
dto.setReadPosts(postReadService.countReads(user.getUsername()));
|
||||
@@ -306,6 +307,7 @@ public class UserController {
|
||||
private long following;
|
||||
private java.time.LocalDateTime createdAt;
|
||||
private java.time.LocalDateTime lastPostTime;
|
||||
private java.time.LocalDateTime lastCommentTime;
|
||||
private long totalViews;
|
||||
private long visitedDays;
|
||||
private long readPosts;
|
||||
|
||||
@@ -23,4 +23,8 @@ public interface CommentRepository extends JpaRepository<Comment, Long> {
|
||||
@org.springframework.data.jpa.repository.Query("SELECT COUNT(c) FROM Comment c WHERE c.author.username = :username AND c.createdAt >= :start")
|
||||
long countByAuthorAfter(@org.springframework.data.repository.query.Param("username") String username,
|
||||
@org.springframework.data.repository.query.Param("start") java.time.LocalDateTime start);
|
||||
|
||||
@org.springframework.data.jpa.repository.Query("SELECT MAX(c.createdAt) FROM Comment c WHERE c.author.id = :userId")
|
||||
java.time.LocalDateTime findLastCommentTimeOfUserByUserId(@org.springframework.data.repository.query.Param("userId") Long userId);
|
||||
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ public class CommentService {
|
||||
return comment;
|
||||
}
|
||||
|
||||
public java.time.LocalDateTime getLastCommentTimeOfUserByUserId(Long userId) { // 根据用户id查询该用户最后回复时间
|
||||
return commentRepository.findLastCommentTimeOfUserByUserId(userId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Comment addReply(String username, Long parentId, String content) {
|
||||
log.debug("addReply called by user {} for parent comment {}", username, parentId);
|
||||
|
||||
26
backend/src/main/resources/logback-spring.xml
Normal file
26
backend/src/main/resources/logback-spring.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<property name="LOG_PATH" value="${LOG_PATH:-logs}"/>
|
||||
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/app.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/app-%d{yyyy-MM-dd-HH}.log</fileNamePattern>
|
||||
<maxHistory>72</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="${LOG_LEVEL:-INFO}">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
<appender-ref ref="FILE"/>
|
||||
</root>
|
||||
</configuration>
|
||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"core-js": "^3.8.3",
|
||||
"cropperjs": "^1.6.2",
|
||||
"echarts": "^5.6.0",
|
||||
"ldrs": "^1.1.7",
|
||||
"markdown-it": "^14.1.0",
|
||||
@@ -4434,6 +4435,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/cropperjs": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
|
||||
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "6.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-6.0.6.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "^3.8.3",
|
||||
"cropperjs": "^1.6.2",
|
||||
"echarts": "^5.6.0",
|
||||
"ldrs": "^1.1.7",
|
||||
"markdown-it": "^14.1.0",
|
||||
|
||||
142
frontend/src/components/AvatarCropper.vue
Normal file
142
frontend/src/components/AvatarCropper.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div v-if="show" class="cropper-modal">
|
||||
<div class="cropper-body">
|
||||
<div class="cropper-wrapper">
|
||||
<img ref="image" :src="src" alt="to crop" />
|
||||
</div>
|
||||
<div class="cropper-actions">
|
||||
<button class="cropper-btn" @click="$emit('close')">取消</button>
|
||||
<button class="cropper-btn primary" @click="onConfirm">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Cropper from 'cropperjs'
|
||||
import 'cropperjs/dist/cropper.css'
|
||||
|
||||
export default {
|
||||
name: 'AvatarCropper',
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['close', 'crop'],
|
||||
data() {
|
||||
return { cropper: null }
|
||||
},
|
||||
watch: {
|
||||
show(val) {
|
||||
if (val) {
|
||||
this.$nextTick(() => this.init())
|
||||
} else {
|
||||
this.destroy()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.show) {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
const image = this.$refs.image
|
||||
this.cropper = new Cropper(image, {
|
||||
aspectRatio: 1,
|
||||
viewMode: 1,
|
||||
autoCropArea: 1,
|
||||
responsive: true
|
||||
})
|
||||
},
|
||||
destroy() {
|
||||
if (this.cropper) {
|
||||
this.cropper.destroy()
|
||||
this.cropper = null
|
||||
}
|
||||
},
|
||||
onConfirm() {
|
||||
if (!this.cropper) return
|
||||
this.cropper.getCroppedCanvas({ width: 256, height: 256 }).toBlob(blob => {
|
||||
const file = new File([blob], 'avatar.png', { type: 'image/png' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
this.$emit('crop', { file, url })
|
||||
this.$emit('close')
|
||||
this.destroy()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cropper-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
opacity: 1.0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.cropper-body {
|
||||
background: var(--background-color);
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cropper-wrapper {
|
||||
width: 80vw;
|
||||
height: 80vw;
|
||||
max-width: 400px;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.cropper-wrapper img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cropper-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cropper-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
color: var(--primary-color);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cropper-btn.primary {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.cropper-wrapper {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -44,6 +44,10 @@
|
||||
<div class="profile-info-item-label">最后发帖时间:</div>
|
||||
<div class="profile-info-item-value">{{ formatDate(user.lastPostTime) }}</div>
|
||||
</div>
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">最后评论时间:</div>
|
||||
<div class="profile-info-item-value">{{ user.lastCommentTime!=null?formatDate(user.lastCommentTime):"暂无评论" }}</div>
|
||||
</div>
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">浏览量:</div>
|
||||
<div class="profile-info-item-value">{{ user.totalViews }}</div>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<AvatarCropper
|
||||
:src="tempAvatar"
|
||||
:show="showCropper"
|
||||
@close="showCropper = false"
|
||||
@crop="onCropped"
|
||||
/>
|
||||
<div v-if="isLoadingPage" class="loading-page">
|
||||
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
@@ -59,11 +65,12 @@ import { API_BASE_URL, toast } from '../main'
|
||||
import { getToken, fetchCurrentUser, setToken } from '../utils/auth'
|
||||
import BaseInput from '../components/BaseInput.vue'
|
||||
import Dropdown from '../components/Dropdown.vue'
|
||||
import AvatarCropper from '../components/AvatarCropper.vue'
|
||||
import { hatch } from 'ldrs'
|
||||
hatch.register()
|
||||
export default {
|
||||
name: 'SettingsPageView',
|
||||
components: { BaseInput, Dropdown },
|
||||
components: { BaseInput, Dropdown, AvatarCropper },
|
||||
data() {
|
||||
return {
|
||||
username: '',
|
||||
@@ -71,6 +78,8 @@ export default {
|
||||
usernameError: '',
|
||||
avatar: '',
|
||||
avatarFile: null,
|
||||
tempAvatar: '',
|
||||
showCropper: false,
|
||||
role: '',
|
||||
publishMode: 'DIRECT',
|
||||
passwordStrength: 'LOW',
|
||||
@@ -101,15 +110,19 @@ export default {
|
||||
methods: {
|
||||
onAvatarChange(e) {
|
||||
const file = e.target.files[0]
|
||||
this.avatarFile = file
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
this.avatar = reader.result
|
||||
this.tempAvatar = reader.result
|
||||
this.showCropper = true
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
},
|
||||
onCropped({ file, url }) {
|
||||
this.avatarFile = file
|
||||
this.avatar = url
|
||||
},
|
||||
fetchPublishModes() {
|
||||
return Promise.resolve([
|
||||
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
|
||||
|
||||
Reference in New Issue
Block a user