Merge branch 'main' of github.com:nagisa77/OpenIsle

This commit is contained in:
Tim
2025-08-04 17:05:03 +08:00
10 changed files with 212 additions and 5 deletions

View File

@@ -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));
}
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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);

View 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>

View File

@@ -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",

View File

@@ -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",

View 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>

View File

@@ -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>

View File

@@ -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' },