mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-25 07:30:46 +08:00
Compare commits
10 Commits
codex/add-
...
codex/rss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f5b6f84a8 | ||
|
|
cd57d478f2 | ||
|
|
da07313df8 | ||
|
|
c08ecb5e33 | ||
|
|
0a722c81c5 | ||
|
|
15071471b2 | ||
|
|
98a9939738 | ||
|
|
9554030054 | ||
|
|
72e9a77373 | ||
|
|
ed7dcd9414 |
@@ -47,7 +47,8 @@ public class AuthController {
|
|||||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
||||||
}
|
}
|
||||||
if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) {
|
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", "邀请码使用次数过多"));
|
return ResponseEntity.badRequest().body(Map.of("error", "邀请码使用次数过多"));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -144,7 +145,8 @@ public class AuthController {
|
|||||||
@PostMapping("/google")
|
@PostMapping("/google")
|
||||||
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
|
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
|
||||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
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"));
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
}
|
}
|
||||||
Optional<AuthResult> resultOpt = googleAuthService.authenticate(
|
Optional<AuthResult> resultOpt = googleAuthService.authenticate(
|
||||||
@@ -154,7 +156,7 @@ public class AuthController {
|
|||||||
if (resultOpt.isPresent()) {
|
if (resultOpt.isPresent()) {
|
||||||
AuthResult result = resultOpt.get();
|
AuthResult result = resultOpt.get();
|
||||||
if (viaInvite && result.isNewUser()) {
|
if (viaInvite && result.isNewUser()) {
|
||||||
inviteService.consume(req.getInviteToken(), user.getUsername());
|
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||||
"reason_code", "INVITE_APPROVED"
|
"reason_code", "INVITE_APPROVED"
|
||||||
@@ -218,7 +220,8 @@ public class AuthController {
|
|||||||
@PostMapping("/github")
|
@PostMapping("/github")
|
||||||
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
|
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
|
||||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
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"));
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
}
|
}
|
||||||
Optional<AuthResult> resultOpt = githubAuthService.authenticate(
|
Optional<AuthResult> resultOpt = githubAuthService.authenticate(
|
||||||
@@ -229,7 +232,7 @@ public class AuthController {
|
|||||||
if (resultOpt.isPresent()) {
|
if (resultOpt.isPresent()) {
|
||||||
AuthResult result = resultOpt.get();
|
AuthResult result = resultOpt.get();
|
||||||
if (viaInvite && result.isNewUser()) {
|
if (viaInvite && result.isNewUser()) {
|
||||||
inviteService.consume(req.getInviteToken(), user.getUsername());
|
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||||
"reason_code", "INVITE_APPROVED"
|
"reason_code", "INVITE_APPROVED"
|
||||||
@@ -265,7 +268,8 @@ public class AuthController {
|
|||||||
@PostMapping("/discord")
|
@PostMapping("/discord")
|
||||||
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
|
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
|
||||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
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"));
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
}
|
}
|
||||||
Optional<AuthResult> resultOpt = discordAuthService.authenticate(
|
Optional<AuthResult> resultOpt = discordAuthService.authenticate(
|
||||||
@@ -276,7 +280,7 @@ public class AuthController {
|
|||||||
if (resultOpt.isPresent()) {
|
if (resultOpt.isPresent()) {
|
||||||
AuthResult result = resultOpt.get();
|
AuthResult result = resultOpt.get();
|
||||||
if (viaInvite && result.isNewUser()) {
|
if (viaInvite && result.isNewUser()) {
|
||||||
inviteService.consume(req.getInviteToken(), user.getUsername());
|
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||||
"reason_code", "INVITE_APPROVED"
|
"reason_code", "INVITE_APPROVED"
|
||||||
@@ -311,7 +315,8 @@ public class AuthController {
|
|||||||
@PostMapping("/twitter")
|
@PostMapping("/twitter")
|
||||||
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
|
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
|
||||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
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"));
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
}
|
}
|
||||||
Optional<AuthResult> resultOpt = twitterAuthService.authenticate(
|
Optional<AuthResult> resultOpt = twitterAuthService.authenticate(
|
||||||
@@ -323,7 +328,7 @@ public class AuthController {
|
|||||||
if (resultOpt.isPresent()) {
|
if (resultOpt.isPresent()) {
|
||||||
AuthResult result = resultOpt.get();
|
AuthResult result = resultOpt.get();
|
||||||
if (viaInvite && result.isNewUser()) {
|
if (viaInvite && result.isNewUser()) {
|
||||||
inviteService.consume(req.getInviteToken(), user.getUsername());
|
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||||
"reason_code", "INVITE_APPROVED"
|
"reason_code", "INVITE_APPROVED"
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.openisle.controller;
|
package com.openisle.controller;
|
||||||
|
|
||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
|
import com.openisle.model.Comment;
|
||||||
|
import com.openisle.model.CommentSort;
|
||||||
import com.openisle.service.PostService;
|
import com.openisle.service.PostService;
|
||||||
|
import com.openisle.service.CommentService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.jsoup.Jsoup;
|
import org.jsoup.Jsoup;
|
||||||
import org.jsoup.nodes.Document;
|
import org.jsoup.nodes.Document;
|
||||||
@@ -31,6 +34,7 @@ import java.util.regex.Pattern;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class RssController {
|
public class RssController {
|
||||||
private final PostService postService;
|
private final PostService postService;
|
||||||
|
private final CommentService commentService;
|
||||||
|
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url:https://www.open-isle.com}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
@@ -103,6 +107,19 @@ public class RssController {
|
|||||||
enclosure = absolutifyUrl(enclosure, base);
|
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>");
|
sb.append("<item>");
|
||||||
elem(sb, "title", cdata(nullSafe(p.getTitle())));
|
elem(sb, "title", cdata(nullSafe(p.getTitle())));
|
||||||
elem(sb, "link", link);
|
elem(sb, "link", link);
|
||||||
@@ -117,6 +134,11 @@ public class RssController {
|
|||||||
sb.append("<enclosure url=\"").append(escapeXml(enclosure)).append("\" type=\"")
|
sb.append("<enclosure url=\"").append(escapeXml(enclosure)).append("\" type=\"")
|
||||||
.append(getMimeType(enclosure)).append("\" />");
|
.append(getMimeType(enclosure)).append("\" />");
|
||||||
}
|
}
|
||||||
|
// Markdown comments
|
||||||
|
elem(sb, "commentsMarkdown", cdata(commentMd.toString()));
|
||||||
|
// Markdown original link
|
||||||
|
elem(sb, "originalLinkMarkdown", cdata("[原文链接](" + link + ")"));
|
||||||
|
|
||||||
sb.append("</item>");
|
sb.append("</item>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ public enum PointHistoryType {
|
|||||||
POST_LIKED,
|
POST_LIKED,
|
||||||
COMMENT_LIKED,
|
COMMENT_LIKED,
|
||||||
INVITE,
|
INVITE,
|
||||||
SYSTEM_ONLINE
|
SYSTEM_ONLINE,
|
||||||
|
REDEEM
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.openisle.model.User;
|
|||||||
import com.openisle.repository.InviteTokenRepository;
|
import com.openisle.repository.InviteTokenRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -18,6 +19,12 @@ public class InviteService {
|
|||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final PointService pointService;
|
private final PointService pointService;
|
||||||
|
|
||||||
|
@Value
|
||||||
|
public class InviteValidateResult {
|
||||||
|
InviteToken inviteToken;
|
||||||
|
boolean validate;
|
||||||
|
}
|
||||||
|
|
||||||
public String generate(String username) {
|
public String generate(String username) {
|
||||||
User inviter = userRepository.findByUsername(username).orElseThrow();
|
User inviter = userRepository.findByUsername(username).orElseThrow();
|
||||||
LocalDate today = LocalDate.now();
|
LocalDate today = LocalDate.now();
|
||||||
@@ -35,14 +42,17 @@ public class InviteService {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean validate(String token) {
|
public InviteValidateResult validate(String token) {
|
||||||
|
if (token == null || token.isEmpty()) {
|
||||||
|
return new InviteValidateResult(null, false);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
jwtService.validateAndGetSubjectForInvite(token);
|
jwtService.validateAndGetSubjectForInvite(token);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return false;
|
return new InviteValidateResult(null, false);
|
||||||
}
|
}
|
||||||
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
|
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) {
|
public void consume(String token, String newUserName) {
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package com.openisle.service;
|
|||||||
import com.openisle.exception.FieldException;
|
import com.openisle.exception.FieldException;
|
||||||
import com.openisle.exception.NotFoundException;
|
import com.openisle.exception.NotFoundException;
|
||||||
import com.openisle.model.PointGood;
|
import com.openisle.model.PointGood;
|
||||||
|
import com.openisle.model.PointHistory;
|
||||||
|
import com.openisle.model.PointHistoryType;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import com.openisle.repository.PointGoodRepository;
|
import com.openisle.repository.PointGoodRepository;
|
||||||
|
import com.openisle.repository.PointHistoryRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -18,6 +21,7 @@ public class PointMallService {
|
|||||||
private final PointGoodRepository pointGoodRepository;
|
private final PointGoodRepository pointGoodRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
|
private final PointHistoryRepository pointHistoryRepository;
|
||||||
|
|
||||||
public List<PointGood> listGoods() {
|
public List<PointGood> listGoods() {
|
||||||
return pointGoodRepository.findAll();
|
return pointGoodRepository.findAll();
|
||||||
@@ -32,6 +36,13 @@ public class PointMallService {
|
|||||||
user.setPoint(user.getPoint() - good.getCost());
|
user.setPoint(user.getPoint() - good.getCost());
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact);
|
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();
|
return user.getPoint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ public class PointService {
|
|||||||
|
|
||||||
private int addPoint(User user, int amount, PointHistoryType type,
|
private int addPoint(User user, int amount, PointHistoryType type,
|
||||||
Post post, Comment comment, User fromUser) {
|
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);
|
user.setPoint(user.getPoint() + amount);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
recordHistory(user, type, amount, post, comment, fromUser);
|
recordHistory(user, type, amount, post, comment, fromUser);
|
||||||
|
|||||||
65
frontend_nuxt/components/BaseSwitch.vue
Normal file
65
frontend_nuxt/components/BaseSwitch.vue
Normal 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>
|
||||||
@@ -33,15 +33,13 @@
|
|||||||
<div v-if="selectedTab === 'control'">
|
<div v-if="selectedTab === 'control'">
|
||||||
<div class="message-control-container">
|
<div class="message-control-container">
|
||||||
<div class="message-control-title">通知设置</div>
|
<div class="message-control-title">通知设置</div>
|
||||||
<div class="message-control-push-item-container">
|
<div class="message-control-item-container">
|
||||||
<div
|
<div v-for="pref in notificationPrefs" :key="pref.type" class="message-control-item">
|
||||||
v-for="pref in notificationPrefs"
|
<div class="message-control-item-label">{{ formatType(pref.type) }}</div>
|
||||||
:key="pref.type"
|
<BaseSwitch
|
||||||
class="message-control-push-item"
|
:model-value="pref.enabled"
|
||||||
:class="{ select: pref.enabled }"
|
@update:modelValue="(val) => togglePref(pref, val)"
|
||||||
@click="togglePref(pref)"
|
/>
|
||||||
>
|
|
||||||
{{ formatType(pref.type) }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -550,6 +548,7 @@ import {
|
|||||||
updateNotificationPreference,
|
updateNotificationPreference,
|
||||||
} from '~/utils/notification'
|
} from '~/utils/notification'
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
|
import BaseSwitch from '~/components/BaseSwitch.vue'
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
@@ -582,10 +581,10 @@ const fetchPrefs = async () => {
|
|||||||
notificationPrefs.value = await fetchNotificationPreferences()
|
notificationPrefs.value = await fetchNotificationPreferences()
|
||||||
}
|
}
|
||||||
|
|
||||||
const togglePref = async (pref) => {
|
const togglePref = async (pref, value) => {
|
||||||
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
|
const ok = await updateNotificationPreference(pref.type, value)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
pref.enabled = !pref.enabled
|
pref.enabled = value
|
||||||
await fetchNotifications({
|
await fetchNotifications({
|
||||||
page: page.value,
|
page: page.value,
|
||||||
size: pageSize,
|
size: pageSize,
|
||||||
@@ -846,26 +845,21 @@ onActivated(async () => {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-control-push-item-container {
|
.message-control-item-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: column;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-control-push-item {
|
.message-control-item {
|
||||||
font-size: 14px;
|
display: flex;
|
||||||
margin-bottom: 5px;
|
align-items: center;
|
||||||
padding: 8px 16px;
|
justify-content: space-between;
|
||||||
border: 1px solid var(--normal-border-color);
|
max-width: 200px;
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-control-push-item.select {
|
.message-control-item-label {
|
||||||
background-color: var(--primary-color);
|
font-size: 14px;
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -16,49 +16,52 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="selectedTab === 'mall'">
|
<template v-if="selectedTab === 'mall'">
|
||||||
<section class="rules">
|
<div class="point-mall-page-content">
|
||||||
<div class="section-title">🎉 积分规则</div>
|
<section class="rules">
|
||||||
<div class="section-content">
|
<div class="section-title">🎉 积分规则</div>
|
||||||
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
|
<div class="section-content">
|
||||||
</div>
|
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</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>
|
</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>
|
||||||
</section>
|
|
||||||
<RedeemPopup
|
<div class="point-info">
|
||||||
:visible="dialogVisible"
|
<p v-if="authState.loggedIn && point !== null">
|
||||||
v-model="contact"
|
<span><i class="fas fa-coins coin-icon"></i></span>我的积分:<span
|
||||||
:loading="loading"
|
class="point-value"
|
||||||
@close="closeRedeem"
|
>{{ point }}</span
|
||||||
@submit="submitRedeem"
|
>
|
||||||
/>
|
</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>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -77,14 +80,30 @@
|
|||||||
}}</NuxtLink>
|
}}</NuxtLink>
|
||||||
,获得{{ item.amount }}积分
|
,获得{{ item.amount }}积分
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'COMMENT' && item.commentId && !item.fromUserId">
|
<template v-else-if="item.type === 'COMMENT'">
|
||||||
发送评论
|
在文章
|
||||||
<NuxtLink
|
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
item.postTitle
|
||||||
class="timeline-link"
|
}}</NuxtLink>
|
||||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
中
|
||||||
>
|
<template v-if="!item.fromUserId">
|
||||||
,获得{{ item.amount }}积分
|
发送评论
|
||||||
|
<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>
|
||||||
<template v-else-if="item.type === 'POST_LIKED' && item.fromUserId">
|
<template v-else-if="item.type === 'POST_LIKED' && item.fromUserId">
|
||||||
帖子
|
帖子
|
||||||
@@ -114,13 +133,16 @@
|
|||||||
邀请了好友
|
邀请了好友
|
||||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||||
item.fromUserName
|
item.fromUserName
|
||||||
}}</NuxtLink
|
}}</NuxtLink>
|
||||||
>,加入社区,获得 {{ item.amount }} 积分
|
加入社区 🎉,获得 {{ item.amount }} 积分
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'SYSTEM_ONLINE'">
|
<template v-else-if="item.type === 'REDEEM'">
|
||||||
积分历史系统上线,你目前的积分是 {{ item.balance }}
|
兑换商品,消耗 {{ -item.amount }} 积分
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
||||||
|
<i class="fas fa-coins"></i> 你目前的积分是 {{ item.balance }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="history-time">{{ TimeManager.format(item.createdAt) }}</div>
|
||||||
</template>
|
</template>
|
||||||
</BaseTimeline>
|
</BaseTimeline>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,6 +158,7 @@ import RedeemPopup from '~/components/RedeemPopup.vue'
|
|||||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||||
import { stripMarkdownLength } from '~/utils/markdown'
|
import { stripMarkdownLength } from '~/utils/markdown'
|
||||||
|
import TimeManager from '~/utils/time'
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
@@ -161,6 +184,16 @@ const contact = ref('')
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const selectedGood = ref(null)
|
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 () => {
|
onMounted(async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
if (authState.loggedIn) {
|
if (authState.loggedIn) {
|
||||||
@@ -195,7 +228,10 @@ const loadHistory = async () => {
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
histories.value = await res.json()
|
histories.value = (await res.json()).map((item) => ({
|
||||||
|
...item,
|
||||||
|
icon: iconMap[item.type],
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
historyLoading.value = false
|
historyLoading.value = false
|
||||||
historyLoaded.value = true
|
historyLoaded.value = true
|
||||||
@@ -241,12 +277,15 @@ const submitRedeem = async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.point-mall-page {
|
.point-mall-page {
|
||||||
padding: 0 20px;
|
|
||||||
max-width: var(--page-max-width);
|
max-width: var(--page-max-width);
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.point-mall-page-content {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.point-tabs {
|
.point-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
border-bottom: 1px solid var(--normal-border-color);
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
@@ -259,15 +298,21 @@ const submitRedeem = async () => {
|
|||||||
|
|
||||||
.point-tab-item.selected {
|
.point-tab-item.selected {
|
||||||
border-bottom: 2px solid var(--primary-color);
|
border-bottom: 2px solid var(--primary-color);
|
||||||
font-weight: bold;
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-container {
|
.timeline-container {
|
||||||
margin-top: 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-link {
|
.timeline-link {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-points-container {
|
.loading-points-container {
|
||||||
@@ -350,6 +395,17 @@ const submitRedeem = async () => {
|
|||||||
cursor: not-allowed;
|
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 {
|
.section-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|||||||
@@ -38,10 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-row switch-row">
|
<div class="form-row switch-row">
|
||||||
<div class="setting-title">毛玻璃效果</div>
|
<div class="setting-title">毛玻璃效果</div>
|
||||||
<label class="switch">
|
<BaseSwitch v-model="frosted" />
|
||||||
<input type="checkbox" v-model="frosted" />
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="role === 'ADMIN'" class="admin-section">
|
<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 AvatarCropper from '~/components/AvatarCropper.vue'
|
||||||
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 BaseSwitch from '~/components/BaseSwitch.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
||||||
import { frostedState, setFrosted } from '~/utils/frosted'
|
import { frostedState, setFrosted } from '~/utils/frosted'
|
||||||
@@ -318,51 +316,6 @@ const save = async () => {
|
|||||||
max-width: 200px;
|
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 {
|
.profile-section {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|||||||
1
frontend_nuxt/public/tencent2707107139169774686.txt
Normal file
1
frontend_nuxt/public/tencent2707107139169774686.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1839503219847005265
|
||||||
Reference in New Issue
Block a user