mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-06-08 19:17:40 +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)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<?> handleException(Exception ex) {
|
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")
|
@PutMapping("/me")
|
||||||
public ResponseEntity<?> updateProfile(@RequestBody UpdateProfileDto dto,
|
public ResponseEntity<?> updateProfile(@RequestBody UpdateProfileDto dto,
|
||||||
Authentication auth) {
|
Authentication auth) {
|
||||||
User user = userService.updateProfile(auth.getName(), dto.getUsername(), dto.getIntroduction());
|
User user = userService.updateProfile(auth.getName(), dto.getUsername(), dto.getIntroduction());
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"token", jwtService.generateToken(user.getUsername()),
|
"token", jwtService.generateToken(user.getUsername()),
|
||||||
@@ -239,6 +239,7 @@ public class UserController {
|
|||||||
dto.setFollowing(subscriptionService.countSubscribed(user.getUsername()));
|
dto.setFollowing(subscriptionService.countSubscribed(user.getUsername()));
|
||||||
dto.setCreatedAt(user.getCreatedAt());
|
dto.setCreatedAt(user.getCreatedAt());
|
||||||
dto.setLastPostTime(postService.getLastPostTime(user.getUsername()));
|
dto.setLastPostTime(postService.getLastPostTime(user.getUsername()));
|
||||||
|
dto.setLastCommentTime(commentService.getLastCommentTimeOfUserByUserId(user.getId()));
|
||||||
dto.setTotalViews(postService.getTotalViews(user.getUsername()));
|
dto.setTotalViews(postService.getTotalViews(user.getUsername()));
|
||||||
dto.setVisitedDays(userVisitService.countVisits(user.getUsername()));
|
dto.setVisitedDays(userVisitService.countVisits(user.getUsername()));
|
||||||
dto.setReadPosts(postReadService.countReads(user.getUsername()));
|
dto.setReadPosts(postReadService.countReads(user.getUsername()));
|
||||||
@@ -306,6 +307,7 @@ public class UserController {
|
|||||||
private long following;
|
private long following;
|
||||||
private java.time.LocalDateTime createdAt;
|
private java.time.LocalDateTime createdAt;
|
||||||
private java.time.LocalDateTime lastPostTime;
|
private java.time.LocalDateTime lastPostTime;
|
||||||
|
private java.time.LocalDateTime lastCommentTime;
|
||||||
private long totalViews;
|
private long totalViews;
|
||||||
private long visitedDays;
|
private long visitedDays;
|
||||||
private long readPosts;
|
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")
|
@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,
|
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.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;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public java.time.LocalDateTime getLastCommentTimeOfUserByUserId(Long userId) { // 根据用户id查询该用户最后回复时间
|
||||||
|
return commentRepository.findLastCommentTimeOfUserByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Comment addReply(String username, Long parentId, String content) {
|
public Comment addReply(String username, Long parentId, String content) {
|
||||||
log.debug("addReply called by user {} for parent comment {}", username, parentId);
|
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",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "^3.8.3",
|
"core-js": "^3.8.3",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
"ldrs": "^1.1.7",
|
"ldrs": "^1.1.7",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
@@ -4434,6 +4435,12 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "6.0.6",
|
"version": "6.0.6",
|
||||||
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-6.0.6.tgz",
|
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-6.0.6.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "^3.8.3",
|
"core-js": "^3.8.3",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
"ldrs": "^1.1.7",
|
"ldrs": "^1.1.7",
|
||||||
"markdown-it": "^14.1.0",
|
"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-label">最后发帖时间:</div>
|
||||||
<div class="profile-info-item-value">{{ formatDate(user.lastPostTime) }}</div>
|
<div class="profile-info-item-value">{{ formatDate(user.lastPostTime) }}</div>
|
||||||
</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">
|
||||||
<div class="profile-info-item-label">浏览量:</div>
|
<div class="profile-info-item-label">浏览量:</div>
|
||||||
<div class="profile-info-item-value">{{ user.totalViews }}</div>
|
<div class="profile-info-item-value">{{ user.totalViews }}</div>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="settings-page">
|
<div class="settings-page">
|
||||||
|
<AvatarCropper
|
||||||
|
:src="tempAvatar"
|
||||||
|
:show="showCropper"
|
||||||
|
@close="showCropper = false"
|
||||||
|
@crop="onCropped"
|
||||||
|
/>
|
||||||
<div v-if="isLoadingPage" class="loading-page">
|
<div v-if="isLoadingPage" class="loading-page">
|
||||||
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,11 +65,12 @@ import { API_BASE_URL, toast } from '../main'
|
|||||||
import { getToken, fetchCurrentUser, setToken } from '../utils/auth'
|
import { getToken, fetchCurrentUser, setToken } from '../utils/auth'
|
||||||
import BaseInput from '../components/BaseInput.vue'
|
import BaseInput from '../components/BaseInput.vue'
|
||||||
import Dropdown from '../components/Dropdown.vue'
|
import Dropdown from '../components/Dropdown.vue'
|
||||||
|
import AvatarCropper from '../components/AvatarCropper.vue'
|
||||||
import { hatch } from 'ldrs'
|
import { hatch } from 'ldrs'
|
||||||
hatch.register()
|
hatch.register()
|
||||||
export default {
|
export default {
|
||||||
name: 'SettingsPageView',
|
name: 'SettingsPageView',
|
||||||
components: { BaseInput, Dropdown },
|
components: { BaseInput, Dropdown, AvatarCropper },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
username: '',
|
username: '',
|
||||||
@@ -71,6 +78,8 @@ export default {
|
|||||||
usernameError: '',
|
usernameError: '',
|
||||||
avatar: '',
|
avatar: '',
|
||||||
avatarFile: null,
|
avatarFile: null,
|
||||||
|
tempAvatar: '',
|
||||||
|
showCropper: false,
|
||||||
role: '',
|
role: '',
|
||||||
publishMode: 'DIRECT',
|
publishMode: 'DIRECT',
|
||||||
passwordStrength: 'LOW',
|
passwordStrength: 'LOW',
|
||||||
@@ -101,15 +110,19 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
onAvatarChange(e) {
|
onAvatarChange(e) {
|
||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
this.avatarFile = file
|
|
||||||
if (file) {
|
if (file) {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
this.avatar = reader.result
|
this.tempAvatar = reader.result
|
||||||
|
this.showCropper = true
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onCropped({ file, url }) {
|
||||||
|
this.avatarFile = file
|
||||||
|
this.avatar = url
|
||||||
|
},
|
||||||
fetchPublishModes() {
|
fetchPublishModes() {
|
||||||
return Promise.resolve([
|
return Promise.resolve([
|
||||||
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
|
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
|
||||||
|
|||||||
Reference in New Issue
Block a user