Compare commits

..

10 Commits

Author SHA1 Message Date
Tim
5f5b6f84a8 feat: add markdown comments and link to rss 2025-08-21 14:28:33 +08:00
Tim
cd57d478f2 Merge pull request #677 from nagisa77/feature/website_block
fix: 微信黑名单申诉 #676
2025-08-21 13:41:31 +08:00
Tim
da07313df8 Merge pull request #678 from nagisa77/codex/add-record-to-points-history
Add point redemption history record
2025-08-21 13:41:21 +08:00
Tim
c08ecb5e33 Record point redemption in history 2025-08-21 13:38:53 +08:00
tim
0a722c81c5 fix: 微信黑名单申诉 #676 2025-08-21 13:37:02 +08:00
Tim
15071471b2 Merge pull request #673 from nagisa77/feature/daily_bugfix_0821 2025-08-21 12:37:31 +08:00
Tim
98a9939738 Merge pull request #675 from nagisa77/codex-5yja7z 2025-08-21 12:36:37 +08:00
Tim
9554030054 refactor: add reusable switch component 2025-08-21 12:36:02 +08:00
Tim
72e9a77373 fix: ui 调整 2025-08-21 12:27:50 +08:00
Tim
ed7dcd9414 Merge pull request #674 from nagisa77/codex/add-points-history-system-with-ui
feat: add point history
2025-08-21 11:05:07 +08:00
11 changed files with 266 additions and 145 deletions

View File

