mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 23:21:16 +08:00
Compare commits
132 Commits
feature/da
...
feature/md
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ccdc21568 | ||
|
|
ff63d232a9 | ||
|
|
32a624e62d | ||
|
|
5af0c9dee0 | ||
|
|
edaafdd000 | ||
|
|
24838ab714 | ||
|
|
56a80a184b | ||
|
|
ed24ed174b | ||
|
|
3080acb6e4 | ||
|
|
1856eb191b | ||
|
|
0c2a50d620 | ||
|
|
7562de11a5 | ||
|
|
aaacf4efb1 | ||
|
|
1f30cdfe85 | ||
|
|
8b37cf5abb | ||
|
|
4af19a75c9 | ||
|
|
37ea986389 | ||
|
|
fefd0b3b6c | ||
|
|
a31ed29cfa | ||
|
|
2719819ad7 | ||
|
|
27ff9a9c9b | ||
|
|
18fde1052f | ||
|
|
800970f078 | ||
|
|
cbbd1440a1 | ||
|
|
215616d771 | ||
|
|
575e90e558 | ||
|
|
e63d66806d | ||
|
|
1fc0118c5a | ||
|
|
f3512c1184 | ||
|
|
28842c90b1 | ||
|
|
d67cc326c4 | ||
|
|
27c217a630 | ||
|
|
4e3e5f147c | ||
|
|
8767aa31d6 | ||
|
|
a428f472f2 | ||
|
|
8544803e62 | ||
|
|
54874cea7a | ||
|
|
098d82a6a0 | ||
|
|
90eee03198 | ||
|
|
3f152906f2 | ||
|
|
ef71d0b3d4 | ||
|
|
6f80d139ba | ||
|
|
7454931fa5 | ||
|
|
0852664a82 | ||
|
|
5814fb673a | ||
|
|
4ee4266e3d | ||
|
|
6a27fbe1d7 | ||
|
|
38ff04c358 | ||
|
|
fc27200ac1 | ||
|
|
b1998be425 | ||
|
|
72adc5b232 | ||
|
|
d24e67de5d | ||
|
|
eefefac236 | ||
|
|
2f339fdbdb | ||
|
|
3808becc8b | ||
|
|
18db4d7317 | ||
|
|
52cbb71945 | ||
|
|
39c34a9048 | ||
|
|
4baabf2224 | ||
|
|
8023183bc6 | ||
|
|
27efc493b2 | ||
|
|
ca6e45a711 | ||
|
|
803ca9e103 | ||
|
|
9d1e12773a | ||
|
|
5a09934866 | ||
|
|
db1d7981c5 | ||
|
|
6e1a7c773c | ||
|
|
ac4f1064e7 | ||
|
|
4e98fd6a89 | ||
|
|
1bf92ab1ad | ||
|
|
c6ab431c87 | ||
|
|
aaa25d5c2f | ||
|
|
569531b462 | ||
|
|
c3ae97f8ba | ||
|
|
a57f3e6406 | ||
|
|
23582934fa | ||
|
|
5adee4db0e | ||
|
|
a2ccc95b4e | ||
|
|
dc5eb5a637 | ||
|
|
55dd36bd24 | ||
|
|
59232f99ca | ||
|
|
f93f58b055 | ||
|
|
8ad35af199 | ||
|
|
d427a41f6d | ||
|
|
ea53bc3c83 | ||
|
|
3a39cfdb49 | ||
|
|
3d1b8b8e6e | ||
|
|
f0e58d1efe | ||
|
|
5c4aca5ab8 | ||
|
|
fff59e800d | ||
|
|
b42ed19160 | ||
|
|
6fd663d983 | ||
|
|
fd6fc11630 | ||
|
|
d7bfeed259 | ||
|
|
c5e4da5e07 | ||
|
|
b87932560b | ||
|
|
58ff8b177e | ||
|
|
4f6b585735 | ||
|
|
ac81bccd20 | ||
|
|
351447e3d1 | ||
|
|
453d8fa68b | ||
|
|
2c5b38ee9e | ||
|
|
b5fd5a3edc | ||
|
|
ee717aced2 | ||
|
|
9a9152593e | ||
|
|
856d3dd513 | ||
|
|
0e42a3335a | ||
|
|
d96aae59d2 | ||
|
|
122722d0e9 | ||
|
|
0c2264e509 | ||
|
|
1e503e26f2 | ||
|
|
ec0fd63e30 | ||
|
|
dfd4c70b6e | ||
|
|
d79dc8877d | ||
|
|
e979350d40 | ||
|
|
99bf80a47a | ||
|
|
bfadda1e7d | ||
|
|
906998a07f | ||
|
|
02287c05be | ||
|
|
56aed4603e | ||
|
|
a1fa7b2d5b | ||
|
|
083c7980c6 | ||
|
|
3d51f29be7 | ||
|
|
d243e3a9d6 | ||
|
|
2b3c60f9a7 | ||
|
|
8b948a20cd | ||
|
|
5053ac213d | ||
|
|
e5ec801785 | ||
|
|
31e25232d0 | ||
|
|
cdc92aeebe | ||
|
|
c687ffed54 | ||
|
|
78c7681bc8 |
20
.github/ISSUE_TEMPLATE/新功能建议.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/新功能建议.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: 新功能建议
|
||||
about: 请为该项目提出一个想法
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**你的功能请求是否与某个问题相关?请描述。**
|
||||
请清晰、简洁地说明问题。例如:“我经常因为……而感到困扰。”
|
||||
|
||||
**你期望的解决方案**
|
||||
请清晰、简洁地描述你希望发生的事情/功能如何工作。
|
||||
|
||||
**你考虑过的替代方案**
|
||||
请清晰、简洁地说明你已考虑过的其他解决方案或功能。
|
||||
|
||||
**其他上下文**
|
||||
在此添加与功能请求相关的其他信息或截图。
|
||||
41
.github/ISSUE_TEMPLATE/错误-bug报告.md
vendored
Normal file
41
.github/ISSUE_TEMPLATE/错误-bug报告.md
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: 错误/Bug报告
|
||||
about: 创建报告以帮助我们改进
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**描述 Bug**
|
||||
对该 Bug 进行清晰简明的描述。
|
||||
|
||||
**复现步骤**
|
||||
复现该问题的步骤:
|
||||
|
||||
1. 进入 '...'
|
||||
2. 点击 '...'
|
||||
3. 下拉到 '...'
|
||||
4. 看到错误
|
||||
|
||||
**预期行为**
|
||||
清晰简明地描述你期望发生的情况。
|
||||
|
||||
**截图**
|
||||
如果适用,请添加截图以帮助解释问题。
|
||||
|
||||
**桌面端(请完成以下信息):**
|
||||
|
||||
* 操作系统:\[例如 iOS]
|
||||
* 浏览器:\[例如 Chrome、Safari]
|
||||
* 版本:\[例如 22]
|
||||
|
||||
**移动端(请完成以下信息):**
|
||||
|
||||
* 设备:\[例如 iPhone6]
|
||||
* 操作系统:\[例如 iOS8.1]
|
||||
* 浏览器:\[例如 系统自带浏览器、Safari]
|
||||
* 版本:\[例如 22]
|
||||
|
||||
**附加上下文**
|
||||
在此添加与问题相关的其他上下文信息。
|
||||
32
CODE_OF_CONDUCT.md
Normal file
32
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# OpenIsle Code of Conduct
|
||||
|
||||
Like the technical community as a whole, the OpenIsle team and community is made up of a mixture of professionals and volunteers from all over the world, working on every aspect of the mission - including mentorship, teaching, and connecting people.
|
||||
|
||||
Diversity is one of our huge strengths, but it can also lead to communication issues and unhappiness. To that end, we have a few ground rules that we ask people to adhere to. This code applies equally to founders, mentors and those seeking help and guidance.
|
||||
|
||||
This isn’t an exhaustive list of things that you can’t do. Rather, take it in the spirit in which it’s intended - a guide to make it easier to enrich all of us and the technical communities in which we participate.
|
||||
|
||||
This code of conduct applies to all spaces managed by the OpenIsle project or . This includes IRC, the mailing lists, the issue tracker, DSF events, and any other forums created by the project team which the community uses for communication. In addition, violations of this code outside these spaces may affect a person's ability to participate within them.
|
||||
|
||||
If you believe someone is violating the code of conduct, we ask that you report it by emailing [](mailto:). For more details please see our
|
||||
|
||||
- **Be friendly and patient.**
|
||||
- **Be welcoming.** We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, colour, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability.
|
||||
- **Be considerate.** Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language.
|
||||
- **Be respectful.** Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. It’s important to remember that a community where people feel uncomfortable or threatened is not a productive one. Members of the OpenIsle community should be respectful when dealing with other members as well as with people outside the OpenIsle community.
|
||||
- **Be careful in the words that you choose.** We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to:
|
||||
- Violent threats or language directed against another person.
|
||||
- Discriminatory jokes and language.
|
||||
- Posting sexually explicit or violent material.
|
||||
- Posting (or threatening to post) other people's personally identifying information ("doxing").
|
||||
- Personal insults, especially those using racist or sexist terms.
|
||||
- Unwelcome sexual attention.
|
||||
- Advocating for, or encouraging, any of the above behavior.
|
||||
- Repeated harassment of others. In general, if someone asks you to stop, then stop.
|
||||
- **When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time and OpenIsle is no exception. It is important that we resolve disagreements and differing views constructively. Remember that we’re different. The strength of OpenIsle comes from its varied community, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes.
|
||||
|
||||
Original text courtesy of the [Speak Up! project](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html).
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions, please see . If that doesn't answer your questions, feel free to [contact us](mailto:).
|
||||
@@ -58,6 +58,8 @@ cp open-isle.env.example open-isle.env
|
||||
|
||||
> Step3 前端部署
|
||||
|
||||
**⚠️ 环境要求:Node.js 版本最低 20.0.0(因为 Nuxt 框架要求)**
|
||||
|
||||
前端可以依赖本机部署的后端,也可以直接调用线上的后端接口
|
||||
|
||||
```shell
|
||||
@@ -74,11 +76,13 @@ cp .env.staging.example .env
|
||||
|
||||
```yaml
|
||||
; 本地部署后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
|
||||
NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8081
|
||||
; 预发环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
; 开发环境
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=localhost:3000
|
||||
```
|
||||
|
||||
2. 依赖预发环境后台环境
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Tim
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,9 +1,9 @@
|
||||
<p align="center">
|
||||
<BaseImage alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" width="200">
|
||||
<img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" width="200">
|
||||
<br>
|
||||
高效的开源社区前后端平台
|
||||
<br><br><br>
|
||||
<BaseImage alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
|
||||
<img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
|
||||
</p>
|
||||
|
||||
## 💡 简介
|
||||
|
||||
@@ -28,6 +28,7 @@ TWITTER_CLIENT_ID=<你的twitter-client-id>
|
||||
TWITTER_CLIENT_SECRET=<你的-twitter-client-secret>
|
||||
DISCORD_CLIENT_ID=<你的discord-client-id>
|
||||
DISCORD_CLIENT_SECRET=<你的discord-client-secret>
|
||||
TELEGRAM_BOT_TOKEN=<你的telegram-bot-token>
|
||||
|
||||
# === OPENAI ===
|
||||
OPENAI_API_KEY=<你的openai-api-key>
|
||||
|
||||
@@ -26,6 +26,7 @@ public class AuthController {
|
||||
private final GithubAuthService githubAuthService;
|
||||
private final DiscordAuthService discordAuthService;
|
||||
private final TwitterAuthService twitterAuthService;
|
||||
private final TelegramAuthService telegramAuthService;
|
||||
private final RegisterModeService registerModeService;
|
||||
private final NotificationService notificationService;
|
||||
private final UserRepository userRepository;
|
||||
@@ -360,6 +361,51 @@ public class AuthController {
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/telegram")
|
||||
public ResponseEntity<?> loginWithTelegram(@RequestBody TelegramLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
|
||||
if (viaInvite && !inviteValidateResult.isValidate()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = telegramAuthService.authenticate(
|
||||
req,
|
||||
registerModeService.getRegisterMode(),
|
||||
viaInvite);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
));
|
||||
}
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid telegram data",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/check")
|
||||
public ResponseEntity<?> checkToken() {
|
||||
return ResponseEntity.ok(Map.of("valid", true));
|
||||
|
||||
@@ -62,4 +62,14 @@ public class NotificationController {
|
||||
public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
|
||||
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
|
||||
}
|
||||
|
||||
@GetMapping("/email-prefs")
|
||||
public List<NotificationPreferenceDto> emailPrefs(Authentication auth) {
|
||||
return notificationService.listEmailPreferences(auth.getName());
|
||||
}
|
||||
|
||||
@PostMapping("/email-prefs")
|
||||
public void updateEmailPref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
|
||||
notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@@ -25,4 +27,10 @@ public class PointHistoryController {
|
||||
.map(pointHistoryMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/trend")
|
||||
public List<Map<String, Object>> trend(Authentication auth,
|
||||
@RequestParam(value = "days", defaultValue = "30") int days) {
|
||||
return pointService.trend(auth.getName(), days);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.openisle.controller;
|
||||
import com.openisle.dto.PostDetailDto;
|
||||
import com.openisle.dto.PostRequest;
|
||||
import com.openisle.dto.PostSummaryDto;
|
||||
import com.openisle.dto.PollDto;
|
||||
import com.openisle.mapper.PostMapper;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.service.*;
|
||||
@@ -42,7 +43,8 @@ public class PostController {
|
||||
req.getTitle(), req.getContent(), req.getTagIds(),
|
||||
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
|
||||
req.getPrizeCount(), req.getPointCost(),
|
||||
req.getStartTime(), req.getEndTime());
|
||||
req.getStartTime(), req.getEndTime(),
|
||||
req.getOptions(), req.getMultiple());
|
||||
draftService.deleteDraft(auth.getName());
|
||||
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
||||
dto.setReward(levelService.awardForPost(auth.getName()));
|
||||
@@ -86,6 +88,17 @@ public class PostController {
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/poll/progress")
|
||||
public ResponseEntity<PollDto> pollProgress(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/poll/vote")
|
||||
public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") List<Integer> option, Authentication auth) {
|
||||
postService.votePoll(id, auth.getName(), option);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<PostSummaryDto> listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
|
||||
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
|
||||
|
||||
@@ -36,6 +36,7 @@ public class ReactionController {
|
||||
Authentication auth) {
|
||||
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
|
||||
if (reaction == null) {
|
||||
pointService.deductForReactionOfPost(auth.getName(), postId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
ReactionDto dto = reactionMapper.toDto(reaction);
|
||||
@@ -50,6 +51,7 @@ public class ReactionController {
|
||||
Authentication auth) {
|
||||
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
|
||||
if (reaction == null) {
|
||||
pointService.deductForReactionOfComment(auth.getName(), commentId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
ReactionDto dto = reactionMapper.toDto(reaction);
|
||||
|
||||
17
backend/src/main/java/com/openisle/dto/PollDto.java
Normal file
17
backend/src/main/java/com/openisle/dto/PollDto.java
Normal file
@@ -0,0 +1,17 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class PollDto {
|
||||
private List<String> options;
|
||||
private Map<Integer, Integer> votes;
|
||||
private LocalDateTime endTime;
|
||||
private List<AuthorDto> participants;
|
||||
private Map<Integer, List<AuthorDto>> optionParticipants;
|
||||
private boolean multiple;
|
||||
}
|
||||
@@ -26,5 +26,8 @@ public class PostRequest {
|
||||
private Integer pointCost;
|
||||
private LocalDateTime startTime;
|
||||
private LocalDateTime endTime;
|
||||
// fields for poll posts
|
||||
private List<String> options;
|
||||
private Boolean multiple;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ public class PostSummaryDto {
|
||||
private int pointReward;
|
||||
private PostType type;
|
||||
private LotteryDto lottery;
|
||||
private PollDto poll;
|
||||
private boolean rssExcluded;
|
||||
private boolean closed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Request for Telegram login. */
|
||||
@Data
|
||||
public class TelegramLoginRequest {
|
||||
private String id;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String username;
|
||||
private String photoUrl;
|
||||
private Long authDate;
|
||||
private String hash;
|
||||
private String inviteToken;
|
||||
}
|
||||
@@ -5,18 +5,24 @@ import com.openisle.dto.PostDetailDto;
|
||||
import com.openisle.dto.PostSummaryDto;
|
||||
import com.openisle.dto.ReactionDto;
|
||||
import com.openisle.dto.LotteryDto;
|
||||
import com.openisle.dto.PollDto;
|
||||
import com.openisle.dto.AuthorDto;
|
||||
import com.openisle.model.CommentSort;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.LotteryPost;
|
||||
import com.openisle.model.PollPost;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.PollVote;
|
||||
import com.openisle.service.CommentService;
|
||||
import com.openisle.service.ReactionService;
|
||||
import com.openisle.service.SubscriptionService;
|
||||
import com.openisle.repository.PollVoteRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Mapper responsible for converting posts into DTOs. */
|
||||
@@ -32,6 +38,7 @@ public class PostMapper {
|
||||
private final UserMapper userMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
private final PollVoteRepository pollVoteRepository;
|
||||
|
||||
public PostSummaryDto toSummaryDto(Post post) {
|
||||
PostSummaryDto dto = new PostSummaryDto();
|
||||
@@ -93,5 +100,19 @@ public class PostMapper {
|
||||
l.setWinners(lp.getWinners().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
|
||||
dto.setLottery(l);
|
||||
}
|
||||
|
||||
if (post instanceof PollPost pp) {
|
||||
PollDto p = new PollDto();
|
||||
p.setOptions(pp.getOptions());
|
||||
p.setVotes(pp.getVotes());
|
||||
p.setEndTime(pp.getEndTime());
|
||||
p.setParticipants(pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
|
||||
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository.findByPostId(pp.getId()).stream()
|
||||
.collect(Collectors.groupingBy(PollVote::getOptionIndex,
|
||||
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())));
|
||||
p.setOptionParticipants(optionParticipants);
|
||||
p.setMultiple(Boolean.TRUE.equals(pp.getMultiple()));
|
||||
dto.setPoll(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.SQLDelete;
|
||||
import org.hibernate.annotations.Where;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -13,6 +15,8 @@ import java.time.LocalDateTime;
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "comments")
|
||||
@SQLDelete(sql = "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
|
||||
@Where(clause = "deleted_at IS NULL")
|
||||
public class Comment {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@@ -41,4 +45,7 @@ public class Comment {
|
||||
@Column
|
||||
private LocalDateTime pinnedAt;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private LocalDateTime deletedAt;
|
||||
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public class Draft {
|
||||
|
||||
private String title;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
@Column(columnDefinition = "LONGTEXT")
|
||||
private String content;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
|
||||
@@ -14,6 +14,13 @@ public class InviteToken {
|
||||
@Id
|
||||
private String token;
|
||||
|
||||
/**
|
||||
* Short token used in invite links. Existing records may have this field null
|
||||
* and fall back to {@link #token} for backward compatibility.
|
||||
*/
|
||||
@Column(unique = true)
|
||||
private String shortToken;
|
||||
|
||||
@ManyToOne
|
||||
private User inviter;
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ public enum NotificationType {
|
||||
LOTTERY_WIN,
|
||||
/** Your lottery post was drawn */
|
||||
LOTTERY_DRAW,
|
||||
/** Someone participated in your poll */
|
||||
POLL_VOTE,
|
||||
/** Your poll post has concluded */
|
||||
POLL_RESULT_OWNER,
|
||||
/** A poll you participated in has concluded */
|
||||
POLL_RESULT_PARTICIPANT,
|
||||
/** Your post was featured */
|
||||
POST_FEATURED,
|
||||
/** You were mentioned in a post or comment */
|
||||
|
||||
@@ -4,6 +4,8 @@ import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.SQLDelete;
|
||||
import org.hibernate.annotations.Where;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -13,6 +15,8 @@ import java.time.LocalDateTime;
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "point_histories")
|
||||
@SQLDelete(sql = "UPDATE point_histories SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
|
||||
@Where(clause = "deleted_at IS NULL")
|
||||
public class PointHistory {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@@ -46,4 +50,7 @@ public class PointHistory {
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private LocalDateTime deletedAt;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ public enum PointHistoryType {
|
||||
COMMENT,
|
||||
POST_LIKED,
|
||||
COMMENT_LIKED,
|
||||
POST_LIKE_CANCELLED,
|
||||
COMMENT_LIKE_CANCELLED,
|
||||
INVITE,
|
||||
FEATURE,
|
||||
SYSTEM_ONLINE,
|
||||
|
||||
43
backend/src/main/java/com/openisle/model/PollPost.java
Normal file
43
backend/src/main/java/com/openisle/model/PollPost.java
Normal file
@@ -0,0 +1,43 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "poll_posts")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@PrimaryKeyJoinColumn(name = "post_id")
|
||||
public class PollPost extends Post {
|
||||
@ElementCollection
|
||||
@CollectionTable(name = "poll_post_options", joinColumns = @JoinColumn(name = "post_id"))
|
||||
@Column(name = "option_text")
|
||||
private List<String> options = new ArrayList<>();
|
||||
|
||||
@ElementCollection
|
||||
@CollectionTable(name = "poll_post_votes", joinColumns = @JoinColumn(name = "post_id"))
|
||||
@MapKeyColumn(name = "option_index")
|
||||
@Column(name = "vote_count")
|
||||
private Map<Integer, Integer> votes = new HashMap<>();
|
||||
|
||||
@ManyToMany
|
||||
@JoinTable(name = "poll_participants",
|
||||
joinColumns = @JoinColumn(name = "post_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "user_id"))
|
||||
private Set<User> participants = new HashSet<>();
|
||||
|
||||
@Column
|
||||
private Boolean multiple = false;
|
||||
|
||||
@Column
|
||||
private LocalDateTime endTime;
|
||||
|
||||
@Column
|
||||
private boolean resultAnnounced = false;
|
||||
}
|
||||
28
backend/src/main/java/com/openisle/model/PollVote.java
Normal file
28
backend/src/main/java/com/openisle/model/PollVote.java
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "poll_votes", uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "user_id", "option_index"}))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class PollVote {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "post_id")
|
||||
private PollPost post;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@Column(name = "option_index", nullable = false)
|
||||
private int optionIndex;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ public class Post {
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
@Column(nullable = false, columnDefinition = "LONGTEXT")
|
||||
private String content;
|
||||
|
||||
@CreationTimestamp
|
||||
|
||||
@@ -2,5 +2,6 @@ package com.openisle.model;
|
||||
|
||||
public enum PostType {
|
||||
NORMAL,
|
||||
LOTTERY
|
||||
LOTTERY,
|
||||
POLL
|
||||
}
|
||||
|
||||
@@ -74,6 +74,12 @@ public class User {
|
||||
NotificationType.USER_ACTIVITY
|
||||
);
|
||||
|
||||
@ElementCollection(targetClass = NotificationType.class)
|
||||
@CollectionTable(name = "user_disabled_email_notification_types", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@Column(name = "notification_type")
|
||||
@Enumerated(EnumType.STRING)
|
||||
private Set<NotificationType> disabledEmailNotificationTypes = EnumSet.noneOf(NotificationType.class);
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(nullable = false, updatable = false,
|
||||
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
||||
|
||||
@@ -9,4 +9,8 @@ import java.util.Optional;
|
||||
|
||||
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
|
||||
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
|
||||
|
||||
Optional<InviteToken> findByShortToken(String shortToken);
|
||||
|
||||
boolean existsByShortToken(String shortToken);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.openisle.model.User;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.NotificationType;
|
||||
import com.openisle.model.ReactionType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -29,4 +30,8 @@ public interface NotificationRepository extends JpaRepository<Notification, Long
|
||||
List<Notification> findByTypeAndFromUser(NotificationType type, User fromUser);
|
||||
|
||||
void deleteByTypeAndFromUserAndPost(NotificationType type, User fromUser, Post post);
|
||||
|
||||
void deleteByTypeAndFromUserAndPostAndReactionType(NotificationType type, User fromUser, Post post, ReactionType reactionType);
|
||||
|
||||
void deleteByTypeAndFromUserAndCommentAndReactionType(NotificationType type, User fromUser, Comment comment, ReactionType reactionType);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,17 @@ package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.PointHistory;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.Comment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
|
||||
List<PointHistory> findByUserOrderByIdDesc(User user);
|
||||
long countByUser(User user);
|
||||
|
||||
List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt);
|
||||
|
||||
List<PointHistory> findByComment(Comment comment);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.PollPost;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public interface PollPostRepository extends JpaRepository<PollPost, Long> {
|
||||
List<PollPost> findByEndTimeAfterAndResultAnnouncedFalse(LocalDateTime now);
|
||||
|
||||
List<PollPost> findByEndTimeBeforeAndResultAnnouncedFalse(LocalDateTime now);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.PollVote;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PollVoteRepository extends JpaRepository<PollVote, Long> {
|
||||
List<PollVote> findByPostId(Long postId);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.NotificationType;
|
||||
import com.openisle.model.PointHistory;
|
||||
import com.openisle.model.CommentSort;
|
||||
import com.openisle.repository.CommentRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
@@ -11,8 +12,10 @@ import com.openisle.repository.UserRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.CommentSubscriptionRepository;
|
||||
import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.repository.PointHistoryRepository;
|
||||
import com.openisle.service.NotificationService;
|
||||
import com.openisle.service.SubscriptionService;
|
||||
import com.openisle.service.PointService;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -20,6 +23,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.HashSet;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -37,6 +43,8 @@ public class CommentService {
|
||||
private final ReactionRepository reactionRepository;
|
||||
private final CommentSubscriptionRepository commentSubscriptionRepository;
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final PointHistoryRepository pointHistoryRepository;
|
||||
private final PointService pointService;
|
||||
private final ImageUploader imageUploader;
|
||||
|
||||
@Transactional
|
||||
@@ -63,16 +71,19 @@ public class CommentService {
|
||||
log.debug("Comment {} saved for post {}", comment.getId(), postId);
|
||||
imageUploader.addReferences(imageUploader.extractUrls(content));
|
||||
if (!author.getId().equals(post.getAuthor().getId())) {
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, null, null, null, null);
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment,
|
||||
null, null, null, null);
|
||||
}
|
||||
for (User u : subscriptionService.getPostSubscribers(postId)) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null, null,
|
||||
null, null);
|
||||
}
|
||||
}
|
||||
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null,
|
||||
null, null);
|
||||
}
|
||||
}
|
||||
notificationService.notifyMentions(content, author, post, comment);
|
||||
@@ -109,21 +120,25 @@ public class CommentService {
|
||||
log.debug("Reply {} saved for parent {}", comment.getId(), parentId);
|
||||
imageUploader.addReferences(imageUploader.extractUrls(content));
|
||||
if (!author.getId().equals(parent.getAuthor().getId())) {
|
||||
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null);
|
||||
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(),
|
||||
comment, null, null, null, null);
|
||||
}
|
||||
for (User u : subscriptionService.getCommentSubscribers(parentId)) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment,
|
||||
null, null, null, null);
|
||||
}
|
||||
}
|
||||
for (User u : subscriptionService.getPostSubscribers(parent.getPost().getId())) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment,
|
||||
null, null, null, null);
|
||||
}
|
||||
}
|
||||
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment,
|
||||
null, null, null, null);
|
||||
}
|
||||
}
|
||||
notificationService.notifyMentions(content, author, parent.getPost(), comment);
|
||||
@@ -235,11 +250,33 @@ public class CommentService {
|
||||
for (Comment c : replies) {
|
||||
deleteCommentCascade(c);
|
||||
}
|
||||
|
||||
// 逻辑删除相关的积分历史记录,并收集受影响的用户
|
||||
List<PointHistory> pointHistories = pointHistoryRepository.findByComment(comment);
|
||||
// 收集需要重新计算积分的用户
|
||||
Set<User> usersToRecalculate = pointHistories.stream().map(PointHistory::getUser).collect(Collectors.toSet());
|
||||
|
||||
// 删除其他相关数据
|
||||
reactionRepository.findByComment(comment).forEach(reactionRepository::delete);
|
||||
commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete);
|
||||
notificationRepository.deleteAll(notificationRepository.findByComment(comment));
|
||||
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
|
||||
|
||||
// 逻辑删除评论
|
||||
commentRepository.delete(comment);
|
||||
// 删除积分历史
|
||||
pointHistoryRepository.deleteAll(pointHistories);
|
||||
|
||||
// 重新计算受影响用户的积分
|
||||
if (!usersToRecalculate.isEmpty()) {
|
||||
for (User user : usersToRecalculate) {
|
||||
int newPoints = pointService.recalculateUserPoints(user);
|
||||
user.setPoint(newPoints);
|
||||
log.debug("Recalculated points for user {}: {}", user.getUsername(), newPoints);
|
||||
}
|
||||
userRepository.saveAll(usersToRecalculate);
|
||||
}
|
||||
|
||||
log.debug("deleteCommentCascade removed comment {}", comment.getId());
|
||||
}
|
||||
|
||||
|
||||
@@ -30,33 +30,53 @@ public class InviteService {
|
||||
LocalDate today = LocalDate.now();
|
||||
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
|
||||
if (existing.isPresent()) {
|
||||
return existing.get().getToken();
|
||||
InviteToken inviteToken = existing.get();
|
||||
return inviteToken.getShortToken() != null ? inviteToken.getShortToken() : inviteToken.getToken();
|
||||
}
|
||||
|
||||
String token = jwtService.generateInviteToken(username);
|
||||
String shortToken;
|
||||
do {
|
||||
shortToken = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8);
|
||||
} while (inviteTokenRepository.existsByShortToken(shortToken));
|
||||
|
||||
InviteToken inviteToken = new InviteToken();
|
||||
inviteToken.setToken(token);
|
||||
inviteToken.setShortToken(shortToken);
|
||||
inviteToken.setInviter(inviter);
|
||||
inviteToken.setCreatedDate(today);
|
||||
inviteToken.setUsageCount(0);
|
||||
inviteTokenRepository.save(inviteToken);
|
||||
return token;
|
||||
return shortToken;
|
||||
}
|
||||
|
||||
public InviteValidateResult validate(String token) {
|
||||
if (token == null || token.isEmpty()) {
|
||||
return new InviteValidateResult(null, false);
|
||||
}
|
||||
|
||||
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
|
||||
String realToken = token;
|
||||
if (invite == null) {
|
||||
invite = inviteTokenRepository.findByShortToken(token).orElse(null);
|
||||
if (invite == null) {
|
||||
return new InviteValidateResult(null, false);
|
||||
}
|
||||
realToken = invite.getToken();
|
||||
}
|
||||
|
||||
try {
|
||||
jwtService.validateAndGetSubjectForInvite(token);
|
||||
jwtService.validateAndGetSubjectForInvite(realToken);
|
||||
} catch (Exception e) {
|
||||
return new InviteValidateResult(null, false);
|
||||
}
|
||||
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
|
||||
return new InviteValidateResult(invite, invite != null && invite.getUsageCount() < 3);
|
||||
|
||||
return new InviteValidateResult(invite, invite.getUsageCount() < 3);
|
||||
}
|
||||
|
||||
public void consume(String token, String newUserName) {
|
||||
InviteToken invite = inviteTokenRepository.findById(token).orElseThrow();
|
||||
InviteToken invite = inviteTokenRepository.findById(token)
|
||||
.orElseGet(() -> inviteTokenRepository.findByShortToken(token).orElseThrow());
|
||||
invite.setUsageCount(invite.getUsageCount() + 1);
|
||||
inviteTokenRepository.save(invite);
|
||||
pointService.awardForInvite(invite.getInviter().getUsername(), newUserName);
|
||||
|
||||
@@ -19,6 +19,7 @@ import java.util.regex.Pattern;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.Set;
|
||||
import java.util.HashSet;
|
||||
import java.util.EnumSet;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
@@ -40,6 +41,12 @@ public class NotificationService {
|
||||
|
||||
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
|
||||
|
||||
private static final Set<NotificationType> EMAIL_TYPES = EnumSet.of(
|
||||
NotificationType.COMMENT_REPLY,
|
||||
NotificationType.LOTTERY_WIN,
|
||||
NotificationType.LOTTERY_DRAW
|
||||
);
|
||||
|
||||
private String buildPayload(String body, String url) {
|
||||
// Ensure push notifications contain a link to the related resource so
|
||||
// that verifications can assert its presence and users can navigate
|
||||
@@ -75,7 +82,8 @@ public class NotificationService {
|
||||
n = notificationRepository.save(n);
|
||||
|
||||
// Runnable asyncTask = () -> {
|
||||
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null) {
|
||||
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null
|
||||
&& !user.getDisabledEmailNotificationTypes().contains(NotificationType.COMMENT_REPLY)) {
|
||||
String url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId());
|
||||
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
|
||||
sendCustomPush(user, "有人回复了你", url);
|
||||
@@ -114,6 +122,14 @@ public class NotificationService {
|
||||
return n;
|
||||
}
|
||||
|
||||
public void deleteReactionNotification(User fromUser, Post post, Comment comment, ReactionType reactionType) {
|
||||
if (post != null) {
|
||||
notificationRepository.deleteByTypeAndFromUserAndPostAndReactionType(NotificationType.REACTION, fromUser, post, reactionType);
|
||||
} else if (comment != null) {
|
||||
notificationRepository.deleteByTypeAndFromUserAndCommentAndReactionType(NotificationType.REACTION, fromUser, comment, reactionType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notifications for all admins when a user submits a register request.
|
||||
* Old register request notifications from the same applicant are removed first.
|
||||
@@ -179,6 +195,35 @@ public class NotificationService {
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
public List<NotificationPreferenceDto> listEmailPreferences(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Set<NotificationType> disabled = user.getDisabledEmailNotificationTypes();
|
||||
List<NotificationPreferenceDto> prefs = new ArrayList<>();
|
||||
for (NotificationType nt : EMAIL_TYPES) {
|
||||
NotificationPreferenceDto dto = new NotificationPreferenceDto();
|
||||
dto.setType(nt);
|
||||
dto.setEnabled(!disabled.contains(nt));
|
||||
prefs.add(dto);
|
||||
}
|
||||
return prefs;
|
||||
}
|
||||
|
||||
public void updateEmailPreference(String username, NotificationType type, boolean enabled) {
|
||||
if (!EMAIL_TYPES.contains(type)) {
|
||||
return;
|
||||
}
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Set<NotificationType> disabled = user.getDisabledEmailNotificationTypes();
|
||||
if (enabled) {
|
||||
disabled.remove(type);
|
||||
} else {
|
||||
disabled.add(type);
|
||||
}
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
public List<Notification> listNotifications(String username, Boolean read, int page, int size) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
|
||||
@@ -7,6 +7,10 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -146,6 +150,16 @@ public class PointService {
|
||||
return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner);
|
||||
}
|
||||
|
||||
public int deductForReactionOfPost(String reactionerName, Long postId) {
|
||||
User poster = postRepository.findById(postId).orElseThrow().getAuthor();
|
||||
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
|
||||
if (poster.getId().equals(reactioner.getId())) {
|
||||
return 0;
|
||||
}
|
||||
Post post = postRepository.findById(postId).orElseThrow();
|
||||
return addPoint(poster, -10, PointHistoryType.POST_LIKE_CANCELLED, post, null, reactioner);
|
||||
}
|
||||
|
||||
// 考虑点赞者和评论者是同一个的情况
|
||||
public int awardForReactionOfComment(String reactionerName, Long commentId) {
|
||||
// 根据帖子id找到评论者
|
||||
@@ -165,6 +179,17 @@ public class PointService {
|
||||
return addPoint(commenter, 10, PointHistoryType.COMMENT_LIKED, post, comment, reactioner);
|
||||
}
|
||||
|
||||
public int deductForReactionOfComment(String reactionerName, Long commentId) {
|
||||
User commenter = commentRepository.findById(commentId).orElseThrow().getAuthor();
|
||||
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
|
||||
if (commenter.getId().equals(reactioner.getId())) {
|
||||
return 0;
|
||||
}
|
||||
Comment comment = commentRepository.findById(commentId).orElseThrow();
|
||||
Post post = comment.getPost();
|
||||
return addPoint(commenter, -10, PointHistoryType.COMMENT_LIKE_CANCELLED, post, comment, reactioner);
|
||||
}
|
||||
|
||||
public java.util.List<PointHistory> listHistory(String userName) {
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
if (pointHistoryRepository.countByUser(user) == 0) {
|
||||
@@ -173,4 +198,53 @@ public class PointService {
|
||||
return pointHistoryRepository.findByUserOrderByIdDesc(user);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> trend(String userName, int days) {
|
||||
if (days < 1) days = 1;
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
LocalDate end = LocalDate.now();
|
||||
LocalDate start = end.minusDays(days - 1L);
|
||||
var histories = pointHistoryRepository.findByUserAndCreatedAtAfterOrderByCreatedAtDesc(
|
||||
user, start.atStartOfDay());
|
||||
int idx = 0;
|
||||
int balance = user.getPoint();
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (LocalDate day = end; !day.isBefore(start); day = day.minusDays(1)) {
|
||||
result.add(Map.of("date", day.toString(), "value", balance));
|
||||
while (idx < histories.size() && histories.get(idx).getCreatedAt().toLocalDate().isEqual(day)) {
|
||||
balance -= histories.get(idx).getAmount();
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
Collections.reverse(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新计算用户的积分总数
|
||||
* 通过累加所有积分历史记录来重新计算用户的当前积分
|
||||
*/
|
||||
public int recalculateUserPoints(User user) {
|
||||
// 获取用户所有的积分历史记录(由于@Where注解,已删除的记录会被自动过滤)
|
||||
List<PointHistory> histories = pointHistoryRepository.findByUserOrderByIdDesc(user);
|
||||
|
||||
int totalPoints = 0;
|
||||
for (PointHistory history : histories) {
|
||||
totalPoints += history.getAmount();
|
||||
}
|
||||
|
||||
// 更新用户积分
|
||||
user.setPoint(totalPoints);
|
||||
userRepository.save(user);
|
||||
|
||||
return totalPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新计算用户的积分总数(通过用户名)
|
||||
*/
|
||||
public int recalculateUserPoints(String userName) {
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
return recalculateUserPoints(user);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,8 +9,11 @@ import com.openisle.model.Category;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.NotificationType;
|
||||
import com.openisle.model.LotteryPost;
|
||||
import com.openisle.model.PollPost;
|
||||
import com.openisle.model.PollVote;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.LotteryPostRepository;
|
||||
import com.openisle.repository.PollPostRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.repository.CategoryRepository;
|
||||
import com.openisle.repository.TagRepository;
|
||||
@@ -20,6 +23,7 @@ import com.openisle.repository.CommentRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.PostSubscriptionRepository;
|
||||
import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.repository.PollVoteRepository;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -54,6 +58,8 @@ public class PostService {
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final LotteryPostRepository lotteryPostRepository;
|
||||
private final PollPostRepository pollPostRepository;
|
||||
private final PollVoteRepository pollVoteRepository;
|
||||
private PublishMode publishMode;
|
||||
private final NotificationService notificationService;
|
||||
private final SubscriptionService subscriptionService;
|
||||
@@ -78,6 +84,8 @@ public class PostService {
|
||||
CategoryRepository categoryRepository,
|
||||
TagRepository tagRepository,
|
||||
LotteryPostRepository lotteryPostRepository,
|
||||
PollPostRepository pollPostRepository,
|
||||
PollVoteRepository pollVoteRepository,
|
||||
NotificationService notificationService,
|
||||
SubscriptionService subscriptionService,
|
||||
CommentService commentService,
|
||||
@@ -97,6 +105,8 @@ public class PostService {
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.lotteryPostRepository = lotteryPostRepository;
|
||||
this.pollPostRepository = pollPostRepository;
|
||||
this.pollVoteRepository = pollVoteRepository;
|
||||
this.notificationService = notificationService;
|
||||
this.subscriptionService = subscriptionService;
|
||||
this.commentService = commentService;
|
||||
@@ -125,6 +135,15 @@ public class PostService {
|
||||
for (LotteryPost lp : lotteryPostRepository.findByEndTimeBeforeAndWinnersIsEmpty(now)) {
|
||||
applicationContext.getBean(PostService.class).finalizeLottery(lp.getId());
|
||||
}
|
||||
for (PollPost pp : pollPostRepository.findByEndTimeAfterAndResultAnnouncedFalse(now)) {
|
||||
ScheduledFuture<?> future = taskScheduler.schedule(
|
||||
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
|
||||
java.util.Date.from(pp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()));
|
||||
scheduledFinalizations.put(pp.getId(), future);
|
||||
}
|
||||
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
|
||||
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
|
||||
}
|
||||
}
|
||||
|
||||
public PublishMode getPublishMode() {
|
||||
@@ -166,7 +185,9 @@ public class PostService {
|
||||
Integer prizeCount,
|
||||
Integer pointCost,
|
||||
LocalDateTime startTime,
|
||||
LocalDateTime endTime) {
|
||||
LocalDateTime endTime,
|
||||
java.util.List<String> options,
|
||||
Boolean multiple) {
|
||||
long recent = postRepository.countByAuthorAfter(username,
|
||||
java.time.LocalDateTime.now().minusMinutes(5));
|
||||
if (recent >= 1) {
|
||||
@@ -200,6 +221,15 @@ public class PostService {
|
||||
lp.setStartTime(startTime);
|
||||
lp.setEndTime(endTime);
|
||||
post = lp;
|
||||
} else if (actualType == PostType.POLL) {
|
||||
if (options == null || options.size() < 2) {
|
||||
throw new IllegalArgumentException("At least two options required");
|
||||
}
|
||||
PollPost pp = new PollPost();
|
||||
pp.setOptions(options);
|
||||
pp.setEndTime(endTime);
|
||||
pp.setMultiple(multiple != null && multiple);
|
||||
post = pp;
|
||||
} else {
|
||||
post = new Post();
|
||||
}
|
||||
@@ -212,6 +242,8 @@ public class PostService {
|
||||
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
||||
if (post instanceof LotteryPost) {
|
||||
post = lotteryPostRepository.save((LotteryPost) post);
|
||||
} else if (post instanceof PollPost) {
|
||||
post = pollPostRepository.save((PollPost) post);
|
||||
} else {
|
||||
post = postRepository.save(post);
|
||||
}
|
||||
@@ -246,6 +278,11 @@ public class PostService {
|
||||
() -> applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()),
|
||||
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()));
|
||||
scheduledFinalizations.put(lp.getId(), future);
|
||||
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
|
||||
ScheduledFuture<?> future = taskScheduler.schedule(
|
||||
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
|
||||
java.util.Date.from(pp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()));
|
||||
scheduledFinalizations.put(pp.getId(), future);
|
||||
}
|
||||
return post;
|
||||
}
|
||||
@@ -261,6 +298,66 @@ public class PostService {
|
||||
}
|
||||
}
|
||||
|
||||
public PollPost getPoll(Long postId) {
|
||||
return pollPostRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PollPost votePoll(Long postId, String username, java.util.List<Integer> optionIndices) {
|
||||
PollPost post = pollPostRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
if (post.getEndTime() != null && post.getEndTime().isBefore(LocalDateTime.now())) {
|
||||
throw new IllegalStateException("Poll has ended");
|
||||
}
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
if (post.getParticipants().contains(user)) {
|
||||
throw new IllegalArgumentException("User already voted");
|
||||
}
|
||||
if (optionIndices == null || optionIndices.isEmpty()) {
|
||||
throw new IllegalArgumentException("No options selected");
|
||||
}
|
||||
java.util.Set<Integer> unique = new java.util.HashSet<>(optionIndices);
|
||||
for (int optionIndex : unique) {
|
||||
if (optionIndex < 0 || optionIndex >= post.getOptions().size()) {
|
||||
throw new IllegalArgumentException("Invalid option");
|
||||
}
|
||||
}
|
||||
post.getParticipants().add(user);
|
||||
for (int optionIndex : unique) {
|
||||
post.getVotes().merge(optionIndex, 1, Integer::sum);
|
||||
PollVote vote = new PollVote();
|
||||
vote.setPost(post);
|
||||
vote.setUser(user);
|
||||
vote.setOptionIndex(optionIndex);
|
||||
pollVoteRepository.save(vote);
|
||||
}
|
||||
PollPost saved = pollPostRepository.save(post);
|
||||
if (post.getAuthor() != null && !post.getAuthor().getId().equals(user.getId())) {
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.POLL_VOTE, post, null, null, user, null, null);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void finalizePoll(Long postId) {
|
||||
scheduledFinalizations.remove(postId);
|
||||
pollPostRepository.findById(postId).ifPresent(pp -> {
|
||||
if (pp.isResultAnnounced()) {
|
||||
return;
|
||||
}
|
||||
pp.setResultAnnounced(true);
|
||||
pollPostRepository.save(pp);
|
||||
if (pp.getAuthor() != null) {
|
||||
notificationService.createNotification(pp.getAuthor(), NotificationType.POLL_RESULT_OWNER, pp, null, null, null, null, null);
|
||||
}
|
||||
for (User participant : pp.getParticipants()) {
|
||||
notificationService.createNotification(participant, NotificationType.POLL_RESULT_PARTICIPANT, pp, null, null, null, null, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void finalizeLottery(Long postId) {
|
||||
log.info("start to finalizeLottery for {}", postId);
|
||||
@@ -277,14 +374,16 @@ public class PostService {
|
||||
lp.setWinners(winners);
|
||||
lotteryPostRepository.save(lp);
|
||||
for (User w : winners) {
|
||||
if (w.getEmail() != null) {
|
||||
if (w.getEmail() != null &&
|
||||
!w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)) {
|
||||
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
|
||||
}
|
||||
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
|
||||
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
|
||||
}
|
||||
if (lp.getAuthor() != null) {
|
||||
if (lp.getAuthor().getEmail() != null) {
|
||||
if (lp.getAuthor().getEmail() != null &&
|
||||
!lp.getAuthor().getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_DRAW)) {
|
||||
emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖");
|
||||
}
|
||||
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
|
||||
|
||||
@@ -42,6 +42,7 @@ public class ReactionService {
|
||||
java.util.Optional<Reaction> existing =
|
||||
reactionRepository.findByUserAndPostAndType(user, post, type);
|
||||
if (existing.isPresent()) {
|
||||
notificationService.deleteReactionNotification(user, post, null, type);
|
||||
reactionRepository.delete(existing.get());
|
||||
return null;
|
||||
}
|
||||
@@ -65,6 +66,7 @@ public class ReactionService {
|
||||
java.util.Optional<Reaction> existing =
|
||||
reactionRepository.findByUserAndCommentAndType(user, comment, type);
|
||||
if (existing.isPresent()) {
|
||||
notificationService.deleteReactionNotification(user, null, comment, type);
|
||||
reactionRepository.delete(existing.get());
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.dto.TelegramLoginRequest;
|
||||
import com.openisle.model.RegisterMode;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TelegramAuthService {
|
||||
private final UserRepository userRepository;
|
||||
private final AvatarGenerator avatarGenerator;
|
||||
|
||||
@Value("${telegram.bot-token:}")
|
||||
private String botToken;
|
||||
|
||||
public Optional<AuthResult> authenticate(TelegramLoginRequest req, RegisterMode mode, boolean viaInvite) {
|
||||
try {
|
||||
if (botToken == null || botToken.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String dataCheckString = buildDataCheckString(req);
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] secretKey = md.digest(botToken.getBytes(StandardCharsets.UTF_8));
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secretKey, "HmacSHA256"));
|
||||
byte[] hash = mac.doFinal(dataCheckString.getBytes(StandardCharsets.UTF_8));
|
||||
String hex = bytesToHex(hash);
|
||||
if (!hex.equalsIgnoreCase(req.getHash())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String username = req.getUsername();
|
||||
String email = (username != null ? username : req.getId()) + "@telegram.org";
|
||||
String avatar = req.getPhotoUrl();
|
||||
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private String buildDataCheckString(TelegramLoginRequest req) {
|
||||
List<String> data = new ArrayList<>();
|
||||
if (req.getAuthDate() != null) data.add("auth_date=" + req.getAuthDate());
|
||||
if (req.getFirstName() != null) data.add("first_name=" + req.getFirstName());
|
||||
if (req.getId() != null) data.add("id=" + req.getId());
|
||||
if (req.getLastName() != null) data.add("last_name=" + req.getLastName());
|
||||
if (req.getPhotoUrl() != null) data.add("photo_url=" + req.getPhotoUrl());
|
||||
if (req.getUsername() != null) data.add("username=" + req.getUsername());
|
||||
Collections.sort(data);
|
||||
return String.join("\n", data);
|
||||
}
|
||||
|
||||
private String bytesToHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private AuthResult processUser(String email, String username, String avatar, RegisterMode mode, boolean viaInvite) {
|
||||
Optional<User> existing = userRepository.findByEmail(email);
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
if (!user.isVerified()) {
|
||||
user.setVerified(true);
|
||||
user.setVerificationCode(null);
|
||||
userRepository.save(user);
|
||||
}
|
||||
return new AuthResult(user, false);
|
||||
}
|
||||
String baseUsername = username != null ? username : email.split("@")[0];
|
||||
String finalUsername = baseUsername;
|
||||
int suffix = 1;
|
||||
while (userRepository.findByUsername(finalUsername).isPresent()) {
|
||||
finalUsername = baseUsername + suffix++;
|
||||
}
|
||||
User user = new User();
|
||||
user.setUsername(finalUsername);
|
||||
user.setEmail(email);
|
||||
user.setPassword("");
|
||||
user.setRole(Role.USER);
|
||||
user.setVerified(true);
|
||||
user.setApproved(mode == RegisterMode.DIRECT || viaInvite);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar(avatarGenerator.generate(finalUsername));
|
||||
}
|
||||
return new AuthResult(userRepository.save(user), true);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,8 @@ discord.client-secret=${DISCORD_CLIENT_SECRET:}
|
||||
# Twitter OAuth configuration
|
||||
twitter.client-id=${TWITTER_CLIENT_ID:}
|
||||
twitter.client-secret=${TWITTER_CLIENT_SECRET:}
|
||||
# Telegram login configuration
|
||||
telegram.bot-token=${TELEGRAM_BOT_TOKEN:}
|
||||
# OpenAI configuration
|
||||
openai.api-key=${OPENAI_API_KEY:}
|
||||
openai.model=${OPENAI_MODEL:gpt-4o}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Add logical delete support for comments and point_histories tables
|
||||
|
||||
-- Add deleted_at column to comments table
|
||||
ALTER TABLE comments ADD COLUMN deleted_at DATETIME(6) NULL;
|
||||
|
||||
-- Add deleted_at column to point_histories table
|
||||
ALTER TABLE point_histories ADD COLUMN deleted_at DATETIME(6) NULL;
|
||||
|
||||
-- Add index for better performance on logical delete queries
|
||||
CREATE INDEX idx_comments_deleted_at ON comments(deleted_at);
|
||||
CREATE INDEX idx_point_histories_deleted_at ON point_histories(deleted_at);
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.config.CustomAccessDeniedHandler;
|
||||
import com.openisle.config.SecurityConfig;
|
||||
import com.openisle.service.PointService;
|
||||
import com.openisle.mapper.PointHistoryMapper;
|
||||
import com.openisle.service.JwtService;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.Role;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(PointHistoryController.class)
|
||||
@AutoConfigureMockMvc
|
||||
@Import({SecurityConfig.class, CustomAccessDeniedHandler.class})
|
||||
class PointHistoryControllerTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private JwtService jwtService;
|
||||
@MockBean
|
||||
private UserRepository userRepository;
|
||||
@MockBean
|
||||
private PointService pointService;
|
||||
@MockBean
|
||||
private PointHistoryMapper pointHistoryMapper;
|
||||
|
||||
@Test
|
||||
void trendReturnsSeries() throws Exception {
|
||||
Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user");
|
||||
User user = new User();
|
||||
user.setUsername("user");
|
||||
user.setPassword("p");
|
||||
user.setEmail("u@example.com");
|
||||
user.setRole(Role.USER);
|
||||
Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user));
|
||||
List<Map<String, Object>> data = List.of(
|
||||
Map.of("date", java.time.LocalDate.now().minusDays(1).toString(), "value", 100),
|
||||
Map.of("date", java.time.LocalDate.now().toString(), "value", 110)
|
||||
);
|
||||
Mockito.when(pointService.trend(Mockito.eq("user"), Mockito.anyInt())).thenReturn(data);
|
||||
|
||||
mockMvc.perform(get("/api/point-histories/trend").param("days", "2")
|
||||
.header("Authorization", "Bearer token"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].value").value(100))
|
||||
.andExpect(jsonPath("$[1].value").value(110));
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ class PostControllerTest {
|
||||
post.setTags(Set.of(tag));
|
||||
|
||||
when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"), eq(List.of(1L)),
|
||||
isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
|
||||
isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
|
||||
when(postService.viewPost(eq(1L), any())).thenReturn(post);
|
||||
when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of());
|
||||
when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of());
|
||||
@@ -187,7 +187,7 @@ class PostControllerTest {
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verify(postService, never()).createPost(any(), any(), any(), any(), any(),
|
||||
any(), any(), any(), any(), any(), any(), any());
|
||||
any(), any(), any(), any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.openisle.repository.UserRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.CommentSubscriptionRepository;
|
||||
import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.repository.PointHistoryRepository;
|
||||
import com.openisle.service.PointService;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -24,10 +26,12 @@ class CommentServiceTest {
|
||||
ReactionRepository reactionRepo = mock(ReactionRepository.class);
|
||||
CommentSubscriptionRepository subRepo = mock(CommentSubscriptionRepository.class);
|
||||
NotificationRepository nRepo = mock(NotificationRepository.class);
|
||||
PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class);
|
||||
PointService pointService = mock(PointService.class);
|
||||
ImageUploader imageUploader = mock(ImageUploader.class);
|
||||
|
||||
CommentService service = new CommentService(commentRepo, postRepo, userRepo,
|
||||
notifService, subService, reactionRepo, subRepo, nRepo, imageUploader);
|
||||
notifService, subService, reactionRepo, subRepo, nRepo, pointHistoryRepo, pointService, imageUploader);
|
||||
|
||||
when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L);
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ class PostServiceTest {
|
||||
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
CommentService commentService = mock(CommentService.class);
|
||||
@@ -37,7 +39,7 @@ class PostServiceTest {
|
||||
PointService pointService = mock(PointService.class);
|
||||
|
||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||
notifService, subService, commentService, commentRepo,
|
||||
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
|
||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
@@ -69,6 +71,8 @@ class PostServiceTest {
|
||||
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
CommentService commentService = mock(CommentService.class);
|
||||
@@ -84,7 +88,7 @@ class PostServiceTest {
|
||||
PointService pointService = mock(PointService.class);
|
||||
|
||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||
notifService, subService, commentService, commentRepo,
|
||||
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
|
||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
@@ -122,6 +126,8 @@ class PostServiceTest {
|
||||
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
CommentService commentService = mock(CommentService.class);
|
||||
@@ -137,7 +143,7 @@ class PostServiceTest {
|
||||
PointService pointService = mock(PointService.class);
|
||||
|
||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||
notifService, subService, commentService, commentRepo,
|
||||
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
|
||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
@@ -146,7 +152,7 @@ class PostServiceTest {
|
||||
|
||||
assertThrows(RateLimitException.class,
|
||||
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
|
||||
null, null, null, null, null, null, null));
|
||||
null, null, null, null, null, null, null, null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -156,6 +162,8 @@ class PostServiceTest {
|
||||
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
CommentService commentService = mock(CommentService.class);
|
||||
@@ -171,7 +179,7 @@ class PostServiceTest {
|
||||
PointService pointService = mock(PointService.class);
|
||||
|
||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||
notifService, subService, commentService, commentRepo,
|
||||
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
|
||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
; 预发环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
NUXT_PUBLIC_API_BASE_URL=https://open-isle.com
|
||||
|
||||
; 预发环境
|
||||
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
|
||||
; 正式环境/生产环境
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://open-isle.com
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
; 预发环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://open-isle.com
|
||||
|
||||
; 预发环境
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
|
||||
; 正式环境/生产环境
|
||||
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
|
||||
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://open-isle.com
|
||||
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
||||
|
||||
@@ -58,6 +58,7 @@ const hideMenu = computed(() => {
|
||||
'/discord-callback',
|
||||
'/forgot-password',
|
||||
'/google-callback',
|
||||
'/telegram-callback',
|
||||
].includes(useRoute().path)
|
||||
})
|
||||
|
||||
@@ -138,7 +139,7 @@ const goToNewPost = () => {
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
bottom: 70px;
|
||||
right: 20px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
--background-color-blur: rgba(255, 255, 255, 0.57);
|
||||
--menu-border-color: lightgray;
|
||||
--normal-border-color: lightgray;
|
||||
--menu-selected-background-color: rgba(228, 228, 228, 0.884);
|
||||
--menu-text-color: black;
|
||||
--menu-selected-background-color: rgba(242, 242, 242, 0.884);
|
||||
--menu-text-color: rgb(99, 99, 99);
|
||||
--scroller-background-color: rgba(130, 175, 180, 0.5);
|
||||
/* --normal-background-color: rgb(241, 241, 241); */
|
||||
--normal-background-color: white;
|
||||
@@ -27,13 +27,20 @@
|
||||
--code-highlight-background-color: rgb(241, 241, 241);
|
||||
--login-background-color: rgb(248, 248, 248);
|
||||
--login-background-color-hover: #e0e0e0;
|
||||
--text-color: black;
|
||||
--text-color: rgb(70, 70, 70);
|
||||
--blockquote-text-color: #6a737d;
|
||||
--menu-width: 200px;
|
||||
--page-max-width: 1400px;
|
||||
--page-max-width-mobile: 900px;
|
||||
--article-info-background-color: #f0f0f0;
|
||||
--activity-card-background-color: #fafafa;
|
||||
--poll-option-button-background-color: rgb(218, 218, 218);
|
||||
--telegram-bg: #caedff74;
|
||||
--telegram-bg-hover: #67a2c088;
|
||||
--twitter-bg: rgb(68, 68, 68);
|
||||
--twitter-bg-hover: rgb(91, 91, 91);
|
||||
--discord-bg: #5865f258;
|
||||
--discord-bg-hover: #5865f2b1;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
@@ -50,7 +57,7 @@
|
||||
--menu-border-color: #555;
|
||||
--normal-border-color: #555;
|
||||
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
|
||||
--menu-text-color: white;
|
||||
--menu-text-color: rgb(173, 173, 173);
|
||||
/* --normal-background-color: #000000; */
|
||||
--normal-background-color: #333;
|
||||
--lottery-background-color: #4e4e4e;
|
||||
@@ -61,6 +68,7 @@
|
||||
--blockquote-text-color: #999;
|
||||
--article-info-background-color: #747373;
|
||||
--activity-card-background-color: #585858;
|
||||
--poll-option-button-background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
:root[data-frosted='off'] {
|
||||
@@ -75,7 +83,7 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-family: 'WenQuanYi Micro Hei', 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--normal-background-color);
|
||||
color: var(--text-color);
|
||||
/* 禁止滚动 */
|
||||
@@ -91,7 +99,7 @@ body {
|
||||
|
||||
.vditor-toolbar--pin {
|
||||
top: calc(var(--header-height) + 1px) !important;
|
||||
z-index: 2000;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.vditor-panel {
|
||||
@@ -142,6 +150,7 @@ body {
|
||||
.info-content-text video {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.info-content-text {
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
@@ -161,6 +170,7 @@ body {
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
||||
.info-content-text pre .line-numbers {
|
||||
@@ -187,7 +197,6 @@ body {
|
||||
font-family: 'Maple Mono', monospace;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
white-space: break-spaces;
|
||||
background-color: var(--code-highlight-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
@@ -236,6 +245,14 @@ body {
|
||||
overflow-x: auto; /* 小屏可横向滚动 */
|
||||
}
|
||||
|
||||
.info-content-text hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--normal-border-color);
|
||||
padding: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-content-text thead th {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 512"><path fill="#5865F2" d="M105 0h302c57.928.155 104.845 47.072 105 104.996V407c-.155 57.926-47.072 104.844-104.996 104.998L105 512C47.074 511.844.156 464.926.002 407.003L0 105C.156 47.072 47.074.155 104.997 0H105z"/><g data-name="å¾å± 2"><g data-name="Discord Logos"><path fill="#fff" fill-rule="nonzero" d="M368.896 153.381a269.506 269.506 0 00-67.118-20.637 186.88 186.88 0 00-8.57 17.475 250.337 250.337 0 00-37.247-2.8c-12.447 0-24.955.946-37.25 2.776-2.511-5.927-5.427-11.804-8.592-17.454a271.73 271.73 0 00-67.133 20.681c-42.479 62.841-53.991 124.112-48.235 184.513a270.622 270.622 0 0082.308 41.312c6.637-8.959 12.582-18.497 17.63-28.423a173.808 173.808 0 01-27.772-13.253c2.328-1.688 4.605-3.427 6.805-5.117 25.726 12.083 53.836 18.385 82.277 18.385 28.442 0 56.551-6.302 82.279-18.387 2.226 1.817 4.503 3.557 6.805 5.117a175.002 175.002 0 01-27.823 13.289 197.847 197.847 0 0017.631 28.4 269.513 269.513 0 0082.363-41.305l-.007.007c6.754-70.045-11.538-130.753-48.351-184.579zM201.968 300.789c-16.04 0-29.292-14.557-29.292-32.465s12.791-32.592 29.241-32.592 29.599 14.684 29.318 32.592c-.282 17.908-12.919 32.465-29.267 32.465zm108.062 0c-16.066 0-29.267-14.557-29.267-32.465s12.791-32.592 29.267-32.592c16.475 0 29.522 14.684 29.241 32.592-.281 17.908-12.894 32.465-29.241 32.465z" data-name="Discord Logo - Large - White"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
1
frontend_nuxt/assets/icons/telegram.svg
Normal file
1
frontend_nuxt/assets/icons/telegram.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><defs><clipPath id="A"><path d="M15.903 131.998c0-65.17 52.83-118 118-118s118 52.83 118 118-52.83 118-118 118-118-52.83-118-118"/></clipPath><linearGradient x1="133.903" y1="13.999" x2="133.903" y2="249.999" gradientUnits="userSpaceOnUse" spreadMethod="pad" id="B"><stop offset="0" stop-color="#1d93d2"/><stop offset="1" stop-color="#38b0e3"/></linearGradient><clipPath id="C"><path d="M0 265.9h266.987V0H0z"/></clipPath><clipPath id="D"><path d="M0 265.9h266.987V0H0z"/></clipPath><clipPath id="E"><path d="M0 265.9h266.987V0H0z"/></clipPath></defs><g transform="matrix(.271187 0 0 -.271187 -4.312678 67.796339)"><path d="M15.903 131.998c0-65.17 52.83-118 118-118s118 52.83 118 118-52.83 118-118 118-118-52.83-118-118" fill="url(#B)" clip-path="url(#A)"/><g clip-path="url(#C)"><path d="M95.778 123.374l14-38.75S111.528 81 113.403 81s29.75 29 29.75 29l31 59.875-77.875-36.5z" fill="#c8daea"/></g><g clip-path="url(#D)"><path d="M114.34 113.436l-2.688-28.562s-1.125-8.75 7.625 0 17.125 15.5 17.125 15.5" fill="#a9c6d8"/></g><g clip-path="url(#E)"><path d="M96.03 121.99l-28.795 9.383s-3.437 1.395-2.333 4.562c.228.653.687 1.208 2.062 2.167 6.382 4.447 118.104 44.604 118.104 44.604s3.155 1.062 5.02.356c.852-.323 1.396-.688 1.854-2.02.167-.485.263-1.516.25-2.542-.01-.74-.1-1.425-.166-2.5-.68-10.98-21.04-92.918-21.04-92.918s-1.218-4.795-5.583-4.958c-1.592-.06-3.524.263-5.834 2.25-8.565 7.368-38.172 27.265-44.713 31.64-.37.246-.474.567-.537.88-.092.46.4 1.034.4 1.034s51.552 45.825 52.924 50.633c.106.373-.293.557-.834.396-3.424-1.26-62.78-38.74-69.33-42.88-.383-.242-1.457-.086-1.457-.086" fill="#fff"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1,4 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Twitter icon</title>
|
||||
<path d="M23.954 4.569c-.885.389-1.83.654-2.825.775 1.014-.611 1.794-1.574 2.163-2.723-.949.555-2.005.959-3.127 1.184-.897-.959-2.173-1.559-3.591-1.559-2.717 0-4.92 2.203-4.92 4.917 0 .39.045.765.127 1.124-4.083-.205-7.697-2.159-10.126-5.134-.422.722-.666 1.561-.666 2.475 0 1.709.87 3.214 2.188 4.096-.807-.026-1.566-.248-2.229-.616v.061c0 2.385 1.693 4.374 3.946 4.827-.413.111-.849.171-1.296.171-.314 0-.615-.03-.916-.086.631 1.953 2.445 3.376 4.6 3.416-1.68 1.318-3.808 2.105-6.102 2.105-.39 0-.779-.023-1.17-.069 2.189 1.394 4.768 2.209 7.548 2.209 9.051 0 14.001-7.496 14.001-13.986 0-.21 0-.42-.015-.63.961-.689 1.8-1.56 2.46-2.548l-.047-.02z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-480 -466.815 2160 2160"><circle fill="#444" cx="600" cy="613.185" r="1080"/><path fill="#fff" d="M306.615 79.694H144.011L892.476 1150.3h162.604ZM0 0h357.328l309.814 450.883L1055.03 0h105.86L714.15 519.295 1200 1226.37H842.672L515.493 750.215 105.866 1226.37H0l468.485-544.568Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 764 B After Width: | Height: | Size: 335 B |
@@ -2,7 +2,7 @@
|
||||
<div v-if="show" class="cropper-modal">
|
||||
<div class="cropper-body">
|
||||
<div class="cropper-wrapper">
|
||||
<BaseImage ref="image" :src="src" alt="to crop" />
|
||||
<img ref="image" :src="src" alt="to crop" />
|
||||
</div>
|
||||
<div class="cropper-actions">
|
||||
<button class="cropper-btn" @click="$emit('close')">取消</button>
|
||||
|
||||
@@ -59,7 +59,7 @@ function onError() {
|
||||
}
|
||||
|
||||
.base-image.is-loaded {
|
||||
filter: none;
|
||||
/* Allow filters from parent classes (e.g. grayscale for unfinished medals) */
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,13 @@
|
||||
>{{ getMedalTitle(comment.medal) }}</NuxtLink
|
||||
>
|
||||
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
|
||||
<span v-if="level >= 2">
|
||||
<span v-if="level >= 2" class="reply-item">
|
||||
<i class="fas fa-reply reply-icon"></i>
|
||||
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
|
||||
<span class="reply-info">
|
||||
<BaseImage class="reply-avatar" :src="comment.parentUserAvatar || '/default-avatar.svg'" alt="avatar"
|
||||
@click="comment.parentUserClick && comment.parentUserClick()" />
|
||||
<span class="reply-user-name">{{ comment.parentUserName }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<div class="post-time">{{ comment.time }}</div>
|
||||
</div>
|
||||
@@ -250,6 +254,7 @@ const submitReply = async (parentUserName, text, clear) => {
|
||||
medal: data.author.displayMedal,
|
||||
text: data.content,
|
||||
parentUserName: parentUserName,
|
||||
parentUserAvatar: props.comment.avatar,
|
||||
reactions: [],
|
||||
reply: (data.replies || []).map((r) => ({
|
||||
id: r.id,
|
||||
@@ -376,7 +381,22 @@ const handleContentClick = (e) => {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.reply-item, .reply-info {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reply-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reply-icon {
|
||||
color: var(--primary-color);
|
||||
margin-right: 10px;
|
||||
margin-left: 10px;
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="header-content-left">
|
||||
<div v-if="showMenuBtn" class="menu-btn-wrapper">
|
||||
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
|
||||
<i class="fas fa-bars"></i>
|
||||
<i class="fas fa-bars micon"></i>
|
||||
</button>
|
||||
<span
|
||||
v-if="isMobile && (unreadMessageCount > 0 || hasChannelUnread)"
|
||||
@@ -63,7 +63,7 @@
|
||||
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
|
||||
<template #trigger>
|
||||
<div class="avatar-container">
|
||||
<BaseImage class="avatar-img" :src="avatar" alt="avatar" />
|
||||
<img class="avatar-img" :src="avatar" alt="avatar" />
|
||||
<i class="fas fa-caret-down dropdown-icon"></i>
|
||||
</div>
|
||||
</template>
|
||||
@@ -318,6 +318,10 @@ onMounted(async () => {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.micon {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
font-size: 24px;
|
||||
background: none;
|
||||
@@ -370,6 +374,7 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
|
||||
189
frontend_nuxt/components/LotteryForm.vue
Normal file
189
frontend_nuxt/components/LotteryForm.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div class="lottery-section">
|
||||
<AvatarCropper
|
||||
:src="data.tempPrizeIcon"
|
||||
:show="data.showPrizeCropper"
|
||||
@close="data.showPrizeCropper = false"
|
||||
@crop="onPrizeCropped"
|
||||
/>
|
||||
<div class="prize-row">
|
||||
<span class="prize-row-title">奖品图片</span>
|
||||
<label class="prize-container">
|
||||
<BaseImage v-if="data.prizeIcon" :src="data.prizeIcon" class="prize-preview" alt="prize" />
|
||||
<i v-else class="fa-solid fa-image default-prize-icon"></i>
|
||||
<div class="prize-overlay">上传奖品图片</div>
|
||||
<input type="file" class="prize-input" accept="image/*" @change="onPrizeIconChange" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="prize-name-row">
|
||||
<span class="prize-row-title">奖品描述</span>
|
||||
<BaseInput v-model="data.prizeDescription" placeholder="奖品描述" />
|
||||
</div>
|
||||
<div class="prize-count-row">
|
||||
<span class="prize-row-title">奖品数量</span>
|
||||
<div class="prize-count-input">
|
||||
<input
|
||||
class="prize-count-input-field"
|
||||
type="number"
|
||||
v-model.number="data.prizeCount"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prize-point-row">
|
||||
<span class="prize-row-title">参与所需积分</span>
|
||||
<div class="prize-count-input">
|
||||
<input
|
||||
class="prize-count-input-field"
|
||||
type="number"
|
||||
v-model.number="data.pointCost"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prize-time-row">
|
||||
<span class="prize-row-title">抽奖结束时间</span>
|
||||
<client-only>
|
||||
<flat-pickr v-model="data.endTime" :config="dateConfig" class="time-picker" />
|
||||
</client-only>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import FlatPickr from 'vue-flatpickr-component'
|
||||
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||
import BaseImage from '~/components/BaseImage.vue'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import { watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
|
||||
|
||||
const onPrizeIconChange = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
props.data.tempPrizeIcon = reader.result
|
||||
props.data.showPrizeCropper = true
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
const onPrizeCropped = ({ file, url }) => {
|
||||
props.data.prizeIconFile = file
|
||||
props.data.prizeIcon = url
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data.prizeCount,
|
||||
(val) => {
|
||||
if (!val || val < 1) props.data.prizeCount = 1
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.data.pointCost,
|
||||
(val) => {
|
||||
if (val === undefined || val === null || val < 0) props.data.pointCost = 0
|
||||
if (val > 100) props.data.pointCost = 100
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lottery-section {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-bottom: 200px;
|
||||
}
|
||||
.prize-row-title {
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.prize-row,
|
||||
.prize-name-row,
|
||||
.prize-count-row,
|
||||
.prize-point-row,
|
||||
.prize-time-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.prize-container {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background-color: var(--lottery-background-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.default-prize-icon {
|
||||
font-size: 30px;
|
||||
opacity: 0.1;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.prize-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.prize-input {
|
||||
display: none;
|
||||
}
|
||||
.prize-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.prize-container:hover .prize-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
.prize-count-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.prize-count-input-field {
|
||||
width: 50px;
|
||||
height: 30px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0 10px;
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
background-color: var(--lottery-background-color);
|
||||
}
|
||||
.time-picker {
|
||||
max-width: 200px;
|
||||
height: 30px;
|
||||
background-color: var(--lottery-background-color);
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
@@ -279,12 +279,29 @@ const gotoTag = (t) => {
|
||||
padding: 10px 10px 0 10px;
|
||||
}
|
||||
|
||||
.menu-content::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.menu-item-container {
|
||||
border-bottom: 1px solid var(--menu-border-color);
|
||||
}
|
||||
|
||||
.menu-item:last-child {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
/* .menu-item-container { */
|
||||
/**/
|
||||
/* } */
|
||||
|
||||
.menu-item {
|
||||
padding: 4px 10px;
|
||||
padding: 6px 12px;
|
||||
text-decoration: none;
|
||||
color: var(--menu-text-color);
|
||||
border-radius: 10px;
|
||||
@@ -298,7 +315,7 @@ const gotoTag = (t) => {
|
||||
}
|
||||
|
||||
.menu-item-text {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
color: var(--menu-text-color);
|
||||
}
|
||||
@@ -352,16 +369,17 @@ const gotoTag = (t) => {
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
margin-top: 10px;
|
||||
border-bottom: 1px solid var(--menu-border-color);
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: bold;
|
||||
opacity: 0.5;
|
||||
padding: 4px 10px;
|
||||
font-size: 14px;
|
||||
padding: 6px 12px 0 12px;
|
||||
color: var(--menu-text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -373,7 +391,7 @@ const gotoTag = (t) => {
|
||||
}
|
||||
|
||||
.section-item {
|
||||
padding: 4px 10px;
|
||||
padding: 6px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
@@ -393,6 +411,8 @@ const gotoTag = (t) => {
|
||||
}
|
||||
|
||||
.section-item-text {
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
color: var(--menu-text-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
@click="reboundToDefault"
|
||||
></i>
|
||||
<i class="fas fa-expand" title="在页面中打开" @click="expand"></i>
|
||||
<i class="fas fa-times" title="关闭" @click="close"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -48,6 +49,10 @@ function expand() {
|
||||
navigateTo(target)
|
||||
}
|
||||
|
||||
function close() {
|
||||
floatRoute.value = null
|
||||
}
|
||||
|
||||
function injectBaseTag() {
|
||||
if (!iframeRef.value) return
|
||||
|
||||
|
||||
100
frontend_nuxt/components/PollForm.vue
Normal file
100
frontend_nuxt/components/PollForm.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="poll-section">
|
||||
<div class="poll-options-row">
|
||||
<span class="poll-row-title">投票选项</span>
|
||||
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
|
||||
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
|
||||
<i
|
||||
v-if="data.options.length > 2"
|
||||
class="fa-solid fa-xmark remove-option-icon"
|
||||
@click="removeOption(idx)"
|
||||
></i>
|
||||
</div>
|
||||
<div class="add-option" @click="addOption">添加选项</div>
|
||||
</div>
|
||||
<div class="poll-time-row">
|
||||
<span class="poll-row-title">投票结束时间</span>
|
||||
<client-only>
|
||||
<flat-pickr v-model="data.endTime" :config="dateConfig" class="time-picker" />
|
||||
</client-only>
|
||||
</div>
|
||||
<div class="poll-multiple-row">
|
||||
<span class="poll-row-title">多选</span>
|
||||
<BaseSwitch v-model="data.multiple" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import FlatPickr from 'vue-flatpickr-component'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import BaseSwitch from '~/components/BaseSwitch.vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
|
||||
|
||||
const addOption = () => {
|
||||
props.data.options.push('')
|
||||
}
|
||||
|
||||
const removeOption = (idx) => {
|
||||
if (props.data.options.length > 2) {
|
||||
props.data.options.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.poll-section {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-bottom: 200px;
|
||||
}
|
||||
.poll-row-title {
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.poll-option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.remove-option-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
.add-option {
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.poll-options-row,
|
||||
.poll-time-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.poll-multiple-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.time-picker {
|
||||
max-width: 200px;
|
||||
height: 30px;
|
||||
background-color: var(--lottery-background-color);
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
310
frontend_nuxt/components/PostLottery.vue
Normal file
310
frontend_nuxt/components/PostLottery.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<div class="post-prize-container" v-if="lottery">
|
||||
<div class="prize-content">
|
||||
<div class="prize-info">
|
||||
<div class="prize-info-left">
|
||||
<div class="prize-icon">
|
||||
<BaseImage
|
||||
class="prize-icon-img"
|
||||
v-if="lottery.prizeIcon"
|
||||
:src="lottery.prizeIcon"
|
||||
alt="prize"
|
||||
/>
|
||||
<i v-else class="fa-solid fa-gift default-prize-icon"></i>
|
||||
</div>
|
||||
<div class="prize-name">{{ lottery.prizeDescription }}</div>
|
||||
<div class="prize-count">x {{ lottery.prizeCount }}</div>
|
||||
</div>
|
||||
<div class="prize-end-time prize-info-right">
|
||||
<div v-if="!isMobile" class="prize-end-time-title">离结束还有</div>
|
||||
<div class="prize-end-time-value">{{ countdown }}</div>
|
||||
<div v-if="!isMobile" class="join-prize-button-container-desktop">
|
||||
<div
|
||||
v-if="loggedIn && !hasJoined && !lotteryEnded"
|
||||
class="join-prize-button"
|
||||
@click="joinLottery"
|
||||
>
|
||||
<div class="join-prize-button-text">
|
||||
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
||||
<div class="join-prize-button-text">已参与</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="join-prize-button-container-mobile">
|
||||
<div
|
||||
v-if="loggedIn && !hasJoined && !lotteryEnded"
|
||||
class="join-prize-button"
|
||||
@click="joinLottery"
|
||||
>
|
||||
<div class="join-prize-button-text">
|
||||
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
||||
<div class="join-prize-button-text">已参与</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prize-member-container">
|
||||
<BaseImage
|
||||
v-for="p in lotteryParticipants"
|
||||
:key="p.id"
|
||||
class="prize-member-avatar"
|
||||
:src="p.avatar"
|
||||
alt="avatar"
|
||||
@click="gotoUser(p.id)"
|
||||
/>
|
||||
<div v-if="lotteryEnded && lotteryWinners.length" class="prize-member-winner">
|
||||
<i class="fas fa-medal medal-icon"></i>
|
||||
<span class="prize-member-winner-name">获奖者: </span>
|
||||
<BaseImage
|
||||
v-for="w in lotteryWinners"
|
||||
:key="w.id"
|
||||
class="prize-member-avatar"
|
||||
:src="w.avatar"
|
||||
alt="avatar"
|
||||
@click="gotoUser(w.id)"
|
||||
/>
|
||||
<div v-if="lotteryWinners.length === 1" class="prize-member-winner-name">
|
||||
{{ lotteryWinners[0].username }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { getToken, authState } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
|
||||
const props = defineProps({
|
||||
lottery: { type: Object, required: true },
|
||||
postId: { type: [String, Number], required: true },
|
||||
})
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const loggedIn = computed(() => authState.loggedIn)
|
||||
const lotteryParticipants = computed(() => props.lottery?.participants || [])
|
||||
const lotteryWinners = computed(() => props.lottery?.winners || [])
|
||||
const lotteryEnded = computed(() => {
|
||||
if (!props.lottery || !props.lottery.endTime) return false
|
||||
return new Date(props.lottery.endTime).getTime() <= Date.now()
|
||||
})
|
||||
const hasJoined = computed(() => {
|
||||
if (!loggedIn.value) return false
|
||||
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
|
||||
})
|
||||
|
||||
const countdown = ref('00:00:00')
|
||||
let timer = null
|
||||
const updateCountdown = () => {
|
||||
if (!props.lottery || !props.lottery.endTime) {
|
||||
countdown.value = '00:00:00'
|
||||
return
|
||||
}
|
||||
const diff = new Date(props.lottery.endTime).getTime() - Date.now()
|
||||
if (diff <= 0) {
|
||||
countdown.value = '00:00:00'
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
const h = String(Math.floor(diff / 3600000)).padStart(2, '0')
|
||||
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
|
||||
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
|
||||
countdown.value = `${h}:${m}:${s}`
|
||||
}
|
||||
const startCountdown = () => {
|
||||
updateCountdown()
|
||||
if (timer) clearInterval(timer)
|
||||
timer = setInterval(updateCountdown, 1000)
|
||||
}
|
||||
watch(
|
||||
() => props.lottery?.endTime,
|
||||
() => {
|
||||
if (props.lottery && props.lottery.endTime) startCountdown()
|
||||
},
|
||||
)
|
||||
onMounted(() => {
|
||||
if (props.lottery && props.lottery.endTime) startCountdown()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const joinLottery = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/lottery/join`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (res.ok) {
|
||||
toast.success('已参与抽奖')
|
||||
emit('refresh')
|
||||
} else {
|
||||
toast.error(data.error || '操作失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.post-prize-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background-color: var(--lottery-background-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.prize-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.join-prize-button-container-mobile {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.prize-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.default-prize-icon {
|
||||
font-size: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.prize-icon-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.prize-name {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.prize-count {
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
opacity: 0.7;
|
||||
margin-left: 10px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.prize-end-time {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.prize-end-time-title {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.prize-end-time-value {
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.prize-info-left,
|
||||
.prize-info-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.join-prize-button {
|
||||
margin-left: 10px;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.join-prize-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.join-prize-button-disabled {
|
||||
text-align: center;
|
||||
margin-left: 10px;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--primary-color-disabled);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.prize-member-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-left: 3px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prize-member-winner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.medal-icon {
|
||||
font-size: 16px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.prize-member-winner-name {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.join-prize-button,
|
||||
.join-prize-button-disabled {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
459
frontend_nuxt/components/PostPoll.vue
Normal file
459
frontend_nuxt/components/PostPoll.vue
Normal file
@@ -0,0 +1,459 @@
|
||||
<template>
|
||||
<div class="post-poll-container" v-if="poll">
|
||||
<div class="poll-top-container">
|
||||
<div class="poll-options-container">
|
||||
<div v-if="showPollResult || pollEnded || hasVoted">
|
||||
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
|
||||
<div class="poll-option-info-container">
|
||||
<div class="poll-option-text">{{ opt }}</div>
|
||||
<div class="poll-option-progress-info">
|
||||
{{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票)
|
||||
</div>
|
||||
</div>
|
||||
<div class="poll-option-progress">
|
||||
<div
|
||||
class="poll-option-progress-bar"
|
||||
:style="{ width: pollPercentages[idx] + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="poll-participants">
|
||||
<BaseImage
|
||||
v-for="p in pollOptionParticipants[idx] || []"
|
||||
:key="p.id"
|
||||
class="poll-participant-avatar"
|
||||
:src="p.avatar"
|
||||
alt="avatar"
|
||||
@click="gotoUser(p.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="poll-title-section">
|
||||
<div class="poll-option-title" v-if="poll.multiple">多选</div>
|
||||
<div class="poll-option-title" v-else>单选</div>
|
||||
|
||||
<div class="poll-left-time">
|
||||
<div class="poll-left-time-title">离结束还有</div>
|
||||
<div class="poll-left-time-value">{{ countdown }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="poll.multiple">
|
||||
<div
|
||||
v-for="(opt, idx) in poll.options"
|
||||
:key="idx"
|
||||
class="poll-option"
|
||||
@click="toggleOption(idx)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedOptions.includes(idx)"
|
||||
class="poll-option-input"
|
||||
/>
|
||||
<span class="poll-option-text">{{ opt }}</span>
|
||||
</div>
|
||||
|
||||
<div class="multi-selection-container">
|
||||
<div class="join-poll-button" @click="submitMultiPoll">
|
||||
<i class="fas fa-check"></i> 确认投票
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(opt, idx) in poll.options"
|
||||
:key="idx"
|
||||
class="poll-option"
|
||||
@click="selectOption(idx)"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
:checked="selectedOption === idx"
|
||||
name="poll-option"
|
||||
class="poll-option-input"
|
||||
/>
|
||||
<span class="poll-option-text">{{ opt }}</span>
|
||||
</div>
|
||||
|
||||
<div class="single-selection-container">
|
||||
<div class="join-poll-button" @click="submitSinglePoll">
|
||||
<i class="fas fa-check"></i> 确认投票
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="poll-info">
|
||||
<div class="total-votes">{{ pollParticipants.length }}</div>
|
||||
<div class="total-votes-title">投票人</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="poll-bottom-container">
|
||||
<div
|
||||
v-if="showPollResult && !pollEnded && !hasVoted"
|
||||
class="poll-option-button"
|
||||
@click="showPollResult = false"
|
||||
>
|
||||
<i class="fas fa-chevron-left"></i> 投票
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!pollEnded && !hasVoted"
|
||||
class="poll-option-button"
|
||||
@click="showPollResult = true"
|
||||
>
|
||||
<i class="fas fa-chart-bar"></i> 结果
|
||||
</div>
|
||||
<div v-else-if="pollEnded" class="poll-option-hint">
|
||||
<i class="fas fa-stopwatch"></i> 投票已结束
|
||||
</div>
|
||||
<div v-else class="poll-option-hint">
|
||||
<i class="fas fa-stopwatch"></i> 您已投票,等待结束查看结果
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { getToken, authState } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
|
||||
const props = defineProps({
|
||||
poll: { type: Object, required: true },
|
||||
postId: { type: [String, Number], required: true },
|
||||
})
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
const loggedIn = computed(() => authState.loggedIn)
|
||||
const showPollResult = ref(false)
|
||||
|
||||
const pollParticipants = computed(() => props.poll?.participants || [])
|
||||
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
|
||||
const pollVotes = computed(() => props.poll?.votes || {})
|
||||
const totalPollVotes = computed(() => Object.values(pollVotes.value).reduce((a, b) => a + b, 0))
|
||||
const pollPercentages = computed(() =>
|
||||
props.poll
|
||||
? props.poll.options.map((_, idx) => {
|
||||
const c = pollVotes.value[idx] || 0
|
||||
return totalPollVotes.value ? ((c / totalPollVotes.value) * 100).toFixed(1) : 0
|
||||
})
|
||||
: [],
|
||||
)
|
||||
const pollEnded = computed(() => {
|
||||
if (!props.poll || !props.poll.endTime) return false
|
||||
return new Date(props.poll.endTime).getTime() <= Date.now()
|
||||
})
|
||||
const hasVoted = computed(() => {
|
||||
if (!loggedIn.value) return false
|
||||
return pollParticipants.value.some((p) => p.id === Number(authState.userId))
|
||||
})
|
||||
watch([hasVoted, pollEnded], ([voted, ended]) => {
|
||||
if (voted || ended) showPollResult.value = true
|
||||
})
|
||||
|
||||
const countdown = ref('00:00:00')
|
||||
let timer = null
|
||||
const updateCountdown = () => {
|
||||
if (!props.poll || !props.poll.endTime) {
|
||||
countdown.value = '00:00:00'
|
||||
return
|
||||
}
|
||||
const diff = new Date(props.poll.endTime).getTime() - Date.now()
|
||||
if (diff <= 0) {
|
||||
countdown.value = '00:00:00'
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
const h = String(Math.floor(diff / 3600000)).padStart(2, '0')
|
||||
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
|
||||
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
|
||||
countdown.value = `${h}:${m}:${s}`
|
||||
}
|
||||
const startCountdown = () => {
|
||||
updateCountdown()
|
||||
if (timer) clearInterval(timer)
|
||||
timer = setInterval(updateCountdown, 1000)
|
||||
}
|
||||
watch(
|
||||
() => props.poll?.endTime,
|
||||
() => {
|
||||
if (props.poll && props.poll.endTime) startCountdown()
|
||||
},
|
||||
)
|
||||
onMounted(() => {
|
||||
if (props.poll && props.poll.endTime) startCountdown()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const voteOption = async (idx) => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/poll/vote?option=${idx}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (res.ok) {
|
||||
toast.success('投票成功')
|
||||
emit('refresh')
|
||||
showPollResult.value = true
|
||||
} else {
|
||||
toast.error(data.error || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const selectedOption = ref(null)
|
||||
const selectOption = (idx) => {
|
||||
selectedOption.value = idx
|
||||
}
|
||||
const submitSinglePoll = async () => {
|
||||
if (selectedOption.value === null) {
|
||||
toast.error('请选择一个选项')
|
||||
return
|
||||
}
|
||||
await voteOption(selectedOption.value)
|
||||
}
|
||||
|
||||
const selectedOptions = ref([])
|
||||
const toggleOption = (idx) => {
|
||||
const i = selectedOptions.value.indexOf(idx)
|
||||
if (i >= 0) {
|
||||
selectedOptions.value.splice(i, 1)
|
||||
} else {
|
||||
selectedOptions.value.push(idx)
|
||||
}
|
||||
}
|
||||
const submitMultiPoll = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
if (!selectedOptions.value.length) {
|
||||
toast.error('请选择至少一个选项')
|
||||
return
|
||||
}
|
||||
const params = selectedOptions.value.map((o) => `option=${o}`).join('&')
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/poll/vote?${params}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (res.ok) {
|
||||
toast.success('投票成功')
|
||||
emit('refresh')
|
||||
showPollResult.value = true
|
||||
} else {
|
||||
toast.error(data.error || '操作失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.post-poll-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background-color: var(--lottery-background-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.poll-option-button {
|
||||
color: var(--text-color);
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--poll-option-button-background-color);
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.poll-top-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.poll-options-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
flex: 4;
|
||||
border-right: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.poll-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.total-votes {
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.total-votes-title {
|
||||
font-size: 18px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.poll-option {
|
||||
margin-bottom: 10px;
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.poll-option-result {
|
||||
margin-bottom: 10px;
|
||||
margin-right: 10px;
|
||||
gap: 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.poll-option-input {
|
||||
margin-right: 10px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.poll-option-text {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.poll-bottom-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.poll-left-time {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.poll-left-time-title {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.poll-left-time-value {
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.poll-option-progress {
|
||||
position: relative;
|
||||
background-color: rgb(187, 187, 187);
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.poll-option-progress-bar {
|
||||
background-color: var(--primary-color);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.poll-option-info-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.poll-option-progress-info {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.multi-selection-container,
|
||||
.single-selection-container {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.multi-selection-title,
|
||||
.single-selection-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.poll-title-section {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
flex-direction: row;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.poll-option-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.poll-left-time {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.join-poll-button {
|
||||
padding: 5px 10px;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.join-poll-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.poll-participants {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.poll-participant-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -33,6 +33,7 @@ export default {
|
||||
return [
|
||||
{ id: 'NORMAL', name: '普通帖子', icon: 'fa-regular fa-file' },
|
||||
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'fa-solid fa-gift' },
|
||||
{ id: 'POLL', name: '投票帖子', icon: 'fa-solid fa-square-poll-vertical' },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
183
frontend_nuxt/components/ThirdPartyAuth.vue
Normal file
183
frontend_nuxt/components/ThirdPartyAuth.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="third-party-auth">
|
||||
<div
|
||||
v-for="provider in providers"
|
||||
:key="provider.name"
|
||||
class="third-party-button"
|
||||
:class="provider.name"
|
||||
@click="provider.action"
|
||||
>
|
||||
<img
|
||||
class="third-party-button-icon"
|
||||
:class="provider.name"
|
||||
:src="provider.icon"
|
||||
:alt="provider.alt"
|
||||
/>
|
||||
<div class="third-party-button-text" :class="provider.name">
|
||||
{{ provider.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import googleIcon from '~/assets/icons/google.svg'
|
||||
import githubIcon from '~/assets/icons/github.svg'
|
||||
import discordIcon from '~/assets/icons/discord.svg'
|
||||
import twitterIcon from '~/assets/icons/twitter.svg'
|
||||
import telegramIcon from '~/assets/icons/telegram.svg'
|
||||
|
||||
import { googleAuthorize } from '~/utils/google'
|
||||
import { githubAuthorize } from '~/utils/github'
|
||||
import { discordAuthorize } from '~/utils/discord'
|
||||
import { twitterAuthorize } from '~/utils/twitter'
|
||||
import { telegramAuthorize } from '~/utils/telegram'
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'login',
|
||||
},
|
||||
inviteToken: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const actionText = computed(() => (props.mode === 'signup' ? '注册' : '登录'))
|
||||
|
||||
const providers = computed(() => [
|
||||
{
|
||||
name: 'google',
|
||||
icon: googleIcon,
|
||||
action: () => googleAuthorize(props.inviteToken),
|
||||
alt: 'Google Logo',
|
||||
label: `Google ${actionText.value}`,
|
||||
},
|
||||
{
|
||||
name: 'github',
|
||||
icon: githubIcon,
|
||||
action: () => githubAuthorize(props.inviteToken),
|
||||
alt: 'GitHub Logo',
|
||||
label: `GitHub ${actionText.value}`,
|
||||
},
|
||||
{
|
||||
name: 'discord',
|
||||
icon: discordIcon,
|
||||
action: () => discordAuthorize(props.inviteToken),
|
||||
alt: 'Discord Logo',
|
||||
label: `Discord ${actionText.value}`,
|
||||
},
|
||||
{
|
||||
name: 'twitter',
|
||||
icon: twitterIcon,
|
||||
action: () => twitterAuthorize(props.inviteToken),
|
||||
alt: 'Twitter Logo',
|
||||
label: `X ${actionText.value}`,
|
||||
},
|
||||
{
|
||||
name: 'telegram',
|
||||
icon: telegramIcon,
|
||||
action: () => telegramAuthorize(props.inviteToken),
|
||||
alt: 'Telegram Logo',
|
||||
label: `Telegram ${actionText.value}`,
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.third-party-auth {
|
||||
margin-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30%;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.third-party-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7px 20px;
|
||||
min-width: 150px;
|
||||
background-color: var(--login-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.third-party-button:hover {
|
||||
background-color: var(--login-background-color-hover);
|
||||
}
|
||||
|
||||
.third-party-button-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.third-party-button-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.third-party-button-text.twitter {
|
||||
color: rgb(182, 182, 182);
|
||||
}
|
||||
|
||||
/* Provider specific classes for customization */
|
||||
.third-party-button.google {
|
||||
background-color: var(--google-bg, var(--login-background-color));
|
||||
color: var(--google-color, inherit);
|
||||
}
|
||||
.third-party-button.google:hover {
|
||||
background-color: var(--google-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
.third-party-button.github {
|
||||
background-color: var(--github-bg, var(--login-background-color));
|
||||
color: var(--github-color, inherit);
|
||||
}
|
||||
.third-party-button.github:hover {
|
||||
background-color: var(--github-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
.third-party-button.discord {
|
||||
background-color: var(--discord-bg, var(--login-background-color));
|
||||
color: var(--discord-color, inherit);
|
||||
}
|
||||
.third-party-button.discord:hover {
|
||||
background-color: var(--discord-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
.third-party-button.twitter {
|
||||
background-color: var(--twitter-bg, var(--login-background-color));
|
||||
color: var(--twitter-color, inherit);
|
||||
}
|
||||
.third-party-button.twitter:hover {
|
||||
background-color: var(--twitter-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
.third-party-button.telegram {
|
||||
background-color: var(--telegram-bg, var(--login-background-color));
|
||||
color: var(--telegram-color, inherit);
|
||||
}
|
||||
.third-party-button.telegram:hover {
|
||||
background-color: var(--telegram-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.third-party-auth {
|
||||
margin-top: 20px;
|
||||
margin-left: 0px;
|
||||
width: calc(100% - 40px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.third-party-button {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@ export default defineNuxtConfig({
|
||||
githubClientId: process.env.NUXT_PUBLIC_GITHUB_CLIENT_ID || '',
|
||||
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID || '',
|
||||
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
||||
telegramBotId: process.env.NUXT_PUBLIC_TELEGRAM_BOT_ID || '',
|
||||
},
|
||||
},
|
||||
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||
|
||||
3279
frontend_nuxt/package-lock.json
generated
3279
frontend_nuxt/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,9 @@
|
||||
"name": "frontend_nuxt",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
@@ -15,8 +18,10 @@
|
||||
"echarts": "^5.6.0",
|
||||
"flatpickr": "^4.6.13",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ipx": "^3.1.1",
|
||||
"ldrs": "^1.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mermaid": "^10.9.4",
|
||||
"nprogress": "^0.2.0",
|
||||
"nuxt": "latest",
|
||||
"sanitize-html": "^2.17.0",
|
||||
|
||||
@@ -33,23 +33,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import {
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { getToken } from '~/utils/auth'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])
|
||||
|
||||
const dauOption = ref(null)
|
||||
const newUserOption = ref(null)
|
||||
const postOption = ref(null)
|
||||
|
||||
@@ -70,11 +70,15 @@
|
||||
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
|
||||
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
|
||||
<i v-if="article.type === 'LOTTERY'" class="fa-solid fa-gift lottery-icon"></i>
|
||||
<i
|
||||
v-else-if="article.type === 'POLL'"
|
||||
class="fa-solid fa-square-poll-vertical poll-icon"
|
||||
></i>
|
||||
{{ article.title }}
|
||||
</NuxtLink>
|
||||
<div class="article-item-description main-item">
|
||||
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
||||
{{ sanitizeDescription(article.description) }}
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div class="article-info-container main-item">
|
||||
<ArticleCategory :category="article.category" />
|
||||
<ArticleTags :tags="article.tags" />
|
||||
@@ -527,19 +531,23 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
|
||||
.article-item-title {
|
||||
margin-top: 10px;
|
||||
font-size: 20px;
|
||||
font-size: 18px;
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
max-width: 100%;
|
||||
font-weight: bold;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.article-item-title:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.pinned-icon,
|
||||
.lottery-icon {
|
||||
.lottery-icon,
|
||||
.poll-icon {
|
||||
margin-right: 4px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
@@ -547,13 +555,23 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
.article-item-description {
|
||||
max-width: 100%;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: gray;
|
||||
font-size: 13px;
|
||||
color: rgba(140, 140, 140, 0.888);
|
||||
display: -webkit-box;
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.01em;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.article-item-description:hover {
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.article-info-container {
|
||||
|
||||
@@ -34,35 +34,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="other-login-page-content">
|
||||
<div class="login-page-button" @click="loginWithGoogle">
|
||||
<img class="login-page-button-icon" src="../assets/icons/google.svg" alt="Google Logo" />
|
||||
<div class="login-page-button-text">Google 登录</div>
|
||||
</div>
|
||||
<div class="login-page-button" @click="loginWithGithub">
|
||||
<img class="login-page-button-icon" src="../assets/icons/github.svg" alt="GitHub Logo" />
|
||||
<div class="login-page-button-text">GitHub 登录</div>
|
||||
</div>
|
||||
<div class="login-page-button" @click="loginWithDiscord">
|
||||
<img class="login-page-button-icon" src="../assets/icons/discord.svg" alt="Discord Logo" />
|
||||
<div class="login-page-button-text">Discord 登录</div>
|
||||
</div>
|
||||
<div class="login-page-button" @click="loginWithTwitter">
|
||||
<img class="login-page-button-icon" src="../assets/icons/twitter.svg" alt="Twitter Logo" />
|
||||
<div class="login-page-button-text">Twitter 登录</div>
|
||||
</div>
|
||||
</div>
|
||||
<ThirdPartyAuth mode="login" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import { setToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { googleAuthorize } from '~/utils/google'
|
||||
import { githubAuthorize } from '~/utils/github'
|
||||
import { discordAuthorize } from '~/utils/discord'
|
||||
import { twitterAuthorize } from '~/utils/twitter'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
||||
import { registerPush } from '~/utils/push'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
@@ -105,19 +85,6 @@ const submitLogin = async () => {
|
||||
isWaitingForLogin.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loginWithGoogle = () => {
|
||||
googleAuthorize()
|
||||
}
|
||||
const loginWithGithub = () => {
|
||||
githubAuthorize()
|
||||
}
|
||||
const loginWithDiscord = () => {
|
||||
discordAuthorize()
|
||||
}
|
||||
const loginWithTwitter = () => {
|
||||
twitterAuthorize()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -190,16 +157,6 @@ const loginWithTwitter = () => {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.other-login-page-content {
|
||||
margin-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30%;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.login-page-button-primary {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
@@ -229,29 +186,6 @@ const loginWithTwitter = () => {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.login-page-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 20px;
|
||||
min-width: 150px;
|
||||
background-color: var(--login-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-page-button:hover {
|
||||
background-color: var(--login-background-color-hover);
|
||||
}
|
||||
|
||||
.login-page-button-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.login-page-button-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -293,16 +227,5 @@ const loginWithTwitter = () => {
|
||||
margin-top: 0px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.other-login-page-content {
|
||||
margin-top: 20px;
|
||||
margin-left: 0px;
|
||||
width: calc(100% - 40px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-page-button {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -113,17 +113,23 @@ const error = ref(null)
|
||||
const conversationId = route.params.id
|
||||
const currentUser = ref(null)
|
||||
const messagesListEl = ref(null)
|
||||
let lastMessageEl = null
|
||||
const currentPage = ref(0)
|
||||
const totalPages = ref(0)
|
||||
const loadingMore = ref(false)
|
||||
let scrollInterval = null
|
||||
const conversationName = ref('')
|
||||
const isChannel = ref(false)
|
||||
const isFloatMode = computed(() => route.query.float !== undefined)
|
||||
const floatRoute = useState('messageFloatRoute')
|
||||
const replyTo = ref(null)
|
||||
|
||||
const isUserNearBottom = ref(true)
|
||||
function updateNearBottom() {
|
||||
const el = messagesListEl.value
|
||||
if (!el) return
|
||||
const threshold = 40 // px
|
||||
isUserNearBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight <= threshold
|
||||
}
|
||||
|
||||
const hasMoreMessages = computed(() => currentPage.value < totalPages.value - 1)
|
||||
|
||||
const otherParticipant = computed(() => {
|
||||
@@ -133,20 +139,37 @@ const otherParticipant = computed(() => {
|
||||
return participants.value.find((p) => p.id !== currentUser.value.id)
|
||||
})
|
||||
|
||||
function isSentByCurrentUser(message) {
|
||||
return message.sender.id === currentUser.value?.id
|
||||
}
|
||||
|
||||
function handleAvatarError(event) {
|
||||
event.target.src = '/default-avatar.svg'
|
||||
}
|
||||
|
||||
function setReply(message) {
|
||||
replyTo.value = message
|
||||
}
|
||||
|
||||
// No changes needed here, as renderMarkdown is now imported.
|
||||
// The old function is removed.
|
||||
/** 改造:滚动函数 —— smooth & instant */
|
||||
function scrollToBottomSmooth() {
|
||||
const el = messagesListEl.value
|
||||
if (!el) return
|
||||
// 优先使用原生 smooth,失败则降级
|
||||
try {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
|
||||
} catch {
|
||||
// 降级:简易动画
|
||||
const start = el.scrollTop
|
||||
const end = el.scrollHeight
|
||||
const duration = 200
|
||||
const startTs = performance.now()
|
||||
function step(now) {
|
||||
const p = Math.min(1, (now - startTs) / duration)
|
||||
el.scrollTop = start + (end - start) * p
|
||||
if (p < 1) requestAnimationFrame(step)
|
||||
}
|
||||
requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottomInstant() {
|
||||
const el = messagesListEl.value
|
||||
if (!el) return
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
async function fetchMessages(page = 0) {
|
||||
if (page === 0) {
|
||||
@@ -181,7 +204,6 @@ async function fetchMessages(page = 0) {
|
||||
isChannel.value = conversationData.channel
|
||||
}
|
||||
|
||||
// Since the backend sorts by descending, we need to reverse for correct chat order
|
||||
const newMessages = pageData.content.reverse().map((item) => ({
|
||||
...item,
|
||||
src: item.sender.avatar,
|
||||
@@ -202,12 +224,16 @@ async function fetchMessages(page = 0) {
|
||||
currentPage.value = pageData.number
|
||||
totalPages.value = pageData.totalPages
|
||||
|
||||
// Scrolling is now fully handled by the watcher
|
||||
await nextTick()
|
||||
if (page > 0 && list) {
|
||||
// 加载更多:保持原视口位置
|
||||
const newScrollHeight = list.scrollHeight
|
||||
list.scrollTop = newScrollHeight - oldScrollHeight
|
||||
} else if (page === 0) {
|
||||
// 首次加载:定位到底部(不用动画,避免“闪动感”)
|
||||
scrollToBottomInstant()
|
||||
}
|
||||
updateNearBottom()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
toast.error(e.message)
|
||||
@@ -272,9 +298,10 @@ async function sendMessage(content, clearInput) {
|
||||
})
|
||||
clearInput()
|
||||
replyTo.value = null
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 100)
|
||||
|
||||
await nextTick()
|
||||
// 仅“发送消息成功后”才平滑滚动到底部
|
||||
scrollToBottomSmooth()
|
||||
} catch (e) {
|
||||
toast.error(e.message)
|
||||
} finally {
|
||||
@@ -290,7 +317,6 @@ async function markConversationAsRead() {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
// After marking as read, refresh the global unread count
|
||||
refreshGlobalUnreadCount()
|
||||
refreshChannelUnread()
|
||||
} catch (e) {
|
||||
@@ -298,41 +324,19 @@ async function markConversationAsRead() {
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (messagesListEl.value) {
|
||||
const element = messagesListEl.value
|
||||
// 強制滾動到底部,使用 smooth 行為確保視覺效果
|
||||
element.scrollTop = element.scrollHeight
|
||||
|
||||
// 再次確認滾動位置
|
||||
setTimeout(() => {
|
||||
if (element.scrollTop < element.scrollHeight - element.clientHeight) {
|
||||
element.scrollTop = element.scrollHeight
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
messages,
|
||||
async (newMessages) => {
|
||||
if (newMessages.length === 0) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Simple, reliable scroll to bottom
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 100)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
// 监听列表滚动,实时感知是否接近底部
|
||||
if (messagesListEl.value) {
|
||||
messagesListEl.value.addEventListener('scroll', updateNearBottom, { passive: true })
|
||||
}
|
||||
|
||||
currentUser.value = await fetchCurrentUser()
|
||||
if (currentUser.value) {
|
||||
await fetchMessages(0)
|
||||
await markConversationAsRead()
|
||||
await nextTick()
|
||||
// 初次进入频道时,平滑滚动到底部
|
||||
scrollToBottomSmooth()
|
||||
const token = getToken()
|
||||
if (token && !isConnected.value) {
|
||||
connect(token)
|
||||
@@ -345,9 +349,8 @@ onMounted(async () => {
|
||||
|
||||
watch(isConnected, (newValue) => {
|
||||
if (newValue) {
|
||||
// 等待一小段时间确保连接稳定
|
||||
setTimeout(() => {
|
||||
subscription = subscribe(`/topic/conversation/${conversationId}`, (message) => {
|
||||
subscription = subscribe(`/topic/conversation/${conversationId}`, async (message) => {
|
||||
// 避免重复显示当前用户发送的消息
|
||||
if (message.sender.id !== currentUser.value.id) {
|
||||
messages.value.push({
|
||||
@@ -357,11 +360,10 @@ watch(isConnected, (newValue) => {
|
||||
openUser(message.sender.id)
|
||||
},
|
||||
})
|
||||
// 实时收到消息时自动标记为已读
|
||||
// 收到消息后只标记已读,不强制滚动(符合“非发送不拉底”)
|
||||
markConversationAsRead()
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 100)
|
||||
await nextTick()
|
||||
updateNearBottom()
|
||||
}
|
||||
})
|
||||
}, 500)
|
||||
@@ -369,23 +371,13 @@ watch(isConnected, (newValue) => {
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
// This will be called every time the component is activated (navigated to)
|
||||
// 返回页面时:刷新数据与已读,并滚动到底部
|
||||
if (currentUser.value) {
|
||||
await fetchMessages(0)
|
||||
await markConversationAsRead()
|
||||
|
||||
// 確保滾動到底部 - 使用多重延遲策略
|
||||
await nextTick()
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 100)
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 300)
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 500)
|
||||
|
||||
scrollToBottomSmooth()
|
||||
updateNearBottom()
|
||||
if (!isConnected.value) {
|
||||
const token = getToken()
|
||||
if (token) connect(token)
|
||||
@@ -406,6 +398,9 @@ onUnmounted(() => {
|
||||
subscription.unsubscribe()
|
||||
subscription = null
|
||||
}
|
||||
if (messagesListEl.value) {
|
||||
messagesListEl.value.removeEventListener('scroll', updateNearBottom)
|
||||
}
|
||||
disconnect()
|
||||
})
|
||||
|
||||
@@ -492,6 +487,7 @@ function goBack() {
|
||||
|
||||
.messages-list {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 20px;
|
||||
padding-bottom: 100px;
|
||||
display: flex;
|
||||
@@ -597,6 +593,7 @@ function goBack() {
|
||||
}
|
||||
|
||||
.reply-preview {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-left: 5px solid var(--primary-color);
|
||||
margin-bottom: 5px;
|
||||
|
||||
@@ -14,11 +14,25 @@
|
||||
<div class="message-control-container">
|
||||
<div class="message-control-title">通知设置</div>
|
||||
<div class="message-control-item-container">
|
||||
<div v-for="pref in notificationPrefs" :key="pref.type" class="message-control-item">
|
||||
<template v-for="pref in notificationPrefs">
|
||||
<div v-if="canShowNotification(pref.type)" :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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-control-container">
|
||||
<div class="message-control-title">邮件通知设置</div>
|
||||
<div class="message-control-item-container">
|
||||
<div v-for="pref in emailPrefs" :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)"
|
||||
@update:modelValue="(val) => toggleEmailPref(pref, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,6 +209,44 @@
|
||||
已开奖
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POLL_VOTE'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
有用户参与了你的投票贴
|
||||
<NuxtLink
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POLL_RESULT_OWNER'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
你的投票帖
|
||||
<NuxtLink
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
已出结果
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POLL_RESULT_PARTICIPANT'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
你参与的投票帖
|
||||
<NuxtLink
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
已出结果
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您关注的帖子
|
||||
@@ -541,6 +593,8 @@ import {
|
||||
hasMore,
|
||||
fetchNotificationPreferences,
|
||||
updateNotificationPreference,
|
||||
fetchEmailNotificationPreferences,
|
||||
updateEmailNotificationPreference,
|
||||
} from '~/utils/notification'
|
||||
import TimeManager from '~/utils/time'
|
||||
import BaseSwitch from '~/components/BaseSwitch.vue'
|
||||
@@ -557,6 +611,7 @@ const tabs = [
|
||||
{ key: 'control', label: '消息设置' },
|
||||
]
|
||||
const notificationPrefs = ref([])
|
||||
const emailPrefs = ref([])
|
||||
const page = ref(0)
|
||||
const pageSize = 30
|
||||
|
||||
@@ -581,6 +636,10 @@ const fetchPrefs = async () => {
|
||||
notificationPrefs.value = await fetchNotificationPreferences()
|
||||
}
|
||||
|
||||
const fetchEmailPrefs = async () => {
|
||||
emailPrefs.value = await fetchEmailNotificationPreferences()
|
||||
}
|
||||
|
||||
const togglePref = async (pref, value) => {
|
||||
const ok = await updateNotificationPreference(pref.type, value)
|
||||
if (ok) {
|
||||
@@ -596,6 +655,15 @@ const togglePref = async (pref, value) => {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEmailPref = async (pref, value) => {
|
||||
const ok = await updateEmailNotificationPreference(pref.type, value)
|
||||
if (ok) {
|
||||
pref.enabled = value
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const markRead = async (id) => {
|
||||
markNotificationRead(id)
|
||||
if (selectedTab.value === 'unread') {
|
||||
@@ -676,15 +744,30 @@ const formatType = (t) => {
|
||||
return '帖子被删除'
|
||||
case 'POST_FEATURED':
|
||||
return '文章被精选'
|
||||
case 'POLL_VOTE':
|
||||
return '有人参与你的投票'
|
||||
case 'POLL_RESULT_OWNER':
|
||||
return '发布的投票结果已公布'
|
||||
case 'POLL_RESULT_PARTICIPANT':
|
||||
return '参与的投票结果已公布'
|
||||
default:
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => authState.role === 'ADMIN')
|
||||
|
||||
const needAdminSet = new Set(['POST_REVIEW_REQUEST','REGISTER_REQUEST', 'POINT_REDEEM', 'ACTIVITY_REDEEM'])
|
||||
|
||||
const canShowNotification = (type) => {
|
||||
return !needAdminSet.has(type) || isAdmin.value
|
||||
}
|
||||
|
||||
onActivated(async () => {
|
||||
page.value = 0
|
||||
await fetchNotifications({ page: 0, size: pageSize, unread: selectedTab.value === 'unread' })
|
||||
fetchPrefs()
|
||||
fetchEmailPrefs()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -35,71 +35,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="postType === 'LOTTERY'" class="lottery-section">
|
||||
<AvatarCropper
|
||||
:src="tempPrizeIcon"
|
||||
:show="showPrizeCropper"
|
||||
@close="showPrizeCropper = false"
|
||||
@crop="onPrizeCropped"
|
||||
/>
|
||||
<div class="prize-row">
|
||||
<span class="prize-row-title">奖品图片</span>
|
||||
<label class="prize-container">
|
||||
<BaseImage v-if="prizeIcon" :src="prizeIcon" class="prize-preview" alt="prize" />
|
||||
<i v-else class="fa-solid fa-image default-prize-icon"></i>
|
||||
<div class="prize-overlay">上传奖品图片</div>
|
||||
<input type="file" class="prize-input" accept="image/*" @change="onPrizeIconChange" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="prize-name-row">
|
||||
<span class="prize-row-title">奖品描述</span>
|
||||
<BaseInput v-model="prizeDescription" placeholder="奖品描述" />
|
||||
</div>
|
||||
<div class="prize-count-row">
|
||||
<span class="prize-row-title">奖品数量</span>
|
||||
<div class="prize-count-input">
|
||||
<input
|
||||
class="prize-count-input-field"
|
||||
type="number"
|
||||
v-model.number="prizeCount"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prize-point-row">
|
||||
<span class="prize-row-title">参与所需积分</span>
|
||||
<div class="prize-count-input">
|
||||
<input
|
||||
class="prize-count-input-field"
|
||||
type="number"
|
||||
v-model.number="pointCost"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prize-time-row">
|
||||
<span class="prize-row-title">抽奖结束时间</span>
|
||||
<client-only>
|
||||
<flat-pickr v-model="endTime" :config="dateConfig" class="time-picker" />
|
||||
</client-only>
|
||||
</div>
|
||||
</div>
|
||||
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
|
||||
<PollForm v-if="postType === 'POLL'" :data="poll" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import FlatPickr from 'vue-flatpickr-component'
|
||||
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import { computed, onMounted, ref, reactive } from 'vue'
|
||||
import CategorySelect from '~/components/CategorySelect.vue'
|
||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||
import PostEditor from '~/components/PostEditor.vue'
|
||||
import PostTypeSelect from '~/components/PostTypeSelect.vue'
|
||||
import TagSelect from '~/components/TagSelect.vue'
|
||||
import LotteryForm from '~/components/LotteryForm.vue'
|
||||
import PollForm from '~/components/PollForm.vue'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
const config = useRuntimeConfig()
|
||||
@@ -110,47 +60,27 @@ const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const selectedTags = ref([])
|
||||
const postType = ref('NORMAL')
|
||||
const prizeIcon = ref('')
|
||||
const prizeIconFile = ref(null)
|
||||
const tempPrizeIcon = ref('')
|
||||
const showPrizeCropper = ref(false)
|
||||
const prizeName = ref('')
|
||||
const prizeCount = ref(1)
|
||||
const prizeDescription = ref('')
|
||||
const pointCost = ref(0)
|
||||
const endTime = ref(null)
|
||||
const lottery = reactive({
|
||||
prizeIcon: '',
|
||||
prizeIconFile: null,
|
||||
tempPrizeIcon: '',
|
||||
showPrizeCropper: false,
|
||||
prizeName: '',
|
||||
prizeDescription: '',
|
||||
prizeCount: 1,
|
||||
pointCost: 0,
|
||||
endTime: null,
|
||||
})
|
||||
const poll = reactive({
|
||||
options: ['', ''],
|
||||
endTime: null,
|
||||
multiple: false,
|
||||
})
|
||||
const startTime = ref(null)
|
||||
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
|
||||
const isWaitingPosting = ref(false)
|
||||
const isAiLoading = ref(false)
|
||||
const isLogin = computed(() => authState.loggedIn)
|
||||
|
||||
const onPrizeIconChange = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
tempPrizeIcon.value = reader.result
|
||||
showPrizeCropper.value = true
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
const onPrizeCropped = ({ file, url }) => {
|
||||
prizeIconFile.value = file
|
||||
prizeIcon.value = url
|
||||
}
|
||||
|
||||
watch(prizeCount, (val) => {
|
||||
if (!val || val < 1) prizeCount.value = 1
|
||||
})
|
||||
|
||||
watch(pointCost, (val) => {
|
||||
if (val === undefined || val === null || val < 0) pointCost.value = 0
|
||||
if (val > 100) pointCost.value = 100
|
||||
})
|
||||
|
||||
const loadDraft = async () => {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
@@ -180,15 +110,19 @@ const clearPost = async () => {
|
||||
selectedCategory.value = ''
|
||||
selectedTags.value = []
|
||||
postType.value = 'NORMAL'
|
||||
prizeIcon.value = ''
|
||||
prizeIconFile.value = null
|
||||
tempPrizeIcon.value = ''
|
||||
showPrizeCropper.value = false
|
||||
prizeDescription.value = ''
|
||||
prizeCount.value = 1
|
||||
pointCost.value = 0
|
||||
endTime.value = null
|
||||
lottery.prizeIcon = ''
|
||||
lottery.prizeIconFile = null
|
||||
lottery.tempPrizeIcon = ''
|
||||
lottery.showPrizeCropper = false
|
||||
lottery.prizeName = ''
|
||||
lottery.prizeDescription = ''
|
||||
lottery.prizeCount = 1
|
||||
lottery.pointCost = 0
|
||||
lottery.endTime = null
|
||||
startTime.value = null
|
||||
poll.options = ['', '']
|
||||
poll.endTime = null
|
||||
poll.multiple = false
|
||||
|
||||
// 删除草稿
|
||||
const token = getToken()
|
||||
@@ -318,35 +252,45 @@ const submitPost = async () => {
|
||||
return
|
||||
}
|
||||
if (postType.value === 'LOTTERY') {
|
||||
if (!prizeIcon.value) {
|
||||
if (!lottery.prizeIcon) {
|
||||
toast.error('请上传奖品图片')
|
||||
return
|
||||
}
|
||||
if (!prizeCount.value || prizeCount.value < 1) {
|
||||
if (!lottery.prizeCount || lottery.prizeCount < 1) {
|
||||
toast.error('奖品数量必须大于0')
|
||||
return
|
||||
}
|
||||
if (!prizeDescription.value) {
|
||||
if (!lottery.prizeDescription) {
|
||||
toast.error('请输入奖品描述')
|
||||
return
|
||||
}
|
||||
if (!endTime.value) {
|
||||
if (!lottery.endTime) {
|
||||
toast.error('请选择抽奖结束时间')
|
||||
return
|
||||
}
|
||||
if (pointCost.value < 0 || pointCost.value > 100) {
|
||||
if (lottery.pointCost < 0 || lottery.pointCost > 100) {
|
||||
toast.error('参与积分需在0到100之间')
|
||||
return
|
||||
}
|
||||
}
|
||||
if (postType.value === 'POLL') {
|
||||
if (poll.options.length < 2 || poll.options.some((o) => !o.trim())) {
|
||||
toast.error('请填写至少两个投票选项')
|
||||
return
|
||||
}
|
||||
if (!poll.endTime) {
|
||||
toast.error('请选择投票结束时间')
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
const token = getToken()
|
||||
await ensureTags(token)
|
||||
isWaitingPosting.value = true
|
||||
let prizeIconUrl = prizeIcon.value
|
||||
if (postType.value === 'LOTTERY' && prizeIconFile.value) {
|
||||
let prizeIconUrl = lottery.prizeIcon
|
||||
if (postType.value === 'LOTTERY' && lottery.prizeIconFile) {
|
||||
const form = new FormData()
|
||||
form.append('file', prizeIconFile.value)
|
||||
form.append('file', lottery.prizeIconFile)
|
||||
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -372,17 +316,21 @@ const submitPost = async () => {
|
||||
tagIds: selectedTags.value,
|
||||
type: postType.value,
|
||||
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
|
||||
prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined,
|
||||
prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined,
|
||||
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
|
||||
prizeName: postType.value === 'LOTTERY' ? lottery.prizeName : undefined,
|
||||
prizeCount: postType.value === 'LOTTERY' ? lottery.prizeCount : undefined,
|
||||
prizeDescription: postType.value === 'LOTTERY' ? lottery.prizeDescription : undefined,
|
||||
options: postType.value === 'POLL' ? poll.options : undefined,
|
||||
multiple: postType.value === 'POLL' ? poll.multiple : undefined,
|
||||
startTime:
|
||||
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
||||
pointCost: postType.value === 'LOTTERY' ? pointCost.value : undefined,
|
||||
pointCost: postType.value === 'LOTTERY' ? lottery.pointCost : undefined,
|
||||
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
|
||||
endTime:
|
||||
postType.value === 'LOTTERY'
|
||||
? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
||||
: undefined,
|
||||
? new Date(new Date(lottery.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
||||
: postType.value === 'POLL'
|
||||
? new Date(new Date(poll.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
||||
: undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
@@ -517,123 +465,6 @@ const submitPost = async () => {
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
|
||||
.lottery-section {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
margin-bottom: 200px;
|
||||
}
|
||||
|
||||
.prize-row-title {
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.prize-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.prize-name-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.prize-container {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background-color: var(--lottery-background-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.default-prize-icon {
|
||||
font-size: 30px;
|
||||
opacity: 0.1;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.prize-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.prize-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.prize-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.prize-container:hover .prize-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.prize-count-row,
|
||||
.prize-time-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.prize-count-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.prize-name-input {
|
||||
height: 30px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0 10px;
|
||||
margin-left: 10px;
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.prize-count-input-field {
|
||||
width: 50px;
|
||||
height: 30px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0 10px;
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
background-color: var(--lottery-background-color);
|
||||
}
|
||||
|
||||
.time-picker {
|
||||
max-width: 200px;
|
||||
height: 30px;
|
||||
background-color: var(--lottery-background-color);
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0 10px;
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.new-post-page {
|
||||
width: calc(100vw - 20px);
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="trend" v-if="trendOption">
|
||||
<div class="section-title">积分走势</div>
|
||||
<ClientOnly>
|
||||
<VChart :option="trendOption" :autoresize="true" style="height: 300px" />
|
||||
</ClientOnly>
|
||||
</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>
|
||||
@@ -97,6 +104,31 @@
|
||||
,获得{{ item.amount }}积分
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_LIKE_CANCELLED' && item.fromUserId">
|
||||
你的帖子
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">
|
||||
{{ item.postTitle }}
|
||||
</NuxtLink>
|
||||
被
|
||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">
|
||||
{{ item.fromUserName }}
|
||||
</NuxtLink>
|
||||
取消点赞,扣除{{ -item.amount }}积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'COMMENT_LIKE_CANCELLED' && item.fromUserId">
|
||||
你的评论
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.commentContent, 100) }}
|
||||
</NuxtLink>
|
||||
被
|
||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">
|
||||
{{ item.fromUserName }}
|
||||
</NuxtLink>
|
||||
取消点赞,扣除{{ -item.amount }}积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_LIKED' && item.fromUserId">
|
||||
帖子
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
@@ -192,6 +224,7 @@ const isLoading = ref(false)
|
||||
const histories = ref([])
|
||||
const historyLoading = ref(false)
|
||||
const historyLoaded = ref(false)
|
||||
const trendOption = ref(null)
|
||||
|
||||
const pointRules = [
|
||||
'发帖:每天前两次,每次 30 积分',
|
||||
@@ -219,6 +252,28 @@ const iconMap = {
|
||||
FEATURE: 'fas fa-star',
|
||||
LOTTERY_JOIN: 'fas fa-ticket-alt',
|
||||
LOTTERY_REWARD: 'fas fa-ticket-alt',
|
||||
POST_LIKE_CANCELLED: 'fas fa-thumbs-down',
|
||||
COMMENT_LIKE_CANCELLED: 'fas fa-thumbs-down',
|
||||
}
|
||||
|
||||
const loadTrend = async () => {
|
||||
if (!authState.loggedIn) return
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/point-histories/trend?days=30`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const dates = data.map((d) => d.date)
|
||||
const values = data.map((d) => d.value)
|
||||
trendOption.value = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: dates, boundaryGap: false },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ type: 'line', areaStyle: {}, smooth: true, data: values }],
|
||||
dataZoom: [{ type: 'slider', start: 80 }, { type: 'inside' }],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -226,8 +281,10 @@ onMounted(async () => {
|
||||
if (authState.loggedIn) {
|
||||
const user = await fetchCurrentUser()
|
||||
point.value = user ? user.point : null
|
||||
await Promise.all([loadGoods(), loadTrend()])
|
||||
} else {
|
||||
await loadGoods()
|
||||
}
|
||||
await loadGoods()
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
@@ -363,7 +420,8 @@ const submitRedeem = async () => {
|
||||
}
|
||||
|
||||
.rules,
|
||||
.goods {
|
||||
.goods,
|
||||
.trend {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,84 +94,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="lottery" class="post-prize-container">
|
||||
<div class="prize-content">
|
||||
<div class="prize-info">
|
||||
<div class="prize-info-left">
|
||||
<div class="prize-icon">
|
||||
<BaseImage
|
||||
class="prize-icon-img"
|
||||
v-if="lottery.prizeIcon"
|
||||
:src="lottery.prizeIcon"
|
||||
alt="prize"
|
||||
/>
|
||||
<i v-else class="fa-solid fa-gift default-prize-icon"></i>
|
||||
</div>
|
||||
<div class="prize-name">{{ lottery.prizeDescription }}</div>
|
||||
<div class="prize-count">x {{ lottery.prizeCount }}</div>
|
||||
</div>
|
||||
<div class="prize-end-time prize-info-right">
|
||||
<div v-if="!isMobile" class="prize-end-time-title">离结束还有</div>
|
||||
<div class="prize-end-time-value">{{ countdown }}</div>
|
||||
<div v-if="!isMobile" class="join-prize-button-container-desktop">
|
||||
<div
|
||||
v-if="loggedIn && !hasJoined && !lotteryEnded"
|
||||
class="join-prize-button"
|
||||
@click="joinLottery"
|
||||
>
|
||||
<div class="join-prize-button-text">
|
||||
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
||||
<div class="join-prize-button-text">已参与</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="join-prize-button-container-mobile">
|
||||
<div
|
||||
v-if="loggedIn && !hasJoined && !lotteryEnded"
|
||||
class="join-prize-button"
|
||||
@click="joinLottery"
|
||||
>
|
||||
<div class="join-prize-button-text">
|
||||
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
||||
<div class="join-prize-button-text">已参与</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prize-member-container">
|
||||
<BaseImage
|
||||
v-for="p in lotteryParticipants"
|
||||
:key="p.id"
|
||||
class="prize-member-avatar"
|
||||
:src="p.avatar"
|
||||
alt="avatar"
|
||||
@click="gotoUser(p.id)"
|
||||
/>
|
||||
<div v-if="lotteryEnded && lotteryWinners.length" class="prize-member-winner">
|
||||
<i class="fas fa-medal medal-icon"></i>
|
||||
<span class="prize-member-winner-name">获奖者: </span>
|
||||
<BaseImage
|
||||
v-for="w in lotteryWinners"
|
||||
:key="w.id"
|
||||
class="prize-member-avatar"
|
||||
:src="w.avatar"
|
||||
alt="avatar"
|
||||
@click="gotoUser(w.id)"
|
||||
/>
|
||||
<div v-if="lotteryWinners.length === 1" class="prize-member-winner-name">
|
||||
{{ lotteryWinners[0].username }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PostLottery v-if="lottery" :lottery="lottery" :post-id="postId" @refresh="refreshPost" />
|
||||
<ClientOnly>
|
||||
<PostPoll v-if="poll" :poll="poll" :post-id="postId" @refresh="refreshPost" />
|
||||
</ClientOnly>
|
||||
<div v-if="closed" class="post-close-container">该帖子已关闭,内容仅供阅读,无法进行互动</div>
|
||||
|
||||
<ClientOnly>
|
||||
@@ -259,6 +185,8 @@ import ArticleTags from '~/components/ArticleTags.vue'
|
||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||
import PostLottery from '~/components/PostLottery.vue'
|
||||
import PostPoll from '~/components/PostPoll.vue'
|
||||
import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown'
|
||||
import { getMedalTitle } from '~/utils/medal'
|
||||
import { toast } from '~/main'
|
||||
@@ -314,7 +242,6 @@ useHead(() => ({
|
||||
if (import.meta.client) {
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', updateCurrentIndex)
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -325,44 +252,7 @@ const loggedIn = computed(() => authState.loggedIn)
|
||||
const isAdmin = computed(() => authState.role === 'ADMIN')
|
||||
const isAuthor = computed(() => authState.username === author.value.username)
|
||||
const lottery = ref(null)
|
||||
const countdown = ref('00:00:00')
|
||||
let countdownTimer = null
|
||||
const lotteryParticipants = computed(() => lottery.value?.participants || [])
|
||||
const lotteryWinners = computed(() => lottery.value?.winners || [])
|
||||
const lotteryEnded = computed(() => {
|
||||
if (!lottery.value || !lottery.value.endTime) return false
|
||||
return new Date(lottery.value.endTime).getTime() <= Date.now()
|
||||
})
|
||||
const hasJoined = computed(() => {
|
||||
if (!loggedIn.value) return false
|
||||
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
|
||||
})
|
||||
const updateCountdown = () => {
|
||||
if (!lottery.value || !lottery.value.endTime) {
|
||||
countdown.value = '00:00:00'
|
||||
return
|
||||
}
|
||||
const diff = new Date(lottery.value.endTime).getTime() - Date.now()
|
||||
if (diff <= 0) {
|
||||
countdown.value = '00:00:00'
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
const h = String(Math.floor(diff / 3600000)).padStart(2, '0')
|
||||
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
|
||||
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
|
||||
countdown.value = `${h}:${m}:${s}`
|
||||
}
|
||||
const startCountdown = () => {
|
||||
if (!import.meta.client) return
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
updateCountdown()
|
||||
countdownTimer = setInterval(updateCountdown, 1000)
|
||||
}
|
||||
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
|
||||
const poll = ref(null)
|
||||
const articleMenuItems = computed(() => {
|
||||
const items = []
|
||||
if (isAuthor.value || isAdmin.value) {
|
||||
@@ -411,7 +301,13 @@ const gatherPostItems = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const mapComment = (c, parentUserName = '', level = 0) => ({
|
||||
const mapComment = (
|
||||
c,
|
||||
parentUserName = '',
|
||||
parentUserAvatar = '',
|
||||
parentUserId = '',
|
||||
level = 0,
|
||||
) => ({
|
||||
id: c.id,
|
||||
userName: c.author.username,
|
||||
medal: c.author.displayMedal,
|
||||
@@ -421,11 +317,15 @@ const mapComment = (c, parentUserName = '', level = 0) => ({
|
||||
text: c.content,
|
||||
reactions: c.reactions || [],
|
||||
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
|
||||
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
|
||||
reply: (c.replies || []).map((r) =>
|
||||
mapComment(r, c.author.username, c.author.avatar, c.author.id, level + 1),
|
||||
),
|
||||
openReplies: level === 0,
|
||||
src: c.author.avatar,
|
||||
iconClick: () => navigateTo(`/users/${c.author.id}`, { replace: true }),
|
||||
iconClick: () => navigateTo(`/users/${c.author.id}`),
|
||||
parentUserName: parentUserName,
|
||||
parentUserAvatar: parentUserAvatar,
|
||||
parentUserClick: parentUserId ? () => navigateTo(`/users/${parentUserId}`) : null,
|
||||
})
|
||||
|
||||
const getTop = (el) => {
|
||||
@@ -513,7 +413,7 @@ watchEffect(() => {
|
||||
rssExcluded.value = data.rssExcluded
|
||||
postTime.value = TimeManager.format(data.createdAt)
|
||||
lottery.value = data.lottery || null
|
||||
if (lottery.value && lottery.value.endTime) startCountdown()
|
||||
poll.value = data.poll || null
|
||||
})
|
||||
|
||||
// 404 客户端跳转
|
||||
@@ -804,25 +704,6 @@ const unsubscribePost = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const joinLottery = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/lottery/join`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (res.ok) {
|
||||
toast.success('已参与抽奖')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error(data.error || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCommentSorts = () => {
|
||||
return Promise.resolve([
|
||||
{ id: 'NEWEST', name: '最新', icon: 'fas fa-clock' },
|
||||
@@ -1225,7 +1106,6 @@ onMounted(async () => {
|
||||
.info-content-text {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.article-footer-container {
|
||||
@@ -1267,139 +1147,6 @@ onMounted(async () => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.post-prize-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background-color: var(--lottery-background-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.prize-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.join-prize-button-container-mobile {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.prize-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.default-prize-icon {
|
||||
font-size: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.prize-icon-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.prize-name {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.prize-count {
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
opacity: 0.7;
|
||||
margin-left: 10px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.prize-end-time {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.prize-end-time-title {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.prize-end-time-value {
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.prize-info-left,
|
||||
.prize-info-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.join-prize-button {
|
||||
margin-left: 10px;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.join-prize-button:hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.join-prize-button-disabled {
|
||||
text-align: center;
|
||||
margin-left: 10px;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--primary-color-disabled);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.prize-member-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-left: 3px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prize-member-winner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.medal-icon {
|
||||
font-size: 16px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.prize-member-winner-name {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.post-page-main-container {
|
||||
width: calc(100% - 20px);
|
||||
@@ -1423,7 +1170,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.info-content-text {
|
||||
line-height: 1.3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.reactions-viewer-item {
|
||||
@@ -1450,10 +1197,5 @@ onMounted(async () => {
|
||||
.loading-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.join-prize-button,
|
||||
.join-prize-button-disabled {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -68,35 +68,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="other-signup-page-content">
|
||||
<div class="signup-page-button" @click="signupWithGoogle">
|
||||
<img class="signup-page-button-icon" src="~/assets/icons/google.svg" alt="Google Logo" />
|
||||
<div class="signup-page-button-text">Google 注册</div>
|
||||
</div>
|
||||
<div class="signup-page-button" @click="signupWithGithub">
|
||||
<img class="signup-page-button-icon" src="~/assets/icons/github.svg" alt="GitHub Logo" />
|
||||
<div class="signup-page-button-text">GitHub 注册</div>
|
||||
</div>
|
||||
<div class="signup-page-button" @click="signupWithDiscord">
|
||||
<img class="signup-page-button-icon" src="~/assets/icons/discord.svg" alt="Discord Logo" />
|
||||
<div class="signup-page-button-text">Discord 注册</div>
|
||||
</div>
|
||||
<div class="signup-page-button" @click="signupWithTwitter">
|
||||
<img class="signup-page-button-icon" src="~/assets/icons/twitter.svg" alt="Twitter Logo" />
|
||||
<div class="signup-page-button-text">Twitter 注册</div>
|
||||
</div>
|
||||
</div>
|
||||
<ThirdPartyAuth mode="signup" :invite-token="inviteToken" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import { toast } from '~/main'
|
||||
import { discordAuthorize } from '~/utils/discord'
|
||||
import { githubAuthorize } from '~/utils/github'
|
||||
import { googleAuthorize } from '~/utils/google'
|
||||
import { twitterAuthorize } from '~/utils/twitter'
|
||||
import { loadCurrentUser, setToken } from '~/utils/auth'
|
||||
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const config = useRuntimeConfig()
|
||||
@@ -216,18 +196,6 @@ const verifyCode = async () => {
|
||||
isWaitingForEmailVerified.value = false
|
||||
}
|
||||
}
|
||||
const signupWithGoogle = () => {
|
||||
googleAuthorize(inviteToken.value)
|
||||
}
|
||||
const signupWithGithub = () => {
|
||||
githubAuthorize(inviteToken.value)
|
||||
}
|
||||
const signupWithDiscord = () => {
|
||||
discordAuthorize(inviteToken.value)
|
||||
}
|
||||
const signupWithTwitter = () => {
|
||||
twitterAuthorize(inviteToken.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -300,16 +268,6 @@ const signupWithTwitter = () => {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.other-signup-page-content {
|
||||
margin-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30%;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.signup-page-button-primary {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
@@ -339,29 +297,6 @@ const signupWithTwitter = () => {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.signup-page-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 20px;
|
||||
background-color: var(--login-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
min-width: 150px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.signup-page-button:hover {
|
||||
background-color: var(--login-background-color-hover);
|
||||
}
|
||||
|
||||
.signup-page-button-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.signup-page-button-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -411,16 +346,5 @@ const signupWithTwitter = () => {
|
||||
margin-top: 0px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.other-signup-page-content {
|
||||
margin-top: 20px;
|
||||
margin-left: 0px;
|
||||
width: calc(100% - 40px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.signup-page-button {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
42
frontend_nuxt/pages/telegram-callback.vue
Normal file
42
frontend_nuxt/pages/telegram-callback.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<CallbackPage />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import CallbackPage from '~/components/CallbackPage.vue'
|
||||
import { telegramExchange } from '~/utils/telegram'
|
||||
|
||||
onMounted(async () => {
|
||||
const url = new URL(window.location.href)
|
||||
const inviteToken =
|
||||
url.searchParams.get('invite_token') || url.searchParams.get('invitetoken') || ''
|
||||
const hash = url.hash.startsWith('#tgAuthResult=') ? url.hash.slice('#tgAuthResult='.length) : ''
|
||||
if (!hash) {
|
||||
navigateTo('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
let authData
|
||||
try {
|
||||
const decoded = atob(hash)
|
||||
const parsed = JSON.parse(decoded)
|
||||
authData = {
|
||||
id: String(parsed.id),
|
||||
firstName: parsed.first_name,
|
||||
lastName: parsed.last_name,
|
||||
username: parsed.username,
|
||||
photoUrl: parsed.photo_url,
|
||||
authDate: parsed.auth_date,
|
||||
hash: parsed.hash,
|
||||
}
|
||||
} catch (e) {
|
||||
navigateTo('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
const result = await telegramExchange(authData, inviteToken, '')
|
||||
if (result.needReason) {
|
||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||
} else {
|
||||
navigateTo('/', { replace: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -58,7 +58,9 @@
|
||||
</div>
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">最后发帖时间:</div>
|
||||
<div class="profile-info-item-value">{{ formatDate(user.lastPostTime) }}</div>
|
||||
<div class="profile-info-item-value">
|
||||
{{ user.lastPostTime != null ? formatDate(user.lastPostTime) : '暂无帖子' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-item-label">最后评论时间:</div>
|
||||
|
||||
18
frontend_nuxt/plugins/echarts.client.ts
Normal file
18
frontend_nuxt/plugins/echarts.client.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// plugins/echarts.client.ts
|
||||
import { defineNuxtPlugin } from 'nuxt/app'
|
||||
import VueECharts from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
DataZoomComponent,
|
||||
TitleComponent,
|
||||
} from 'echarts/components'
|
||||
|
||||
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.component('VChart', VueECharts)
|
||||
})
|
||||
@@ -48,7 +48,7 @@ function tiebaEmojiPlugin(md) {
|
||||
md.renderer.rules['tieba-emoji'] = (tokens, idx) => {
|
||||
const name = tokens[idx].content
|
||||
const file = tiebaEmoji[name]
|
||||
return `<BaseImage class="emoji" src="${file}" alt="${name}">`
|
||||
return `<img class="emoji" src="${file}" alt="${name}">`
|
||||
}
|
||||
md.inline.ruler.before('emphasis', 'tieba-emoji', (state, silent) => {
|
||||
const pos = state.pos
|
||||
@@ -92,10 +92,13 @@ function linkPlugin(md) {
|
||||
|
||||
/** @section MarkdownIt 实例:开启 HTML,但配合强净化 */
|
||||
const md = new MarkdownIt({
|
||||
html: true, // ⭐ 允许行内 HTML(为 <video> 服务)
|
||||
html: true,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
highlight: (str, lang) => {
|
||||
if (lang === 'mermaid') {
|
||||
return `<pre class="mermaid">${str}</pre>`
|
||||
}
|
||||
let code = ''
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
code = hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
|
||||
@@ -106,11 +109,16 @@ const md = new MarkdownIt({
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(() => `<div class="line-number"></div>`)
|
||||
// 保留你原有的 CodeBlock + 复制按钮 + 行号结构
|
||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><div class="line-numbers">${lineNumbers.join('')}</div><code class="hljs language-${lang || ''}">${code.trim()}</code></pre>`
|
||||
},
|
||||
})
|
||||
|
||||
const md2TextRender = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
})
|
||||
|
||||
md.use(mentionPlugin)
|
||||
md.use(tiebaEmojiPlugin)
|
||||
md.use(linkPlugin)
|
||||
@@ -177,7 +185,7 @@ const SANITIZE_CFG = {
|
||||
allowedClasses: {
|
||||
a: ['mention-link'],
|
||||
img: ['emoji'],
|
||||
pre: ['code-block'],
|
||||
pre: ['code-block', 'mermaid'],
|
||||
div: ['line-numbers', 'line-number'],
|
||||
code: ['hljs', /^language-/],
|
||||
button: ['copy-code-btn'],
|
||||
@@ -203,8 +211,15 @@ const SANITIZE_CFG = {
|
||||
/** @section 渲染 & 事件 */
|
||||
export function renderMarkdown(text) {
|
||||
const raw = md.render(text || '')
|
||||
// ⭐ 核心:对最终 HTML 进行一次净化
|
||||
return sanitizeHtml(raw, SANITIZE_CFG)
|
||||
const html = sanitizeHtml(raw, SANITIZE_CFG)
|
||||
if (typeof window !== 'undefined') {
|
||||
setTimeout(async () => {
|
||||
const mermaid = await import('mermaid')
|
||||
mermaid.default.initialize({ startOnLoad: false })
|
||||
mermaid.default.run({ nodes: document.querySelectorAll('.mermaid') })
|
||||
})
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
export function handleMarkdownClick(e) {
|
||||
@@ -221,7 +236,7 @@ export function handleMarkdownClick(e) {
|
||||
|
||||
/** @section 纯文本提取(保持你原有“统一正则法”) */
|
||||
export function stripMarkdown(text) {
|
||||
const html = md.render(text || '')
|
||||
const html = md2TextRender.render(text || '')
|
||||
let plainText = html.replace(/<[^>]+>/g, '')
|
||||
plainText = plainText
|
||||
.replace(/\r\n/g, '\n')
|
||||
|
||||
@@ -25,6 +25,9 @@ const iconMap = {
|
||||
POINT_REDEEM: 'fas fa-gift',
|
||||
LOTTERY_WIN: 'fas fa-trophy',
|
||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||
POLL_VOTE: 'fas fa-square-poll-vertical',
|
||||
POLL_RESULT_OWNER: 'fas fa-flag-checkered',
|
||||
POLL_RESULT_PARTICIPANT: 'fas fa-flag-checkered',
|
||||
MENTION: 'fas fa-at',
|
||||
POST_DELETED: 'fas fa-trash',
|
||||
POST_FEATURED: 'fas fa-star',
|
||||
@@ -113,6 +116,43 @@ export async function updateNotificationPreference(type, enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchEmailNotificationPreferences() {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const token = getToken()
|
||||
if (!token) return []
|
||||
const res = await fetch(`${API_BASE_URL}/api/notifications/email-prefs`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!res.ok) return []
|
||||
return await res.json()
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEmailNotificationPreference(type, enabled) {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const token = getToken()
|
||||
if (!token) return false
|
||||
const res = await fetch(`${API_BASE_URL}/api/notifications/email-prefs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ type, enabled }),
|
||||
})
|
||||
return res.ok
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理信息的高阶函数
|
||||
* @returns
|
||||
@@ -210,6 +250,21 @@ function createFetchNotifications() {
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (
|
||||
n.type === 'POLL_VOTE' ||
|
||||
n.type === 'POLL_RESULT_OWNER' ||
|
||||
n.type === 'POLL_RESULT_PARTICIPANT'
|
||||
) {
|
||||
arr.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'POST_UPDATED' || n.type === 'USER_ACTIVITY') {
|
||||
arr.push({
|
||||
...n,
|
||||
|
||||
56
frontend_nuxt/utils/telegram.js
Normal file
56
frontend_nuxt/utils/telegram.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { toast } from '../main'
|
||||
import { setToken, loadCurrentUser } from './auth'
|
||||
import { registerPush } from './push'
|
||||
|
||||
export function telegramAuthorize(inviteToken = '') {
|
||||
const config = useRuntimeConfig()
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
const TELEGRAM_BOT_ID = config.public.telegramBotId
|
||||
if (!TELEGRAM_BOT_ID) {
|
||||
toast.error('Telegram 登录不可用')
|
||||
return
|
||||
}
|
||||
const redirectUri = `${WEBSITE_BASE_URL}/telegram-callback${inviteToken ? `?invite_token=${encodeURIComponent(inviteToken)}` : ''}`
|
||||
const url =
|
||||
`https://oauth.telegram.org/auth` +
|
||||
`?bot_id=${encodeURIComponent(TELEGRAM_BOT_ID)}` +
|
||||
`&origin=${encodeURIComponent(redirectUri)}` +
|
||||
`&request_access=write`
|
||||
// `&redirect_uri=${encodeURIComponent(redirectUri)}`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
export async function telegramExchange(authData, inviteToken = '', reason = '') {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const payload = { ...authData, reason }
|
||||
if (inviteToken) payload.inviteToken = inviteToken
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/telegram`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
await loadCurrentUser()
|
||||
toast.success('登录成功')
|
||||
registerPush?.()
|
||||
return { success: true, needReason: false }
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
toast.info('当前为注册审核模式,请填写注册理由')
|
||||
return { success: false, needReason: true, token: data.token }
|
||||
} else if (data.reason_code === 'IS_APPROVING') {
|
||||
toast.info('您的注册理由正在审批中')
|
||||
return { success: true, needReason: false }
|
||||
} else {
|
||||
toast.error(data.error || '登录失败')
|
||||
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error('登录失败')
|
||||
return { success: false, needReason: false, error: '登录失败' }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,17 @@ export default class TimeManager {
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
|
||||
if (diffMs >= 0 && diffMs < 60 * 1000) {
|
||||
return '刚刚'
|
||||
}
|
||||
|
||||
if (diffMs >= 0 && diffMs < 60 * 60 * 1000) {
|
||||
const mins = Math.floor(diffMs / 60_000)
|
||||
return `${mins || 1}分钟前`
|
||||
}
|
||||
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const diffDays = Math.floor((startOfToday - startOfDate) / 86400000)
|
||||
|
||||
78
package-lock.json
generated
78
package-lock.json
generated
@@ -8,45 +8,6 @@
|
||||
"name": "openisle",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"ansi-regex": "^6.1.0",
|
||||
"ansi-styles": "^6.2.1",
|
||||
"braces": "^3.0.3",
|
||||
"chalk": "^5.5.0",
|
||||
"cli-cursor": "^5.0.0",
|
||||
"cli-truncate": "^4.0.0",
|
||||
"colorette": "^2.0.20",
|
||||
"commander": "^14.0.0",
|
||||
"debug": "^4.4.1",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"environment": "^1.1.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"fill-range": "^7.1.1",
|
||||
"get-east-asian-width": "^1.3.0",
|
||||
"is-fullwidth-code-point": "^4.0.0",
|
||||
"is-number": "^7.0.0",
|
||||
"lilconfig": "^3.1.3",
|
||||
"listr2": "^9.0.1",
|
||||
"log-update": "^6.1.0",
|
||||
"micromatch": "^4.0.8",
|
||||
"mimic-function": "^5.0.1",
|
||||
"ms": "^2.1.3",
|
||||
"nano-spawn": "^1.0.2",
|
||||
"onetime": "^7.0.0",
|
||||
"picomatch": "^2.3.1",
|
||||
"pidtree": "^0.6.0",
|
||||
"restore-cursor": "^5.1.0",
|
||||
"rfdc": "^1.4.1",
|
||||
"signal-exit": "^4.1.0",
|
||||
"slice-ansi": "^5.0.0",
|
||||
"string-argv": "^0.3.2",
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"to-regex-range": "^5.0.1",
|
||||
"wrap-ansi": "^9.0.0",
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.5",
|
||||
@@ -57,6 +18,7 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz",
|
||||
"integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"environment": "^1.0.0"
|
||||
@@ -72,6 +34,7 @@
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -84,6 +47,7 @@
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
|
||||
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -96,6 +60,7 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
@@ -108,6 +73,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz",
|
||||
"integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
@@ -120,6 +86,7 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
||||
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"restore-cursor": "^5.0.0"
|
||||
@@ -135,6 +102,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz",
|
||||
"integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"slice-ansi": "^5.0.0",
|
||||
@@ -151,12 +119,14 @@
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz",
|
||||
"integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -166,6 +136,7 @@
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -183,12 +154,14 @@
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
|
||||
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/environment": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
|
||||
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -201,12 +174,14 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@@ -219,6 +194,7 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz",
|
||||
"integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -247,6 +223,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
|
||||
"integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -259,6 +236,7 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
@@ -268,6 +246,7 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -308,6 +287,7 @@
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz",
|
||||
"integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cli-truncate": "^4.0.0",
|
||||
@@ -325,6 +305,7 @@
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
||||
"integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -344,6 +325,7 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz",
|
||||
"integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.0.0"
|
||||
@@ -359,6 +341,7 @@
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz",
|
||||
"integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
@@ -375,6 +358,7 @@
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
@@ -388,6 +372,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
|
||||
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -400,12 +385,14 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nano-spawn": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz",
|
||||
"integrity": "sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.17"
|
||||
@@ -418,6 +405,7 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
|
||||
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-function": "^5.0.0"
|
||||
@@ -433,6 +421,7 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -445,6 +434,7 @@
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
|
||||
"integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"pidtree": "bin/pidtree.js"
|
||||
@@ -473,6 +463,7 @@
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"onetime": "^7.0.0",
|
||||
@@ -489,12 +480,14 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -507,6 +500,7 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz",
|
||||
"integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.0.0",
|
||||
@@ -523,6 +517,7 @@
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
|
||||
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6.19"
|
||||
@@ -532,6 +527,7 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
@@ -549,6 +545,7 @@
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
@@ -564,6 +561,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
@@ -576,6 +574,7 @@
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz",
|
||||
"integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
@@ -593,6 +592,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
|
||||
Reference in New Issue
Block a user