@@ -47,7 +47,8 @@ public class AuthController {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
}
if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) {
if (!inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult result = inviteService.validate(req.getInviteToken());
if (!result.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "邀请码使用次数过多"));
}
try {
@@ -144,7 +145,8 @@ public class AuthController {
@PostMapping("/google")
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = googleAuthService.authenticate(
@@ -154,7 +156,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), user.getUsername());
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -218,7 +220,8 @@ public class AuthController {
@PostMapping("/github")
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = githubAuthService.authenticate(
@@ -229,7 +232,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), user.getUsername());
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -265,7 +268,8 @@ public class AuthController {
@PostMapping("/discord")
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = discordAuthService.authenticate(
@@ -276,7 +280,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), user.getUsername());
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -311,7 +315,8 @@ public class AuthController {
@PostMapping("/twitter")
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = twitterAuthService.authenticate(
@@ -323,7 +328,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), user.getUsername());
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"

View File

@@ -1,7 +1,10 @@
package com.openisle.controller;
import com.openisle.model.Post;
import com.openisle.model.Comment;
import com.openisle.model.CommentSort;
import com.openisle.service.PostService;
import com.openisle.service.CommentService;
import lombok.RequiredArgsConstructor;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
@@ -31,6 +34,7 @@ import java.util.regex.Pattern;
@RequiredArgsConstructor
public class RssController {
private final PostService postService;
private final CommentService commentService;
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@@ -103,6 +107,19 @@ public class RssController {
enclosure = absolutifyUrl(enclosure, base);
}
// Top comments in Markdown
List<Comment> topComments = commentService
.getCommentsForPost(p.getId(), CommentSort.MOST_INTERACTIONS);
topComments = topComments.subList(0, Math.min(10, topComments.size()));
StringBuilder commentMd = new StringBuilder();
for (Comment c : topComments) {
commentMd.append("> @")
.append(nullSafe(c.getAuthor().getUsername()))
.append(": ")
.append(nullSafe(c.getContent()).replace("\r", ""))
.append("\n\n");
}
sb.append("<item>");
elem(sb, "title", cdata(nullSafe(p.getTitle())));
elem(sb, "link", link);
@@ -117,6 +134,11 @@ public class RssController {
sb.append("<enclosure url=\"").append(escapeXml(enclosure)).append("\" type=\"")
.append(getMimeType(enclosure)).append("\" />");
}
// Markdown comments
elem(sb, "commentsMarkdown", cdata(commentMd.toString()));
// Markdown original link
elem(sb, "originalLinkMarkdown", cdata("[原文链接](" + link + ")"));
sb.append("</item>");
}

View File

@@ -6,5 +6,6 @@ public enum PointHistoryType {
POST_LIKED,
COMMENT_LIKED,
INVITE,
SYSTEM_ONLINE
SYSTEM_ONLINE,
REDEEM
}

View File

@@ -5,6 +5,7 @@ import com.openisle.model.User;
import com.openisle.repository.InviteTokenRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
@@ -18,6 +19,12 @@ public class InviteService {
private final JwtService jwtService;
private final PointService pointService;
@Value
public class InviteValidateResult {
InviteToken inviteToken;
boolean validate;
}
public String generate(String username) {
User inviter = userRepository.findByUsername(username).orElseThrow();
LocalDate today = LocalDate.now();
@@ -35,14 +42,17 @@ public class InviteService {
return token;
}
public boolean validate(String token) {
public InviteValidateResult validate(String token) {
if (token == null || token.isEmpty()) {
return new InviteValidateResult(null, false);
}
try {
jwtService.validateAndGetSubjectForInvite(token);
} catch (Exception e) {
return false;
return new InviteValidateResult(null, false);
}
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
return invite != null && invite.getUsageCount() < 3;
return new InviteValidateResult(invite, invite != null && invite.getUsageCount() < 3);
}
public void consume(String token, String newUserName) {

View File

@@ -3,8 +3,11 @@ package com.openisle.service;
import com.openisle.exception.FieldException;
import com.openisle.exception.NotFoundException;
import com.openisle.model.PointGood;
import com.openisle.model.PointHistory;
import com.openisle.model.PointHistoryType;
import com.openisle.model.User;
import com.openisle.repository.PointGoodRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -18,6 +21,7 @@ public class PointMallService {
private final PointGoodRepository pointGoodRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
private final PointHistoryRepository pointHistoryRepository;
public List<PointGood> listGoods() {
return pointGoodRepository.findAll();
@@ -32,6 +36,13 @@ public class PointMallService {
user.setPoint(user.getPoint() - good.getCost());
userRepository.save(user);
notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact);
PointHistory history = new PointHistory();
history.setUser(user);
history.setType(PointHistoryType.REDEEM);
history.setAmount(-good.getCost());
history.setBalance(user.getPoint());
history.setCreatedAt(java.time.LocalDateTime.now());
pointHistoryRepository.save(history);
return user.getPoint();
}
}

View File

@@ -49,6 +49,9 @@ public class PointService {
private int addPoint(User user, int amount, PointHistoryType type,
Post post, Comment comment, User fromUser) {
if (pointHistoryRepository.countByUser(user) == 0) {
recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null);
}
user.setPoint(user.getPoint() + amount);
userRepository.save(user);
recordHistory(user, type, amount, post, comment, fromUser);

View File

@@ -0,0 +1,65 @@
<template>
<label class="switch">
<input
type="checkbox"
:checked="modelValue"
@change="$emit('update:modelValue', $event.target.checked)"
/>
<span class="slider"></span>
</label>
</template>
<script setup>
defineProps({
modelValue: { type: Boolean, default: false },
})
defineEmits(['update:modelValue'])
</script>
<style scoped>
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.2s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: '';
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--primary-color);
}
input:checked + .slider:before {
transform: translateX(20px);
}
</style>

View File

@@ -33,15 +33,13 @@
<div v-if="selectedTab === 'control'">
<div class="message-control-container">
<div class="message-control-title">通知设置</div>
<div class="message-control-push-item-container">
<div
v-for="pref in notificationPrefs"
:key="pref.type"
class="message-control-push-item"
:class="{ select: pref.enabled }"
@click="togglePref(pref)"
>
{{ formatType(pref.type) }}
<div class="message-control-item-container">
<div v-for="pref in notificationPrefs" :key="pref.type" class="message-control-item">
<div class="message-control-item-label">{{ formatType(pref.type) }}</div>
<BaseSwitch
:model-value="pref.enabled"
@update:modelValue="(val) => togglePref(pref, val)"
/>
</div>
</div>
</div>
@@ -550,6 +548,7 @@ import {
updateNotificationPreference,
} from '~/utils/notification'
import TimeManager from '~/utils/time'
import BaseSwitch from '~/components/BaseSwitch.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -582,10 +581,10 @@ const fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences()
}
const togglePref = async (pref) => {
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
const togglePref = async (pref, value) => {
const ok = await updateNotificationPreference(pref.type, value)
if (ok) {
pref.enabled = !pref.enabled
pref.enabled = value
await fetchNotifications({
page: page.value,
size: pageSize,
@@ -846,26 +845,21 @@ onActivated(async () => {
padding: 20px;
}
.message-control-push-item-container {
.message-control-item-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
flex-direction: column;
gap: 10px;
}
.message-control-push-item {
font-size: 14px;
margin-bottom: 5px;
padding: 8px 16px;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
cursor: pointer;
transition: all 0.3s ease;
.message-control-item {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 200px;
}
.message-control-push-item.select {
background-color: var(--primary-color);
color: white;
.message-control-item-label {
font-size: 14px;
}
@media (max-width: 768px) {

View File

@@ -16,49 +16,52 @@
</div>
<template v-if="selectedTab === 'mall'">
<section class="rules">
<div class="section-title">🎉 积分规则</div>
<div class="section-content">
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
</div>
</section>
<div class="loading-points-container" v-if="isLoading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div class="point-info">
<p v-if="authState.loggedIn && point !== null">
<span><i class="fas fa-coins coin-icon"></i></span>我的积分<span class="point-value">{{
point
}}</span>
</p>
</div>
<section class="goods">
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
<img class="goods-item-image" :src="good.image" alt="good.name" />
<div class="goods-item-name">{{ good.name }}</div>
<div class="goods-item-cost">
<i class="fas fa-coins"></i>
{{ good.cost }} 积分
</div>
<div
class="goods-item-button"
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
@click="openRedeem(good)"
>
兑换
<div class="point-mall-page-content">
<section class="rules">
<div class="section-title">🎉 积分规则</div>
<div class="section-content">
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
</div>
</section>
<div class="loading-points-container" v-if="isLoading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
</section>
<RedeemPopup
:visible="dialogVisible"
v-model="contact"
:loading="loading"
@close="closeRedeem"
@submit="submitRedeem"
/>
<div class="point-info">
<p v-if="authState.loggedIn && point !== null">
<span><i class="fas fa-coins coin-icon"></i></span>我的积分<span
class="point-value"
>{{ point }}</span
>
</p>
</div>
<section class="goods">
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
<img class="goods-item-image" :src="good.image" alt="good.name" />
<div class="goods-item-name">{{ good.name }}</div>
<div class="goods-item-cost">
<i class="fas fa-coins"></i>
{{ good.cost }} 积分
</div>
<div
class="goods-item-button"
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
@click="openRedeem(good)"
>
兑换
</div>
</div>
</section>
<RedeemPopup
:visible="dialogVisible"
v-model="contact"
:loading="loading"
@close="closeRedeem"
@submit="submitRedeem"
/>
</div>
</template>
<template v-else>
@@ -77,14 +80,30 @@
}}</NuxtLink>
获得{{ item.amount }}积分
</template>
<template v-else-if="item.type === 'COMMENT' && item.commentId && !item.fromUserId">
发送评论
<NuxtLink
:to="`/posts/${item.postId}#comment-${item.commentId}`"
class="timeline-link"
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
>
获得{{ item.amount }}积分
<template v-else-if="item.type === 'COMMENT'">
在文章
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
<template v-if="!item.fromUserId">
发送评论
<NuxtLink
:to="`/posts/${item.postId}#comment-${item.commentId}`"
class="timeline-link"
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
>
获得{{ item.amount }}积分
</template>
<template v-else>
被评论
<NuxtLink
:to="`/posts/${item.postId}#comment-${item.commentId}`"
class="timeline-link"
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
>
获得{{ item.amount }}积分
</template>
</template>
<template v-else-if="item.type === 'POST_LIKED' && item.fromUserId">
帖子
@@ -114,13 +133,16 @@
邀请了好友
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink
>加入社区获得 {{ item.amount }} 积分
}}</NuxtLink>
加入社区 🎉获得 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'SYSTEM_ONLINE'">
积分历史系统上线你目前的积分是 {{ item.balance }}
<template v-else-if="item.type === 'REDEEM'">
兑换商品消耗 {{ -item.amount }} 积分
</template>
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
<i class="fas fa-coins"></i> 你目前的积分是 {{ item.balance }}
</div>
<div class="history-time">{{ TimeManager.format(item.createdAt) }}</div>
</template>
</BaseTimeline>
</div>
@@ -136,6 +158,7 @@ import RedeemPopup from '~/components/RedeemPopup.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import { stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -161,6 +184,16 @@ const contact = ref('')
const loading = ref(false)
const selectedGood = ref(null)
const iconMap = {
POST: 'fas fa-file-alt',
COMMENT: 'fas fa-comment',
POST_LIKED: 'fas fa-thumbs-up',
COMMENT_LIKED: 'fas fa-thumbs-up',
INVITE: 'fas fa-user-plus',
SYSTEM_ONLINE: 'fas fa-clock',
REDEEM: 'fas fa-gift',
}
onMounted(async () => {
isLoading.value = true
if (authState.loggedIn) {
@@ -195,7 +228,10 @@ const loadHistory = async () => {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
histories.value = await res.json()
histories.value = (await res.json()).map((item) => ({
...item,
icon: iconMap[item.type],
}))
}
historyLoading.value = false
historyLoaded.value = true
@@ -241,12 +277,15 @@ const submitRedeem = async () => {
<style scoped>
.point-mall-page {
padding: 0 20px;
max-width: var(--page-max-width);
background-color: var(--background-color);
margin: 0 auto;
}
.point-mall-page-content {
padding: 0 20px;
}
.point-tabs {
display: flex;
border-bottom: 1px solid var(--normal-border-color);
@@ -259,15 +298,21 @@ const submitRedeem = async () => {
.point-tab-item.selected {
border-bottom: 2px solid var(--primary-color);
font-weight: bold;
color: var(--primary-color);
}
.timeline-container {
margin-top: 20px;
padding: 10px 20px;
}
.timeline-link {
color: var(--primary-color);
text-decoration: none;
font-weight: bold;
}
.timeline-link:hover {
text-decoration: underline;
}
.loading-points-container {
@@ -350,6 +395,17 @@ const submitRedeem = async () => {
cursor: not-allowed;
}
.history-content {
font-size: 14px;
opacity: 0.8;
}
.history-time {
font-size: 12px;
color: var(--text-color);
opacity: 0.7;
}
.section-title {
font-size: 18px;
font-weight: bold;

View File

@@ -38,10 +38,7 @@
</div>
<div class="form-row switch-row">
<div class="setting-title">毛玻璃效果</div>
<label class="switch">
<input type="checkbox" v-model="frosted" />
<span class="slider"></span>
</label>
<BaseSwitch v-model="frosted" />
</div>
</div>
<div v-if="role === 'ADMIN'" class="admin-section">
@@ -76,6 +73,7 @@ import { ref, onMounted, watch } from 'vue'
import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseInput from '~/components/BaseInput.vue'
import Dropdown from '~/components/Dropdown.vue'
import BaseSwitch from '~/components/BaseSwitch.vue'
import { toast } from '~/main'
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
import { frostedState, setFrosted } from '~/utils/frosted'
@@ -318,51 +316,6 @@ const save = async () => {
max-width: 200px;
}
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.2s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: '';
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--primary-color);
}
input:checked + .slider:before {
transform: translateX(20px);
}
.profile-section {
margin-bottom: 30px;
}

View File

@@ -0,0 +1 @@
1839503219847005265