mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-13 02:20:56 +08:00
Compare commits
173 Commits
feature/me
...
codex/impl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b837c9d7f | ||
|
|
017ad5bf54 | ||
|
|
f076b70e9b | ||
|
|
62d12ad2a7 | ||
|
|
923854bbc6 | ||
|
|
9ca5d7b167 | ||
|
|
9c3e1d17f0 | ||
|
|
7906062945 | ||
|
|
785c36d339 | ||
|
|
197cbca99c | ||
|
|
b1076d7256 | ||
|
|
ce94cd7e73 | ||
|
|
90147d6cd9 | ||
|
|
2c187cf2cd | ||
|
|
0b6d4f9709 | ||
|
|
cf3b6d8fc7 | ||
|
|
8d98c876d2 | ||
|
|
df4df1933a | ||
|
|
7507f1bb03 | ||
|
|
9b4c36c76a | ||
|
|
edfc81aeb0 | ||
|
|
7bd1225b27 | ||
|
|
2dd56e27af | ||
|
|
c3ecef3609 | ||
|
|
efc74d0f77 | ||
|
|
f27cb5c703 | ||
|
|
a756c2fab3 | ||
|
|
4e2171a8a6 | ||
|
|
bcbdff8768 | ||
|
|
b976a1f46f | ||
|
|
b9fd9711de | ||
|
|
642a527dcf | ||
|
|
88afcc5a8e | ||
|
|
2c5462cd97 | ||
|
|
2f29946b11 | ||
|
|
e27aa34cfd | ||
|
|
2322b2da15 | ||
|
|
79261054f9 | ||
|
|
86633e1f21 | ||
|
|
784598a6f0 | ||
|
|
fdad0e5d34 | ||
|
|
ebf63c4072 | ||
|
|
354d6bdaf9 | ||
|
|
d9aebdebdc | ||
|
|
d6f6495b35 | ||
|
|
300f8705ef | ||
|
|
1f74a29dce | ||
|
|
27ef792b11 | ||
|
|
8dd2d59617 | ||
|
|
077ba448d7 | ||
|
|
9ce85f2769 | ||
|
|
f5557cbf08 | ||
|
|
e042c499e1 | ||
|
|
e01afb168c | ||
|
|
c1d81eb1d1 | ||
|
|
2b0b429866 | ||
|
|
8ea85d78ee | ||
|
|
3b506fe8a8 | ||
|
|
3cc7a4c01a | ||
|
|
2e749a5672 | ||
|
|
7d553d7750 | ||
|
|
16105cef54 | ||
|
|
2b824d94f2 | ||
|
|
00d3c563e2 | ||
|
|
b26891261c | ||
|
|
c1d19b854b | ||
|
|
72e7ccf262 | ||
|
|
84ca6fd28c | ||
|
|
d1c148c5c4 | ||
|
|
ef58630dae | ||
|
|
f025e82e7c | ||
|
|
4380a988f7 | ||
|
|
2899f7af48 | ||
|
|
d4b05256a3 | ||
|
|
57a26e375d | ||
|
|
8a202c4fba | ||
|
|
089b2a3f5f | ||
|
|
0b3d7a21d5 | ||
|
|
fe8a705a28 | ||
|
|
974c7ba83e | ||
|
|
f2937d735d | ||
|
|
423248c574 | ||
|
|
5126cfda8c | ||
|
|
e009875797 | ||
|
|
04ff17f796 | ||
|
|
e9c9fbd742 | ||
|
|
b385945c2d | ||
|
|
24cbed2eda | ||
|
|
ba073b71a6 | ||
|
|
5ff098ea21 | ||
|
|
f6713b956e | ||
|
|
b8ea12646f | ||
|
|
e573e54c2b | ||
|
|
8ec005d392 | ||
|
|
b1f92f61a6 | ||
|
|
824b4dd8aa | ||
|
|
6b08db7e58 | ||
|
|
6f3830b3f7 | ||
|
|
d70dad723f | ||
|
|
2cf89e4802 | ||
|
|
1fc6460ae0 | ||
|
|
a04e5c2f6f | ||
|
|
77b26937f5 | ||
|
|
a1134b9d4b | ||
|
|
600f6ac1d1 | ||
|
|
9ad50b35c9 | ||
|
|
867ee3907b | ||
|
|
58fcd42745 | ||
|
|
0ee62a3a04 | ||
|
|
f0bc7a22a0 | ||
|
|
f6c0c8e226 | ||
|
|
8f3c0d6710 | ||
|
|
4f738778db | ||
|
|
84b45f785d | ||
|
|
df56d7e885 | ||
|
|
76176e135c | ||
|
|
ab87e0e51c | ||
|
|
5346a063bf | ||
|
|
e53f2130b8 | ||
|
|
1e87e9252d | ||
|
|
3fc4d29dce | ||
|
|
bcdac9d9b2 | ||
|
|
ea9710d16f | ||
|
|
47134cadc2 | ||
|
|
1a1b20b9cf | ||
|
|
b63ebb8fae | ||
|
|
e0f7299a86 | ||
|
|
1f9ae8d057 | ||
|
|
da1ad73cf6 | ||
|
|
53c603f33a | ||
|
|
06f86f2b21 | ||
|
|
22693bfdd9 | ||
|
|
0058f20b1e | ||
|
|
304d941d68 | ||
|
|
3dbcd2ac4d | ||
|
|
2efe4e733a | ||
|
|
08239a16b8 | ||
|
|
cb49dc9b73 | ||
|
|
43d4c9be43 | ||
|
|
1dc13698ad | ||
|
|
d58432dcd9 | ||
|
|
e7ff73c7f9 | ||
|
|
4ee9532d5f | ||
|
|
80c3fd8ea2 | ||
|
|
7e277d06d5 | ||
|
|
d2b68119bd | ||
|
|
f7b0d7edd5 | ||
|
|
cdea1ab911 | ||
|
|
ada6bfb5cf | ||
|
|
928dbd73b5 | ||
|
|
8c1a7afc6e | ||
|
|
87453f7198 | ||
|
|
48e3593ef9 | ||
|
|
655e8f2a65 | ||
|
|
7a0afedc7c | ||
|
|
902fce5174 | ||
|
|
0034839e8d | ||
|
|
148fd36fd1 | ||
|
|
06cd663eaf | ||
|
|
0edbeabac2 | ||
|
|
65cc3ee58b | ||
|
|
6965fcfb7f | ||
|
|
40520c30ec | ||
|
|
5d7ca3d29a | ||
|
|
a3aec1133b | ||
|
|
8fa715477b | ||
|
|
9209ebea4c | ||
|
|
47a9ce5843 | ||
|
|
dfef13e2be | ||
|
|
2f4d6e68da | ||
|
|
414872f61e | ||
|
|
82475f71db | ||
|
|
a6874e9be3 |
23
.github/workflows/deploy-staging.yml
vendored
Normal file
23
.github/workflows/deploy-staging.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: Staging CI & CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: Deploy
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy to Server
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: root
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
script: bash /opt/openisle/deploy-staging.sh
|
||||||
|
|
||||||
20
.github/workflows/deploy.yml
vendored
20
.github/workflows/deploy.yml
vendored
@@ -1,9 +1,9 @@
|
|||||||
name: CI & CD
|
name: CI & CD
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
@@ -13,22 +13,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
# - uses: actions/setup-java@v4
|
|
||||||
# with:
|
|
||||||
# java-version: '17'
|
|
||||||
# distribution: 'temurin'
|
|
||||||
|
|
||||||
# - run: mvn -B clean package -DskipTests
|
|
||||||
|
|
||||||
# - uses: actions/setup-node@v4
|
|
||||||
# with:
|
|
||||||
# node-version: '20'
|
|
||||||
|
|
||||||
# - run: |
|
|
||||||
# cd open-isle-cli
|
|
||||||
# npm ci
|
|
||||||
# npm run build
|
|
||||||
|
|
||||||
- name: Deploy to Server
|
- name: Deploy to Server
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
with:
|
with:
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
|
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
|
||||||
|
|
||||||
## 🚀 部署
|
## 🚧 开发
|
||||||
|
|
||||||
### 后端
|
### 后端
|
||||||
|
|
||||||
@@ -20,9 +20,26 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
|||||||
|
|
||||||
### 前端
|
### 前端
|
||||||
|
|
||||||
1. `cd open-isle-cli`
|
1. 进入前端目录
|
||||||
2. 执行 `npm install`
|
```bash
|
||||||
3. `npm run serve`可在本地启动开发服务,产品环境使用 `npm run build`生成 `dist/` 文件,配合线上网站方式部署
|
cd frontend_nuxt
|
||||||
|
```
|
||||||
|
2. 安装依赖
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
3. 启动开发服务
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
生产版本使用如下命令编译:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
会在 `.output` 目录生成文件,配合线上网站方式部署
|
||||||
|
|
||||||
## ✨ 项目特点
|
## ✨ 项目特点
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&
|
|||||||
MYSQL_USER=<数据库用户名>
|
MYSQL_USER=<数据库用户名>
|
||||||
MYSQL_PASSWORD=<数据库密码>
|
MYSQL_PASSWORD=<数据库密码>
|
||||||
|
|
||||||
|
# === JWT ===
|
||||||
|
JWT_SECRET=<jwt secret>
|
||||||
|
JWT_REASON_SECRET=<jwt reason secret>
|
||||||
|
JWT_RESET_SECRET=<jwt reset secret>
|
||||||
|
JWT_INVITE_SECRET=<jwt invite secret>
|
||||||
|
JWT_EXPIRATION=2592000000
|
||||||
|
|
||||||
# === Resend ===
|
# === Resend ===
|
||||||
RESEND_API_KEY=<你的resend-api-key>
|
RESEND_API_KEY=<你的resend-api-key>
|
||||||
@@ -30,4 +36,4 @@ OPENAI_API_KEY=<你的openai-api-key>
|
|||||||
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
|
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
|
||||||
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
|
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
|
||||||
|
|
||||||
# LOG_LEVEL=DEBUG
|
# LOG_LEVEL=DEBUG
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.openisle.repository.ActivityRepository;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -22,5 +24,16 @@ public class ActivityInitializer implements CommandLineRunner {
|
|||||||
a.setContent("为了有利于建站推广以及激励发布内容,我们推出了建站送奶茶的活动,前50名达到level 1的用户,可以联系站长获取奶茶/咖啡一杯");
|
a.setContent("为了有利于建站推广以及激励发布内容,我们推出了建站送奶茶的活动,前50名达到level 1的用户,可以联系站长获取奶茶/咖啡一杯");
|
||||||
activityRepository.save(a);
|
activityRepository.save(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activityRepository.findByType(ActivityType.INVITE_POINTS) == null) {
|
||||||
|
Activity a = new Activity();
|
||||||
|
a.setTitle("🎁邀请码送积分活动");
|
||||||
|
a.setType(ActivityType.INVITE_POINTS);
|
||||||
|
a.setIcon("https://img.icons8.com/color/96/gift.png");
|
||||||
|
a.setContent("使用邀请码注册或邀请好友即可获得积分奖励,快来参与吧!");
|
||||||
|
a.setStartTime(LocalDateTime.now());
|
||||||
|
a.setEndTime(LocalDate.of(LocalDate.now().getYear(), 10, 1).atStartOfDay());
|
||||||
|
activityRepository.save(a);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.openisle.config;
|
||||||
|
|
||||||
|
import com.openisle.model.PointGood;
|
||||||
|
import com.openisle.repository.PointGoodRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** Initialize default point mall goods. */
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PointGoodInitializer implements CommandLineRunner {
|
||||||
|
private final PointGoodRepository pointGoodRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(String... args) {
|
||||||
|
if (pointGoodRepository.count() == 0) {
|
||||||
|
PointGood g1 = new PointGood();
|
||||||
|
g1.setName("GPT Plus 1 个月");
|
||||||
|
g1.setCost(20000);
|
||||||
|
g1.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/chatgpt.png");
|
||||||
|
pointGoodRepository.save(g1);
|
||||||
|
|
||||||
|
PointGood g2 = new PointGood();
|
||||||
|
g2.setName("奶茶");
|
||||||
|
g2.setCost(5000);
|
||||||
|
g2.setImage("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/coffee.png");
|
||||||
|
pointGoodRepository.save(g2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ public class SecurityConfig {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final AccessDeniedHandler customAccessDeniedHandler;
|
private final AccessDeniedHandler customAccessDeniedHandler;
|
||||||
private final UserVisitService userVisitService;
|
private final UserVisitService userVisitService;
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@@ -75,14 +75,16 @@ public class SecurityConfig {
|
|||||||
cfg.setAllowedOrigins(List.of(
|
cfg.setAllowedOrigins(List.of(
|
||||||
"http://127.0.0.1:8080",
|
"http://127.0.0.1:8080",
|
||||||
"http://127.0.0.1:3000",
|
"http://127.0.0.1:3000",
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
"http://127.0.0.1",
|
"http://127.0.0.1",
|
||||||
"http://localhost:8080",
|
"http://localhost:8080",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
|
"http://localhost:3001",
|
||||||
"http://localhost",
|
"http://localhost",
|
||||||
"http://30.211.97.238:3000",
|
"http://30.211.97.238:3000",
|
||||||
"http://30.211.97.238",
|
"http://30.211.97.238",
|
||||||
"http://192.168.7.70",
|
"http://192.168.7.98",
|
||||||
"http://192.168.7.70:8080",
|
"http://192.168.7.98:3000",
|
||||||
websiteUrl,
|
websiteUrl,
|
||||||
websiteUrl.replace("://www.", "://")
|
websiteUrl.replace("://www.", "://")
|
||||||
));
|
));
|
||||||
@@ -117,6 +119,8 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
|
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
|
||||||
.requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated()
|
.requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated()
|
||||||
.requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN")
|
.requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN")
|
||||||
@@ -149,6 +153,7 @@ public class SecurityConfig {
|
|||||||
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
|
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
|
||||||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
|
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
|
||||||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
|
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
|
||||||
|
uri.startsWith("/api/point-goods") ||
|
||||||
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals"));
|
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals"));
|
||||||
|
|
||||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.openisle.controller;
|
||||||
|
|
||||||
|
import com.openisle.dto.CommentDto;
|
||||||
|
import com.openisle.mapper.CommentMapper;
|
||||||
|
import com.openisle.service.CommentService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoints for administrators to manage comments.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/comments")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminCommentController {
|
||||||
|
private final CommentService commentService;
|
||||||
|
private final CommentMapper commentMapper;
|
||||||
|
|
||||||
|
@PostMapping("/{id}/pin")
|
||||||
|
public CommentDto pin(@PathVariable Long id, Authentication auth) {
|
||||||
|
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/unpin")
|
||||||
|
public CommentDto unpin(@PathVariable Long id, Authentication auth) {
|
||||||
|
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ public class AdminUserController {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final NotificationRepository notificationRepository;
|
private final NotificationRepository notificationRepository;
|
||||||
private final EmailSender emailSender;
|
private final EmailSender emailSender;
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
@PostMapping("/{id}/approve")
|
@PostMapping("/{id}/approve")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class AuthController {
|
|||||||
private final RegisterModeService registerModeService;
|
private final RegisterModeService registerModeService;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final InviteService inviteService;
|
||||||
|
|
||||||
|
|
||||||
@Value("${app.captcha.enabled:false}")
|
@Value("${app.captcha.enabled:false}")
|
||||||
@@ -45,6 +46,25 @@ public class AuthController {
|
|||||||
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
||||||
}
|
}
|
||||||
|
if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) {
|
||||||
|
if (!inviteService.validate(req.getInviteToken())) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
User user = userService.registerWithInvite(
|
||||||
|
req.getUsername(), req.getEmail(), req.getPassword());
|
||||||
|
inviteService.consume(req.getInviteToken());
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"token", jwtService.generateToken(user.getUsername()),
|
||||||
|
"reason_code", "INVITE_APPROVED"
|
||||||
|
));
|
||||||
|
} catch (FieldException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"field", e.getField(),
|
||||||
|
"error", e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
User user = userService.register(
|
User user = userService.register(
|
||||||
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
|
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
|
||||||
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
|
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
|
||||||
@@ -106,27 +126,42 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/google")
|
@PostMapping("/google")
|
||||||
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
|
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
|
||||||
Optional<User> user = googleAuthService.authenticate(req.getIdToken(), registerModeService.getRegisterMode());
|
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||||
if (user.isPresent()) {
|
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
}
|
||||||
|
Optional<AuthResult> resultOpt = googleAuthService.authenticate(
|
||||||
|
req.getIdToken(),
|
||||||
|
registerModeService.getRegisterMode(),
|
||||||
|
viaInvite);
|
||||||
|
if (resultOpt.isPresent()) {
|
||||||
|
AuthResult result = resultOpt.get();
|
||||||
|
if (viaInvite && result.isNewUser()) {
|
||||||
|
inviteService.consume(req.getInviteToken());
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||||
|
"reason_code", "INVITE_APPROVED"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
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(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "IS_APPROVING",
|
"reason_code", "IS_APPROVING",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "NOT_APPROVED",
|
"reason_code", "NOT_APPROVED",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Invalid google token",
|
"error", "Invalid google token",
|
||||||
@@ -165,28 +200,44 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/github")
|
@PostMapping("/github")
|
||||||
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
|
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
|
||||||
Optional<User> user = githubAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
|
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||||
if (user.isPresent()) {
|
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
}
|
||||||
|
Optional<AuthResult> resultOpt = githubAuthService.authenticate(
|
||||||
|
req.getCode(),
|
||||||
|
registerModeService.getRegisterMode(),
|
||||||
|
req.getRedirectUri(),
|
||||||
|
viaInvite);
|
||||||
|
if (resultOpt.isPresent()) {
|
||||||
|
AuthResult result = resultOpt.get();
|
||||||
|
if (viaInvite && result.isNewUser()) {
|
||||||
|
inviteService.consume(req.getInviteToken());
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||||
|
"reason_code", "INVITE_APPROVED"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
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(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "IS_APPROVING",
|
"reason_code", "IS_APPROVING",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "NOT_APPROVED",
|
"reason_code", "NOT_APPROVED",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Invalid github code",
|
"error", "Invalid github code",
|
||||||
@@ -196,27 +247,43 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/discord")
|
@PostMapping("/discord")
|
||||||
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
|
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
|
||||||
Optional<User> user = discordAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
|
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||||
if (user.isPresent()) {
|
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
}
|
||||||
|
Optional<AuthResult> resultOpt = discordAuthService.authenticate(
|
||||||
|
req.getCode(),
|
||||||
|
registerModeService.getRegisterMode(),
|
||||||
|
req.getRedirectUri(),
|
||||||
|
viaInvite);
|
||||||
|
if (resultOpt.isPresent()) {
|
||||||
|
AuthResult result = resultOpt.get();
|
||||||
|
if (viaInvite && result.isNewUser()) {
|
||||||
|
inviteService.consume(req.getInviteToken());
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||||
|
"reason_code", "INVITE_APPROVED"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
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(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "IS_APPROVING",
|
"reason_code", "IS_APPROVING",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "NOT_APPROVED",
|
"reason_code", "NOT_APPROVED",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Invalid discord code",
|
"error", "Invalid discord code",
|
||||||
@@ -226,31 +293,44 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/twitter")
|
@PostMapping("/twitter")
|
||||||
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
|
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
|
||||||
Optional<User> user = twitterAuthService.authenticate(
|
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||||
|
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||||
|
}
|
||||||
|
Optional<AuthResult> resultOpt = twitterAuthService.authenticate(
|
||||||
req.getCode(),
|
req.getCode(),
|
||||||
req.getCodeVerifier(),
|
req.getCodeVerifier(),
|
||||||
registerModeService.getRegisterMode(),
|
registerModeService.getRegisterMode(),
|
||||||
req.getRedirectUri());
|
req.getRedirectUri(),
|
||||||
if (user.isPresent()) {
|
viaInvite);
|
||||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
if (resultOpt.isPresent()) {
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
AuthResult result = resultOpt.get();
|
||||||
|
if (viaInvite && result.isNewUser()) {
|
||||||
|
inviteService.consume(req.getInviteToken());
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||||
|
"reason_code", "INVITE_APPROVED"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
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(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "IS_APPROVING",
|
"reason_code", "IS_APPROVING",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Account awaiting approval",
|
"error", "Account awaiting approval",
|
||||||
"reason_code", "NOT_APPROVED",
|
"reason_code", "NOT_APPROVED",
|
||||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
}
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
"error", "Invalid twitter code",
|
"error", "Invalid twitter code",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -44,8 +45,11 @@ public class CategoryController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<CategoryDto> list() {
|
public List<CategoryDto> list() {
|
||||||
return categoryService.listCategories().stream()
|
List<Category> all = categoryService.listCategories();
|
||||||
.map(c -> categoryMapper.toDto(c, postService.countPostsByCategory(c.getId())))
|
List<Long> ids = all.stream().map(Category::getId).toList();
|
||||||
|
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
|
||||||
|
return all.stream()
|
||||||
|
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
|
||||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,4 +85,16 @@ public class CommentController {
|
|||||||
commentService.deleteComment(auth.getName(), id);
|
commentService.deleteComment(auth.getName(), id);
|
||||||
log.debug("deleteComment completed for comment {}", id);
|
log.debug("deleteComment completed for comment {}", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/comments/{id}/pin")
|
||||||
|
public CommentDto pinComment(@PathVariable Long id, Authentication auth) {
|
||||||
|
log.debug("pinComment called by user {} for comment {}", auth.getName(), id);
|
||||||
|
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/comments/{id}/unpin")
|
||||||
|
public CommentDto unpinComment(@PathVariable Long id, Authentication auth) {
|
||||||
|
log.debug("unpinComment called by user {} for comment {}", auth.getName(), id);
|
||||||
|
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.openisle.controller;
|
||||||
|
|
||||||
|
import com.openisle.service.InviteService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/invite")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InviteController {
|
||||||
|
private final InviteService inviteService;
|
||||||
|
|
||||||
|
@PostMapping("/generate")
|
||||||
|
public Map<String, String> generate(Authentication auth) {
|
||||||
|
String token = inviteService.generate(auth.getName());
|
||||||
|
return Map.of("token", token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.openisle.controller;
|
||||||
|
|
||||||
|
import com.openisle.dto.PointGoodDto;
|
||||||
|
import com.openisle.dto.PointRedeemRequest;
|
||||||
|
import com.openisle.mapper.PointGoodMapper;
|
||||||
|
import com.openisle.model.User;
|
||||||
|
import com.openisle.service.PointMallService;
|
||||||
|
import com.openisle.service.UserService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/** REST controller for point mall. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/point-goods")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PointMallController {
|
||||||
|
private final PointMallService pointMallService;
|
||||||
|
private final UserService userService;
|
||||||
|
private final PointGoodMapper pointGoodMapper;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<PointGoodDto> list() {
|
||||||
|
return pointMallService.listGoods().stream()
|
||||||
|
.map(pointGoodMapper::toDto)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/redeem")
|
||||||
|
public Map<String, Integer> redeem(@RequestBody PointRedeemRequest req, Authentication auth) {
|
||||||
|
User user = userService.findByIdentifier(auth.getName()).orElseThrow();
|
||||||
|
int point = pointMallService.redeem(user, req.getGoodId(), req.getContact());
|
||||||
|
return Map.of("point", point);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ import java.util.List;
|
|||||||
public class SitemapController {
|
public class SitemapController {
|
||||||
private final PostRepository postRepository;
|
private final PostRepository postRepository;
|
||||||
|
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
|
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -62,8 +63,11 @@ public class TagController {
|
|||||||
@GetMapping
|
@GetMapping
|
||||||
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
||||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||||
List<TagDto> dtos = tagService.searchTags(keyword).stream()
|
List<Tag> tags = tagService.searchTags(keyword);
|
||||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
|
||||||
|
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
|
||||||
|
List<TagDto> dtos = tags.stream()
|
||||||
|
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
|
||||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
if (limit != null && limit > 0 && dtos.size() > limit) {
|
if (limit != null && limit > 0 && dtos.size() > limit) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class CommentDto {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private String content;
|
private String content;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime pinnedAt;
|
||||||
private AuthorDto author;
|
private AuthorDto author;
|
||||||
private List<CommentDto> replies;
|
private List<CommentDto> replies;
|
||||||
private List<ReactionDto> reactions;
|
private List<ReactionDto> reactions;
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ import lombok.Data;
|
|||||||
public class DiscordLoginRequest {
|
public class DiscordLoginRequest {
|
||||||
private String code;
|
private String code;
|
||||||
private String redirectUri;
|
private String redirectUri;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ import lombok.Data;
|
|||||||
public class GithubLoginRequest {
|
public class GithubLoginRequest {
|
||||||
private String code;
|
private String code;
|
||||||
private String redirectUri;
|
private String redirectUri;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ import lombok.Data;
|
|||||||
@Data
|
@Data
|
||||||
public class GoogleLoginRequest {
|
public class GoogleLoginRequest {
|
||||||
private String idToken;
|
private String idToken;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
12
backend/src/main/java/com/openisle/dto/PointGoodDto.java
Normal file
12
backend/src/main/java/com/openisle/dto/PointGoodDto.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.openisle.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** Point mall good info. */
|
||||||
|
@Data
|
||||||
|
public class PointGoodDto {
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
private int cost;
|
||||||
|
private String image;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.openisle.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** Request to redeem a point mall good. */
|
||||||
|
@Data
|
||||||
|
public class PointRedeemRequest {
|
||||||
|
private Long goodId;
|
||||||
|
private String contact;
|
||||||
|
}
|
||||||
@@ -9,4 +9,5 @@ public class RegisterRequest {
|
|||||||
private String email;
|
private String email;
|
||||||
private String password;
|
private String password;
|
||||||
private String captcha;
|
private String captcha;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ public class TwitterLoginRequest {
|
|||||||
private String code;
|
private String code;
|
||||||
private String redirectUri;
|
private String redirectUri;
|
||||||
private String codeVerifier;
|
private String codeVerifier;
|
||||||
|
private String inviteToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class CommentMapper {
|
|||||||
dto.setId(comment.getId());
|
dto.setId(comment.getId());
|
||||||
dto.setContent(comment.getContent());
|
dto.setContent(comment.getContent());
|
||||||
dto.setCreatedAt(comment.getCreatedAt());
|
dto.setCreatedAt(comment.getCreatedAt());
|
||||||
|
dto.setPinnedAt(comment.getPinnedAt());
|
||||||
dto.setAuthor(userMapper.toAuthorDto(comment.getAuthor()));
|
dto.setAuthor(userMapper.toAuthorDto(comment.getAuthor()));
|
||||||
dto.setReward(0);
|
dto.setReward(0);
|
||||||
return dto;
|
return dto;
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.openisle.mapper;
|
||||||
|
|
||||||
|
import com.openisle.dto.PointGoodDto;
|
||||||
|
import com.openisle.model.PointGood;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** Mapper for point mall goods. */
|
||||||
|
@Component
|
||||||
|
public class PointGoodMapper {
|
||||||
|
public PointGoodDto toDto(PointGood good) {
|
||||||
|
PointGoodDto dto = new PointGoodDto();
|
||||||
|
dto.setId(good.getId());
|
||||||
|
dto.setName(good.getName());
|
||||||
|
dto.setCost(good.getCost());
|
||||||
|
dto.setImage(good.getImage());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,5 +3,6 @@ package com.openisle.model;
|
|||||||
/** Activity type enumeration. */
|
/** Activity type enumeration. */
|
||||||
public enum ActivityType {
|
public enum ActivityType {
|
||||||
NORMAL,
|
NORMAL,
|
||||||
MILK_TEA
|
MILK_TEA,
|
||||||
|
INVITE_POINTS
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,4 +38,7 @@ public class Comment {
|
|||||||
@JoinColumn(name = "parent_id")
|
@JoinColumn(name = "parent_id")
|
||||||
private Comment parent;
|
private Comment parent;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private LocalDateTime pinnedAt;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
23
backend/src/main/java/com/openisle/model/InviteToken.java
Normal file
23
backend/src/main/java/com/openisle/model/InviteToken.java
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package com.openisle.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invite token entity tracking usage counts.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
public class InviteToken {
|
||||||
|
@Id
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
private User inviter;
|
||||||
|
|
||||||
|
private LocalDate createdDate;
|
||||||
|
|
||||||
|
private int usageCount;
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ public class Notification {
|
|||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false)
|
@Column(nullable = false, length = 50)
|
||||||
private NotificationType type;
|
private NotificationType type;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ public enum NotificationType {
|
|||||||
REGISTER_REQUEST,
|
REGISTER_REQUEST,
|
||||||
/** A user redeemed an activity reward */
|
/** A user redeemed an activity reward */
|
||||||
ACTIVITY_REDEEM,
|
ACTIVITY_REDEEM,
|
||||||
|
/** A user redeemed a point good */
|
||||||
|
POINT_REDEEM,
|
||||||
|
/** You won a lottery post */
|
||||||
|
LOTTERY_WIN,
|
||||||
|
/** Your lottery post was drawn */
|
||||||
|
LOTTERY_DRAW,
|
||||||
/** You were mentioned in a post or comment */
|
/** You were mentioned in a post or comment */
|
||||||
MENTION
|
MENTION
|
||||||
}
|
}
|
||||||
|
|||||||
26
backend/src/main/java/com/openisle/model/PointGood.java
Normal file
26
backend/src/main/java/com/openisle/model/PointGood.java
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package com.openisle.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/** Item available in the point mall. */
|
||||||
|
@Entity
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Table(name = "point_goods")
|
||||||
|
public class PointGood {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int cost;
|
||||||
|
|
||||||
|
private String image;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.openisle.repository;
|
||||||
|
|
||||||
|
import com.openisle.model.InviteToken;
|
||||||
|
import com.openisle.model.User;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
|
||||||
|
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.openisle.repository;
|
||||||
|
|
||||||
|
import com.openisle.model.PointGood;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
/** Repository for point mall goods. */
|
||||||
|
public interface PointGoodRepository extends JpaRepository<PointGood, Long> {
|
||||||
|
}
|
||||||
@@ -92,8 +92,14 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
|
|
||||||
long countByCategory_Id(Long categoryId);
|
long countByCategory_Id(Long categoryId);
|
||||||
|
|
||||||
|
@Query("SELECT c.id, COUNT(p) FROM Post p JOIN p.category c WHERE c.id IN :categoryIds GROUP BY c.id")
|
||||||
|
List<Object[]> countPostsByCategoryIds(@Param("categoryIds") List<Long> categoryIds);
|
||||||
|
|
||||||
long countDistinctByTags_Id(Long tagId);
|
long countDistinctByTags_Id(Long tagId);
|
||||||
|
|
||||||
|
@Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id")
|
||||||
|
List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds);
|
||||||
|
|
||||||
long countByAuthor_Id(Long userId);
|
long countByAuthor_Id(Long userId);
|
||||||
|
|
||||||
@Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " +
|
@Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " +
|
||||||
|
|||||||
12
backend/src/main/java/com/openisle/service/AuthResult.java
Normal file
12
backend/src/main/java/com/openisle/service/AuthResult.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package com.openisle.service;
|
||||||
|
|
||||||
|
import com.openisle.model.User;
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
/** Result for OAuth authentication indicating whether a new user was created. */
|
||||||
|
@Value
|
||||||
|
public class AuthResult {
|
||||||
|
User user;
|
||||||
|
boolean newUser;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ import java.util.List;
|
|||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -129,13 +130,26 @@ public class CommentService {
|
|||||||
Post post = postRepository.findById(postId)
|
Post post = postRepository.findById(postId)
|
||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||||
List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post);
|
List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post);
|
||||||
if (sort == CommentSort.NEWEST) {
|
java.util.List<Comment> pinned = new java.util.ArrayList<>();
|
||||||
list.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed());
|
java.util.List<Comment> others = new java.util.ArrayList<>();
|
||||||
} else if (sort == CommentSort.MOST_INTERACTIONS) {
|
for (Comment c : list) {
|
||||||
list.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a)));
|
if (c.getPinnedAt() != null) {
|
||||||
|
pinned.add(c);
|
||||||
|
} else {
|
||||||
|
others.add(c);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log.debug("getCommentsForPost returning {} comments", list.size());
|
pinned.sort(java.util.Comparator.comparing(Comment::getPinnedAt).reversed());
|
||||||
return list;
|
if (sort == CommentSort.NEWEST) {
|
||||||
|
others.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed());
|
||||||
|
} else if (sort == CommentSort.MOST_INTERACTIONS) {
|
||||||
|
others.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a)));
|
||||||
|
}
|
||||||
|
java.util.List<Comment> result = new java.util.ArrayList<>();
|
||||||
|
result.addAll(pinned);
|
||||||
|
result.addAll(others);
|
||||||
|
log.debug("getCommentsForPost returning {} comments", result.size());
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Comment> getReplies(Long parentId) {
|
public List<Comment> getReplies(Long parentId) {
|
||||||
@@ -223,6 +237,32 @@ public class CommentService {
|
|||||||
log.debug("deleteCommentCascade removed comment {}", comment.getId());
|
log.debug("deleteCommentCascade removed comment {}", comment.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Comment pinComment(String username, Long id) {
|
||||||
|
Comment c = commentRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
|
||||||
|
User user = userRepository.findByUsername(username)
|
||||||
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||||
|
if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) {
|
||||||
|
throw new IllegalArgumentException("Unauthorized");
|
||||||
|
}
|
||||||
|
c.setPinnedAt(LocalDateTime.now());
|
||||||
|
return commentRepository.save(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Comment unpinComment(String username, Long id) {
|
||||||
|
Comment c = commentRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
|
||||||
|
User user = userRepository.findByUsername(username)
|
||||||
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||||
|
if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) {
|
||||||
|
throw new IllegalArgumentException("Unauthorized");
|
||||||
|
}
|
||||||
|
c.setPinnedAt(null);
|
||||||
|
return commentRepository.save(c);
|
||||||
|
}
|
||||||
|
|
||||||
private int interactionCount(Comment comment) {
|
private int interactionCount(Comment comment) {
|
||||||
int reactions = reactionRepository.findByComment(comment).size();
|
int reactions = reactionRepository.findByComment(comment).size();
|
||||||
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();
|
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public class DiscordAuthService {
|
|||||||
@Value("${discord.client-secret:}")
|
@Value("${discord.client-secret:}")
|
||||||
private String clientSecret;
|
private String clientSecret;
|
||||||
|
|
||||||
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
|
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
|
||||||
try {
|
try {
|
||||||
String tokenUrl = "https://discord.com/api/oauth2/token";
|
String tokenUrl = "https://discord.com/api/oauth2/token";
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
@@ -67,13 +67,13 @@ public class DiscordAuthService {
|
|||||||
if (email == null) {
|
if (email == null) {
|
||||||
email = (username != null ? username : id) + "@users.noreply.discord.com";
|
email = (username != null ? username : id) + "@users.noreply.discord.com";
|
||||||
}
|
}
|
||||||
return Optional.of(processUser(email, username, avatar, mode));
|
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
Optional<User> existing = userRepository.findByEmail(email);
|
Optional<User> existing = userRepository.findByEmail(email);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
User user = existing.get();
|
User user = existing.get();
|
||||||
@@ -82,7 +82,7 @@ public class DiscordAuthService {
|
|||||||
user.setVerificationCode(null);
|
user.setVerificationCode(null);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
}
|
}
|
||||||
return user;
|
return new AuthResult(user, false);
|
||||||
}
|
}
|
||||||
String baseUsername = username != null ? username : email.split("@")[0];
|
String baseUsername = username != null ? username : email.split("@")[0];
|
||||||
String finalUsername = baseUsername;
|
String finalUsername = baseUsername;
|
||||||
@@ -96,12 +96,12 @@ public class DiscordAuthService {
|
|||||||
user.setPassword("");
|
user.setPassword("");
|
||||||
user.setRole(Role.USER);
|
user.setRole(Role.USER);
|
||||||
user.setVerified(true);
|
user.setVerified(true);
|
||||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
user.setAvatar(avatar);
|
user.setAvatar(avatar);
|
||||||
} else {
|
} else {
|
||||||
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
|
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
|
||||||
}
|
}
|
||||||
return userRepository.save(user);
|
return new AuthResult(userRepository.save(user), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public class GithubAuthService {
|
|||||||
@Value("${github.client-secret:}")
|
@Value("${github.client-secret:}")
|
||||||
private String clientSecret;
|
private String clientSecret;
|
||||||
|
|
||||||
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
|
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
|
||||||
try {
|
try {
|
||||||
String tokenUrl = "https://github.com/login/oauth/access_token";
|
String tokenUrl = "https://github.com/login/oauth/access_token";
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
@@ -86,13 +86,13 @@ public class GithubAuthService {
|
|||||||
if (email == null) {
|
if (email == null) {
|
||||||
email = username + "@users.noreply.github.com";
|
email = username + "@users.noreply.github.com";
|
||||||
}
|
}
|
||||||
return Optional.of(processUser(email, username, avatarUrl, mode));
|
return Optional.of(processUser(email, username, avatarUrl, mode, viaInvite));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
Optional<User> existing = userRepository.findByEmail(email);
|
Optional<User> existing = userRepository.findByEmail(email);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
User user = existing.get();
|
User user = existing.get();
|
||||||
@@ -101,7 +101,7 @@ public class GithubAuthService {
|
|||||||
user.setVerificationCode(null);
|
user.setVerificationCode(null);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
}
|
}
|
||||||
return user;
|
return new AuthResult(user, false);
|
||||||
}
|
}
|
||||||
String baseUsername = username != null ? username : email.split("@")[0];
|
String baseUsername = username != null ? username : email.split("@")[0];
|
||||||
String finalUsername = baseUsername;
|
String finalUsername = baseUsername;
|
||||||
@@ -115,12 +115,12 @@ public class GithubAuthService {
|
|||||||
user.setPassword("");
|
user.setPassword("");
|
||||||
user.setRole(Role.USER);
|
user.setRole(Role.USER);
|
||||||
user.setVerified(true);
|
user.setVerified(true);
|
||||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
user.setAvatar(avatar);
|
user.setAvatar(avatar);
|
||||||
} else {
|
} else {
|
||||||
user.setAvatar(avatarGenerator.generate(finalUsername));
|
user.setAvatar(avatarGenerator.generate(finalUsername));
|
||||||
}
|
}
|
||||||
return userRepository.save(user);
|
return new AuthResult(userRepository.save(user), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public class GoogleAuthService {
|
|||||||
@Value("${google.client-id:}")
|
@Value("${google.client-id:}")
|
||||||
private String clientId;
|
private String clientId;
|
||||||
|
|
||||||
public Optional<User> authenticate(String idTokenString, com.openisle.model.RegisterMode mode) {
|
public Optional<AuthResult> authenticate(String idTokenString, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory())
|
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory())
|
||||||
.setAudience(Collections.singletonList(clientId))
|
.setAudience(Collections.singletonList(clientId))
|
||||||
.build();
|
.build();
|
||||||
@@ -38,13 +38,13 @@ public class GoogleAuthService {
|
|||||||
String email = payload.getEmail();
|
String email = payload.getEmail();
|
||||||
String name = (String) payload.get("name");
|
String name = (String) payload.get("name");
|
||||||
String picture = (String) payload.get("picture");
|
String picture = (String) payload.get("picture");
|
||||||
return Optional.of(processUser(email, name, picture, mode));
|
return Optional.of(processUser(email, name, picture, mode, viaInvite));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private User processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode) {
|
private AuthResult processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
Optional<User> existing = userRepository.findByEmail(email);
|
Optional<User> existing = userRepository.findByEmail(email);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
User user = existing.get();
|
User user = existing.get();
|
||||||
@@ -53,8 +53,7 @@ public class GoogleAuthService {
|
|||||||
user.setVerificationCode(null);
|
user.setVerificationCode(null);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
return new AuthResult(user, false);
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
User user = new User();
|
User user = new User();
|
||||||
String baseUsername = email.split("@")[0];
|
String baseUsername = email.split("@")[0];
|
||||||
@@ -68,12 +67,12 @@ public class GoogleAuthService {
|
|||||||
user.setPassword("");
|
user.setPassword("");
|
||||||
user.setRole(Role.USER);
|
user.setRole(Role.USER);
|
||||||
user.setVerified(true);
|
user.setVerified(true);
|
||||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
user.setAvatar(avatar);
|
user.setAvatar(avatar);
|
||||||
} else {
|
} else {
|
||||||
user.setAvatar(avatarGenerator.generate(username));
|
user.setAvatar(avatarGenerator.generate(username));
|
||||||
}
|
}
|
||||||
return userRepository.save(user);
|
return new AuthResult(userRepository.save(user), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.openisle.service;
|
||||||
|
|
||||||
|
import com.openisle.model.InviteToken;
|
||||||
|
import com.openisle.model.User;
|
||||||
|
import com.openisle.repository.InviteTokenRepository;
|
||||||
|
import com.openisle.repository.UserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InviteService {
|
||||||
|
private final InviteTokenRepository inviteTokenRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final PointService pointService;
|
||||||
|
|
||||||
|
public String generate(String username) {
|
||||||
|
User inviter = userRepository.findByUsername(username).orElseThrow();
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
return existing.get().getToken();
|
||||||
|
}
|
||||||
|
String token = jwtService.generateInviteToken(username);
|
||||||
|
InviteToken inviteToken = new InviteToken();
|
||||||
|
inviteToken.setToken(token);
|
||||||
|
inviteToken.setInviter(inviter);
|
||||||
|
inviteToken.setCreatedDate(today);
|
||||||
|
inviteToken.setUsageCount(0);
|
||||||
|
inviteTokenRepository.save(inviteToken);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean validate(String token) {
|
||||||
|
try {
|
||||||
|
jwtService.validateAndGetSubjectForInvite(token);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
|
||||||
|
return invite != null && invite.getUsageCount() < 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void consume(String token) {
|
||||||
|
InviteToken invite = inviteTokenRepository.findById(token).orElseThrow();
|
||||||
|
invite.setUsageCount(invite.getUsageCount() + 1);
|
||||||
|
inviteTokenRepository.save(invite);
|
||||||
|
pointService.awardForInvite(invite.getInviter().getUsername());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ public class JwtService {
|
|||||||
@Value("${app.jwt.reset-secret}")
|
@Value("${app.jwt.reset-secret}")
|
||||||
private String resetSecret;
|
private String resetSecret;
|
||||||
|
|
||||||
|
@Value("${app.jwt.invite-secret}")
|
||||||
|
private String inviteSecret;
|
||||||
|
|
||||||
@Value("${app.jwt.expiration}")
|
@Value("${app.jwt.expiration}")
|
||||||
private long expiration;
|
private long expiration;
|
||||||
|
|
||||||
@@ -70,6 +73,17 @@ public class JwtService {
|
|||||||
.compact();
|
.compact();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String generateInviteToken(String subject) {
|
||||||
|
Date now = new Date();
|
||||||
|
Date expiryDate = new Date(now.getTime() + expiration);
|
||||||
|
return Jwts.builder()
|
||||||
|
.setSubject(subject)
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.setExpiration(expiryDate)
|
||||||
|
.signWith(getSigningKeyForSecret(inviteSecret))
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
public String validateAndGetSubject(String token) {
|
public String validateAndGetSubject(String token) {
|
||||||
Claims claims = Jwts.parserBuilder()
|
Claims claims = Jwts.parserBuilder()
|
||||||
.setSigningKey(getSigningKeyForSecret(secret))
|
.setSigningKey(getSigningKeyForSecret(secret))
|
||||||
@@ -96,4 +110,13 @@ public class JwtService {
|
|||||||
.getBody();
|
.getBody();
|
||||||
return claims.getSubject();
|
return claims.getSubject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String validateAndGetSubjectForInvite(String token) {
|
||||||
|
Claims claims = Jwts.parserBuilder()
|
||||||
|
.setSigningKey(getSigningKeyForSecret(inviteSecret))
|
||||||
|
.build()
|
||||||
|
.parseClaimsJws(token)
|
||||||
|
.getBody();
|
||||||
|
return claims.getSubject();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public class NotificationService {
|
|||||||
private final ReactionRepository reactionRepository;
|
private final ReactionRepository reactionRepository;
|
||||||
private final Executor notificationExecutor;
|
private final Executor notificationExecutor;
|
||||||
|
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
|
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
|
||||||
@@ -141,6 +141,19 @@ public class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create notifications for all admins when a user redeems a point good.
|
||||||
|
* Old redeem notifications from the same user are removed first.
|
||||||
|
*/
|
||||||
|
@org.springframework.transaction.annotation.Transactional
|
||||||
|
public void createPointRedeemNotifications(User user, String content) {
|
||||||
|
// notificationRepository.deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user);
|
||||||
|
for (User admin : userRepository.findByRole(Role.ADMIN)) {
|
||||||
|
createNotification(admin, NotificationType.POINT_REDEEM, null, null,
|
||||||
|
null, user, null, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public List<NotificationPreferenceDto> listPreferences(String username) {
|
public List<NotificationPreferenceDto> listPreferences(String username) {
|
||||||
User user = userRepository.findByUsername(username)
|
User user = userRepository.findByUsername(username)
|
||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.openisle.service;
|
||||||
|
|
||||||
|
import com.openisle.exception.FieldException;
|
||||||
|
import com.openisle.exception.NotFoundException;
|
||||||
|
import com.openisle.model.PointGood;
|
||||||
|
import com.openisle.model.User;
|
||||||
|
import com.openisle.repository.PointGoodRepository;
|
||||||
|
import com.openisle.repository.UserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Service for point mall operations. */
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PointMallService {
|
||||||
|
private final PointGoodRepository pointGoodRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
|
||||||
|
public List<PointGood> listGoods() {
|
||||||
|
return pointGoodRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int redeem(User user, Long goodId, String contact) {
|
||||||
|
PointGood good = pointGoodRepository.findById(goodId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Good not found"));
|
||||||
|
if (user.getPoint() < good.getCost()) {
|
||||||
|
throw new FieldException("point", "Insufficient points");
|
||||||
|
}
|
||||||
|
user.setPoint(user.getPoint() - good.getCost());
|
||||||
|
userRepository.save(user);
|
||||||
|
notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact);
|
||||||
|
return user.getPoint();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ public class PointService {
|
|||||||
return addPoint(user, 30);
|
return addPoint(user, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int awardForInvite(String userName) {
|
||||||
|
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||||
|
return addPoint(user, 500);
|
||||||
|
}
|
||||||
|
|
||||||
private PointLog getTodayLog(User user) {
|
private PointLog getTodayLog(User user) {
|
||||||
LocalDate today = LocalDate.now();
|
LocalDate today = LocalDate.now();
|
||||||
return pointLogRepository.findByUserAndLogDate(user, today)
|
return pointLogRepository.findByUserAndLogDate(user, today)
|
||||||
|
|||||||
@@ -31,16 +31,15 @@ import com.openisle.service.EmailSender;
|
|||||||
|
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.List;
|
import java.util.*;
|
||||||
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ConcurrentMap;
|
import java.util.concurrent.ConcurrentMap;
|
||||||
import java.util.concurrent.ScheduledFuture;
|
import java.util.concurrent.ScheduledFuture;
|
||||||
@@ -69,6 +68,8 @@ public class PostService {
|
|||||||
private final EmailSender emailSender;
|
private final EmailSender emailSender;
|
||||||
private final ApplicationContext applicationContext;
|
private final ApplicationContext applicationContext;
|
||||||
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
|
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
|
||||||
|
@Value("${app.website-url:https://www.open-isle.com}")
|
||||||
|
private String websiteUrl;
|
||||||
|
|
||||||
@org.springframework.beans.factory.annotation.Autowired
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
public PostService(PostRepository postRepository,
|
public PostService(PostRepository postRepository,
|
||||||
@@ -249,6 +250,15 @@ public class PostService {
|
|||||||
if (w.getEmail() != null) {
|
if (w.getEmail() != null) {
|
||||||
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
|
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) {
|
||||||
|
emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖");
|
||||||
|
}
|
||||||
|
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
|
||||||
|
notificationService.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -556,10 +566,31 @@ public class PostService {
|
|||||||
return postRepository.countByCategory_Id(categoryId);
|
return postRepository.countByCategory_Id(categoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<Long, Long> countPostsByCategoryIds(List<Long> categoryIds) {
|
||||||
|
Map<Long, Long> result = new HashMap<>();
|
||||||
|
var dbResult = postRepository.countPostsByCategoryIds(categoryIds);
|
||||||
|
dbResult.forEach(r -> {
|
||||||
|
result.put(((Long)r[0]), ((Long)r[1]));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public long countPostsByTag(Long tagId) {
|
public long countPostsByTag(Long tagId) {
|
||||||
return postRepository.countDistinctByTags_Id(tagId);
|
return postRepository.countDistinctByTags_Id(tagId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<Long, Long> countPostsByTagIds(List<Long> tagIds) {
|
||||||
|
Map<Long, Long> result = new HashMap<>();
|
||||||
|
if (CollectionUtils.isEmpty(tagIds)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
var dbResult = postRepository.countPostsByTagIds(tagIds);
|
||||||
|
dbResult.forEach(r -> {
|
||||||
|
result.put(((Long)r[0]), ((Long)r[1]));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
|
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
|
||||||
return posts.stream()
|
return posts.stream()
|
||||||
.sorted(java.util.Comparator
|
.sorted(java.util.Comparator
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class ReactionService {
|
|||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final EmailSender emailSender;
|
private final EmailSender emailSender;
|
||||||
|
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ public class TwitterAuthService {
|
|||||||
@Value("${twitter.client-secret:}")
|
@Value("${twitter.client-secret:}")
|
||||||
private String clientSecret;
|
private String clientSecret;
|
||||||
|
|
||||||
public Optional<User> authenticate(
|
public Optional<AuthResult> authenticate(
|
||||||
String code,
|
String code,
|
||||||
String codeVerifier,
|
String codeVerifier,
|
||||||
RegisterMode mode,
|
RegisterMode mode,
|
||||||
String redirectUri) {
|
String redirectUri,
|
||||||
|
boolean viaInvite) {
|
||||||
|
|
||||||
logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier);
|
logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier);
|
||||||
|
|
||||||
@@ -106,10 +107,10 @@ public class TwitterAuthService {
|
|||||||
// Twitter v2 默认拿不到 email;如果你申请到 email.scope,可改用 /2/users/:id?user.fields=email
|
// Twitter v2 默认拿不到 email;如果你申请到 email.scope,可改用 /2/users/:id?user.fields=email
|
||||||
String email = username + "@twitter.com";
|
String email = username + "@twitter.com";
|
||||||
logger.debug("Processing user {} with email {}", username, email);
|
logger.debug("Processing user {} with email {}", username, email);
|
||||||
return Optional.of(processUser(email, username, avatar, mode));
|
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
|
||||||
}
|
}
|
||||||
|
|
||||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||||
Optional<User> existing = userRepository.findByEmail(email);
|
Optional<User> existing = userRepository.findByEmail(email);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
User user = existing.get();
|
User user = existing.get();
|
||||||
@@ -119,7 +120,7 @@ public class TwitterAuthService {
|
|||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
}
|
}
|
||||||
logger.debug("Existing user {} authenticated", user.getUsername());
|
logger.debug("Existing user {} authenticated", user.getUsername());
|
||||||
return user;
|
return new AuthResult(user, false);
|
||||||
}
|
}
|
||||||
String baseUsername = username != null ? username : email.split("@")[0];
|
String baseUsername = username != null ? username : email.split("@")[0];
|
||||||
String finalUsername = baseUsername;
|
String finalUsername = baseUsername;
|
||||||
@@ -133,13 +134,13 @@ public class TwitterAuthService {
|
|||||||
user.setPassword("");
|
user.setPassword("");
|
||||||
user.setRole(Role.USER);
|
user.setRole(Role.USER);
|
||||||
user.setVerified(true);
|
user.setVerified(true);
|
||||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
user.setAvatar(avatar);
|
user.setAvatar(avatar);
|
||||||
} else {
|
} else {
|
||||||
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
|
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
|
||||||
}
|
}
|
||||||
logger.debug("Creating new user {}", finalUsername);
|
logger.debug("Creating new user {}", finalUsername);
|
||||||
return userRepository.save(user);
|
return new AuthResult(userRepository.save(user), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ public class UserService {
|
|||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public User registerWithInvite(String username, String email, String password) {
|
||||||
|
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
|
||||||
|
user.setVerified(true);
|
||||||
|
user.setVerificationCode(null);
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
private String genCode() {
|
private String genCode() {
|
||||||
return String.format("%06d", new Random().nextInt(1000000));
|
return String.format("%06d", new Random().nextInt(1000000));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ spring.jpa.hibernate.ddl-auto=update
|
|||||||
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
||||||
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
|
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
|
||||||
app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec}
|
app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec}
|
||||||
|
app.jwt.invite-secret=${JWT_INVITE_SECRET:jwt_invite_sec}
|
||||||
# 30 days
|
# 30 days
|
||||||
app.jwt.expiration=${JWT_EXPIRATION:2592000000}
|
app.jwt.expiration=${JWT_EXPIRATION:2592000000}
|
||||||
# Password strength: LOW, MEDIUM or HIGH
|
# Password strength: LOW, MEDIUM or HIGH
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE comments ADD COLUMN pinned_at DATETIME(6) NULL;
|
||||||
@@ -144,6 +144,30 @@ class NotificationServiceTest {
|
|||||||
verify(nRepo).save(any(Notification.class));
|
verify(nRepo).save(any(Notification.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPointRedeemNotificationsDeletesOldOnes() {
|
||||||
|
NotificationRepository nRepo = mock(NotificationRepository.class);
|
||||||
|
UserRepository uRepo = mock(UserRepository.class);
|
||||||
|
ReactionRepository rRepo = mock(ReactionRepository.class);
|
||||||
|
EmailSender email = mock(EmailSender.class);
|
||||||
|
PushNotificationService push = mock(PushNotificationService.class);
|
||||||
|
Executor executor = Runnable::run;
|
||||||
|
NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor);
|
||||||
|
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
|
||||||
|
|
||||||
|
User admin = new User();
|
||||||
|
admin.setId(10L);
|
||||||
|
User user = new User();
|
||||||
|
user.setId(20L);
|
||||||
|
|
||||||
|
when(uRepo.findByRole(Role.ADMIN)).thenReturn(List.of(admin));
|
||||||
|
|
||||||
|
service.createPointRedeemNotifications(user, "contact");
|
||||||
|
|
||||||
|
verify(nRepo).deleteByTypeAndFromUser(NotificationType.POINT_REDEEM, user);
|
||||||
|
verify(nRepo).save(any(Notification.class));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createNotificationSendsEmailForCommentReply() {
|
void createNotificationSendsEmailForCommentReply() {
|
||||||
NotificationRepository nRepo = mock(NotificationRepository.class);
|
NotificationRepository nRepo = mock(NotificationRepository.class);
|
||||||
|
|||||||
@@ -93,4 +93,50 @@ class PostServiceTest {
|
|||||||
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
|
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
|
||||||
null, null, null, null, null, null));
|
null, null, null, null, null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finalizeLotteryNotifiesAuthor() {
|
||||||
|
PostRepository postRepo = mock(PostRepository.class);
|
||||||
|
UserRepository userRepo = mock(UserRepository.class);
|
||||||
|
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||||
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
|
CommentService commentService = mock(CommentService.class);
|
||||||
|
CommentRepository commentRepo = mock(CommentRepository.class);
|
||||||
|
ReactionRepository reactionRepo = mock(ReactionRepository.class);
|
||||||
|
PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class);
|
||||||
|
NotificationRepository notificationRepo = mock(NotificationRepository.class);
|
||||||
|
PostReadService postReadService = mock(PostReadService.class);
|
||||||
|
ImageUploader imageUploader = mock(ImageUploader.class);
|
||||||
|
TaskScheduler taskScheduler = mock(TaskScheduler.class);
|
||||||
|
EmailSender emailSender = mock(EmailSender.class);
|
||||||
|
ApplicationContext context = mock(ApplicationContext.class);
|
||||||
|
|
||||||
|
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||||
|
notifService, subService, commentService, commentRepo,
|
||||||
|
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||||
|
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
|
||||||
|
when(context.getBean(PostService.class)).thenReturn(service);
|
||||||
|
|
||||||
|
User author = new User();
|
||||||
|
author.setId(1L);
|
||||||
|
User winner = new User();
|
||||||
|
winner.setId(2L);
|
||||||
|
|
||||||
|
LotteryPost lp = new LotteryPost();
|
||||||
|
lp.setId(1L);
|
||||||
|
lp.setAuthor(author);
|
||||||
|
lp.setTitle("L");
|
||||||
|
lp.setPrizeCount(1);
|
||||||
|
lp.getParticipants().add(winner);
|
||||||
|
|
||||||
|
when(lotteryRepo.findById(1L)).thenReturn(Optional.of(lp));
|
||||||
|
|
||||||
|
service.finalizeLottery(1L);
|
||||||
|
|
||||||
|
verify(notifService).createNotification(eq(winner), eq(NotificationType.LOTTERY_WIN), eq(lp), isNull(), isNull(), eq(author), isNull(), isNull());
|
||||||
|
verify(notifService).createNotification(eq(author), eq(NotificationType.LOTTERY_DRAW), eq(lp), isNull(), isNull(), isNull(), isNull(), isNull());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
frontend_nuxt/.env.example
Normal file
15
frontend_nuxt/.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
; 本地部署后端
|
||||||
|
; NUXT_PUBLIC_API_BASE_URL=https://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=https://staging.open-isle.com
|
||||||
|
; 正式环境/生产环境
|
||||||
|
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.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
|
||||||
16
frontend_nuxt/.env.staging.example
Normal file
16
frontend_nuxt/.env.staging.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
; 本地部署后端
|
||||||
|
; NUXT_PUBLIC_API_BASE_URL=https://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=https://staging.open-isle.com
|
||||||
|
; 正式环境/生产环境
|
||||||
|
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.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
|
||||||
1
frontend_nuxt/.gitignore
vendored
1
frontend_nuxt/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules
|
|||||||
.nuxt
|
.nuxt
|
||||||
dist
|
dist
|
||||||
.output
|
.output
|
||||||
|
.env
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div class="header-container">
|
<div class="header-container">
|
||||||
<HeaderComponent @toggle-menu="menuVisible = !menuVisible" :show-menu-btn="!hideMenu" />
|
<HeaderComponent
|
||||||
|
ref="header"
|
||||||
|
@toggle-menu="menuVisible = !menuVisible"
|
||||||
|
:show-menu-btn="!hideMenu"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
@@ -11,53 +15,77 @@
|
|||||||
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
|
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
|
||||||
<NuxtPage keepalive />
|
<NuxtPage keepalive />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showNewPostIcon && isMobile" class="app-new-post-icon" @click="goToNewPost">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GlobalPopups />
|
<GlobalPopups />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import HeaderComponent from '~/components/HeaderComponent.vue'
|
import HeaderComponent from '~/components/HeaderComponent.vue'
|
||||||
import MenuComponent from '~/components/MenuComponent.vue'
|
import MenuComponent from '~/components/MenuComponent.vue'
|
||||||
|
|
||||||
import GlobalPopups from '~/components/GlobalPopups.vue'
|
import GlobalPopups from '~/components/GlobalPopups.vue'
|
||||||
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
|
||||||
export default {
|
const isMobile = useIsMobile()
|
||||||
name: 'App',
|
const menuVisible = ref(!isMobile.value)
|
||||||
components: { HeaderComponent, MenuComponent, GlobalPopups },
|
|
||||||
setup() {
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const menuVisible = ref(!isMobile.value)
|
|
||||||
const hideMenu = computed(() => {
|
|
||||||
return [
|
|
||||||
'/login',
|
|
||||||
'/signup',
|
|
||||||
'/404',
|
|
||||||
'/signup-reason',
|
|
||||||
'/github-callback',
|
|
||||||
'/twitter-callback',
|
|
||||||
'/discord-callback',
|
|
||||||
'/forgot-password',
|
|
||||||
'/google-callback',
|
|
||||||
].includes(useRoute().path)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
const showNewPostIcon = computed(() => useRoute().path === '/')
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
menuVisible.value = window.innerWidth > 768
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleMenuOutside = () => {
|
const hideMenu = computed(() => {
|
||||||
if (isMobile.value) menuVisible.value = false
|
return [
|
||||||
}
|
'/login',
|
||||||
|
'/signup',
|
||||||
|
'/404',
|
||||||
|
'/signup-reason',
|
||||||
|
'/github-callback',
|
||||||
|
'/twitter-callback',
|
||||||
|
'/discord-callback',
|
||||||
|
'/forgot-password',
|
||||||
|
'/google-callback',
|
||||||
|
].includes(useRoute().path)
|
||||||
|
})
|
||||||
|
|
||||||
return { menuVisible, hideMenu, handleMenuOutside }
|
const header = useTemplateRef('header')
|
||||||
},
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
menuVisible.value = window.innerWidth > 768
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleMenuOutside = (event) => {
|
||||||
|
const btn = header.value.$refs.menuBtn
|
||||||
|
if (btn && (btn === event.target || btn.contains(event.target))) {
|
||||||
|
return // 如果是菜单按钮的点击,不处理关闭
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile.value) {
|
||||||
|
menuVisible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToNewPost = () => {
|
||||||
|
navigateTo('/new-post', { replace: false })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style src="~/assets/global.css"></style>
|
<style src="~/assets/global.css"></style>
|
||||||
<style>
|
<style>
|
||||||
|
/* 页面过渡效果 */
|
||||||
|
.page-enter-active,
|
||||||
|
.page-leave-active {
|
||||||
|
transition: all 0.4s;
|
||||||
|
}
|
||||||
|
.page-enter-from,
|
||||||
|
.page-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
.header-container {
|
.header-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -90,6 +118,24 @@ export default {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-new-post-icon {
|
||||||
|
background-color: var(--new-post-icon-color);
|
||||||
|
color: white;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 40px;
|
||||||
|
right: 20px;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
backdrop-filter: var(--blur-5);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.content,
|
.content,
|
||||||
.content.menu-open {
|
.content.menu-open {
|
||||||
|
|||||||
143
frontend_nuxt/assets/fonts.css
Normal file
143
frontend_nuxt/assets/fonts.css
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/* Maple Mono - Thin 100 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-100-normal.woff2") format("woff2");
|
||||||
|
font-weight: 100;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Thin Italic 100 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-100-italic.woff2") format("woff2");
|
||||||
|
font-weight: 100;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - ExtraLight 200 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-200-normal.woff2") format("woff2");
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - ExtraLight Italic 200 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-200-italic.woff2") format("woff2");
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Light 300 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-300-normal.woff2") format("woff2");
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Light Italic 300 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-300-italic.woff2") format("woff2");
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Regular 400 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-400-normal.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Italic 400 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-400-italic.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Medium 500 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-500-normal.woff2") format("woff2");
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Medium Italic 500 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-500-italic.woff2") format("woff2");
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - SemiBold 600 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-600-normal.woff2") format("woff2");
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - SemiBold Italic 600 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-600-italic.woff2") format("woff2");
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Bold 700 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-700-normal.woff2") format("woff2");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Bold Italic 700 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-700-italic.woff2") format("woff2");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - ExtraBold 800 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-800-normal.woff2") format("woff2");
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - ExtraBold Italic 800 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-800-italic.woff2") format("woff2");
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
@@ -2,27 +2,35 @@
|
|||||||
--primary-color-hover: rgb(9, 95, 105);
|
--primary-color-hover: rgb(9, 95, 105);
|
||||||
--primary-color: rgb(10, 110, 120);
|
--primary-color: rgb(10, 110, 120);
|
||||||
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
||||||
|
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||||
--header-height: 60px;
|
--header-height: 60px;
|
||||||
--header-background-color: white;
|
--header-background-color: white;
|
||||||
--header-border-color: lightgray;
|
--header-border-color: lightgray;
|
||||||
--header-text-color: black;
|
--header-text-color: black;
|
||||||
--menu-background-color: white;
|
--blur-1: blur(1px);
|
||||||
|
--blur-2: blur(2px);
|
||||||
|
--blur-4: blur(4px);
|
||||||
|
--blur-5: blur(5px);
|
||||||
|
--blur-10: blur(10px);
|
||||||
|
/* 加一个app前缀防止与firefox的userChrome.css中的--menu-background-color冲突 */
|
||||||
|
--app-menu-background-color: white;
|
||||||
--background-color: white;
|
--background-color: white;
|
||||||
/* --background-color-blur: rgba(255, 255, 255, 0.57); */
|
--background-color-blur: rgba(255, 255, 255, 0.57);
|
||||||
--background-color-blur: var(--background-color);
|
|
||||||
--menu-border-color: lightgray;
|
--menu-border-color: lightgray;
|
||||||
--normal-border-color: lightgray;
|
--normal-border-color: lightgray;
|
||||||
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
|
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
|
||||||
--menu-text-color: black;
|
--menu-text-color: black;
|
||||||
--scroller-background-color: rgba(130, 175, 180, 0.5);
|
--scroller-background-color: rgba(130, 175, 180, 0.5);
|
||||||
--normal-background-color: rgb(241, 241, 241);
|
/* --normal-background-color: rgb(241, 241, 241); */
|
||||||
|
--normal-background-color: white;
|
||||||
--lottery-background-color: rgb(241, 241, 241);
|
--lottery-background-color: rgb(241, 241, 241);
|
||||||
|
--code-highlight-background-color: rgb(241, 241, 241);
|
||||||
--login-background-color: rgb(248, 248, 248);
|
--login-background-color: rgb(248, 248, 248);
|
||||||
--login-background-color-hover: #e0e0e0;
|
--login-background-color-hover: #e0e0e0;
|
||||||
--text-color: black;
|
--text-color: black;
|
||||||
--blockquote-text-color: #6a737d;
|
--blockquote-text-color: #6a737d;
|
||||||
--menu-width: 200px;
|
--menu-width: 200px;
|
||||||
--page-max-width: 1200px;
|
--page-max-width: 1400px;
|
||||||
--page-max-width-mobile: 900px;
|
--page-max-width-mobile: 900px;
|
||||||
--article-info-background-color: #f0f0f0;
|
--article-info-background-color: #f0f0f0;
|
||||||
--activity-card-background-color: #fafafa;
|
--activity-card-background-color: #fafafa;
|
||||||
@@ -33,8 +41,9 @@
|
|||||||
--header-border-color: #555;
|
--header-border-color: #555;
|
||||||
--primary-color: rgb(17, 182, 197);
|
--primary-color: rgb(17, 182, 197);
|
||||||
--primary-color-hover: rgb(13, 137, 151);
|
--primary-color-hover: rgb(13, 137, 151);
|
||||||
|
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||||
--header-text-color: white;
|
--header-text-color: white;
|
||||||
--menu-background-color: #333;
|
--app-menu-background-color: #333;
|
||||||
--background-color: #333;
|
--background-color: #333;
|
||||||
/* --background-color-blur: #333333a4; */
|
/* --background-color-blur: #333333a4; */
|
||||||
--background-color-blur: var(--background-color);
|
--background-color-blur: var(--background-color);
|
||||||
@@ -42,8 +51,10 @@
|
|||||||
--normal-border-color: #555;
|
--normal-border-color: #555;
|
||||||
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
|
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
|
||||||
--menu-text-color: white;
|
--menu-text-color: white;
|
||||||
--normal-background-color: #000000;
|
/* --normal-background-color: #000000; */
|
||||||
|
--normal-background-color: #333;
|
||||||
--lottery-background-color: #4e4e4e;
|
--lottery-background-color: #4e4e4e;
|
||||||
|
--code-highlight-background-color: #262b35;
|
||||||
--login-background-color: #575757;
|
--login-background-color: #575757;
|
||||||
--login-background-color-hover: #717171;
|
--login-background-color-hover: #717171;
|
||||||
--text-color: #eee;
|
--text-color: #eee;
|
||||||
@@ -52,6 +63,15 @@
|
|||||||
--activity-card-background-color: #585858;
|
--activity-card-background-color: #585858;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-frosted='off'] {
|
||||||
|
--blur-1: none;
|
||||||
|
--blur-2: none;
|
||||||
|
--blur-4: none;
|
||||||
|
--blur-5: none;
|
||||||
|
--blur-10: none;
|
||||||
|
--background-color-blur: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -131,13 +151,43 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text pre {
|
.info-content-text pre {
|
||||||
background-color: var(--normal-background-color);
|
display: flex;
|
||||||
|
background-color: var(--code-highlight-background-color);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-content-text pre .line-numbers {
|
||||||
|
counter-reset: line-number 0;
|
||||||
|
width: 2em;
|
||||||
|
font-size: 13px;
|
||||||
|
position: sticky;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: 'Maple Mono', monospace;
|
||||||
|
margin: 1em 0;
|
||||||
|
color: #888;
|
||||||
|
border-right: 1px solid #888;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-right: 0.5em;
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text pre .line-numbers .line-number::before {
|
||||||
|
content: counter(line-number);
|
||||||
|
counter-increment: line-number;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text code {
|
||||||
|
font-family: 'Maple Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: no-wrap;
|
||||||
|
background-color: var(--code-highlight-background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
.copy-code-btn {
|
.copy-code-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
@@ -156,20 +206,13 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text code {
|
.about-content a,
|
||||||
font-family: 'Roboto Mono', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
border-radius: 4px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
background-color: var(--normal-background-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-content-text a {
|
.info-content-text a {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-content a:hover,
|
||||||
.info-content-text a:hover {
|
.info-content-text a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
@@ -267,7 +310,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text pre {
|
.info-content-text pre {
|
||||||
line-height: 1.1;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vditor-panel {
|
.vditor-panel {
|
||||||
@@ -276,6 +319,29 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Transition API */
|
||||||
|
::view-transition-old(root),
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: none;
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(root) {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-new(root) {
|
||||||
|
z-index: 2147483646;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark']::view-transition-old(root) {
|
||||||
|
z-index: 2147483646;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark']::view-transition-new(root) {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* NProgress styles */
|
/* NProgress styles */
|
||||||
#nprogress {
|
#nprogress {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@@ -37,8 +37,10 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { getToken } from '~/utils/auth'
|
import { getToken } from '~/utils/auth'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
medals: {
|
medals: {
|
||||||
|
|||||||
@@ -11,29 +11,20 @@
|
|||||||
</BasePopup>
|
</BasePopup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import BasePopup from '~/components/BasePopup.vue'
|
import BasePopup from '~/components/BasePopup.vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: 'ActivityPopup',
|
visible: { type: Boolean, default: false },
|
||||||
components: { BasePopup },
|
icon: String,
|
||||||
props: {
|
text: String,
|
||||||
visible: { type: Boolean, default: false },
|
})
|
||||||
icon: String,
|
const emit = defineEmits(['close'])
|
||||||
text: String,
|
const gotoActivity = async () => {
|
||||||
},
|
emit('close')
|
||||||
emits: ['close'],
|
await navigateTo('/activities', { replace: true })
|
||||||
setup(props, { emit }) {
|
|
||||||
const router = useRouter()
|
|
||||||
const gotoActivity = () => {
|
|
||||||
emit('close')
|
|
||||||
router.push('/activities')
|
|
||||||
}
|
|
||||||
const close = () => emit('close')
|
|
||||||
return { gotoActivity, close }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
const close = () => emit('close')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -12,25 +12,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { useRouter } from 'vue-router'
|
const props = defineProps({
|
||||||
|
category: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
export default {
|
const gotoCategory = async () => {
|
||||||
name: 'ArticleCategory',
|
if (!props.category) return
|
||||||
props: {
|
const value = encodeURIComponent(props.category.id ?? props.category.name)
|
||||||
category: { type: Object, default: null },
|
await navigateTo({ path: '/', query: { category: value } }, { replace: true })
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const router = useRouter()
|
|
||||||
const gotoCategory = () => {
|
|
||||||
if (!props.category) return
|
|
||||||
const value = encodeURIComponent(props.category.id ?? props.category.name)
|
|
||||||
router.push({ path: '/', query: { category: value } }).then(() => {
|
|
||||||
window.location.reload()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return { gotoCategory }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -17,24 +17,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { useRouter } from 'vue-router'
|
defineProps({
|
||||||
|
tags: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
export default {
|
const gotoTag = async (tag) => {
|
||||||
name: 'ArticleTags',
|
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||||
props: {
|
await navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||||
tags: { type: Array, default: () => [] },
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const router = useRouter()
|
|
||||||
const gotoTag = (tag) => {
|
|
||||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
|
||||||
router.push({ path: '/', query: { tags: value } }).then(() => {
|
|
||||||
window.location.reload()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return { gotoTag }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export default {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: var(--blur-2);
|
||||||
-webkit-backdrop-filter: blur(2px);
|
-webkit-backdrop-filter: var(--blur-2);
|
||||||
}
|
}
|
||||||
.popup-content {
|
.popup-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -26,49 +26,43 @@
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: 'CategorySelect',
|
modelValue: { type: [String, Number], default: '' },
|
||||||
components: { Dropdown },
|
options: { type: Array, default: () => [] },
|
||||||
props: {
|
})
|
||||||
modelValue: { type: [String, Number], default: '' },
|
|
||||||
options: { type: Array, default: () => [] },
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||||
|
watch(
|
||||||
|
() => props.options,
|
||||||
|
(val) => {
|
||||||
|
providedOptions.value = Array.isArray(val) ? [...val] : []
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
)
|
||||||
setup(props, { emit }) {
|
|
||||||
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
|
|
||||||
|
|
||||||
watch(
|
const fetchCategories = async () => {
|
||||||
() => props.options,
|
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
||||||
(val) => {
|
if (!res.ok) return []
|
||||||
providedOptions.value = Array.isArray(val) ? [...val] : []
|
const data = await res.json()
|
||||||
},
|
return [{ id: '', name: '无分类' }, ...data]
|
||||||
)
|
|
||||||
|
|
||||||
const fetchCategories = async () => {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
|
||||||
if (!res.ok) return []
|
|
||||||
const data = await res.json()
|
|
||||||
return [{ id: '', name: '无分类' }, ...data]
|
|
||||||
}
|
|
||||||
|
|
||||||
const isImageIcon = (icon) => {
|
|
||||||
if (!icon) return false
|
|
||||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (v) => emit('update:modelValue', v),
|
|
||||||
})
|
|
||||||
|
|
||||||
return { fetchCategories, selected, isImageIcon, providedOptions }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isImageIcon = (icon) => {
|
||||||
|
if (!icon) return false
|
||||||
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
:to="`/users/${comment.userId}?tab=achievements`"
|
:to="`/users/${comment.userId}?tab=achievements`"
|
||||||
>{{ getMedalTitle(comment.medal) }}</router-link
|
>{{ getMedalTitle(comment.medal) }}</router-link
|
||||||
>
|
>
|
||||||
|
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
|
||||||
<span v-if="level >= 2">
|
<span v-if="level >= 2">
|
||||||
<i class="fas fa-reply reply-icon"></i>
|
<i class="fas fa-reply reply-icon"></i>
|
||||||
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
|
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
:comment="item"
|
:comment="item"
|
||||||
:level="level + 1"
|
:level="level + 1"
|
||||||
:default-show-replies="item.openReplies"
|
:default-show-replies="item.openReplies"
|
||||||
|
:post-author-id="postAuthorId"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</BaseTimeline>
|
</BaseTimeline>
|
||||||
@@ -88,11 +90,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import VueEasyLightbox from 'vue-easy-lightbox'
|
import VueEasyLightbox from 'vue-easy-lightbox'
|
||||||
import { useRouter } from 'vue-router'
|
import { toast } from '~/main'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
||||||
import { getMedalTitle } from '~/utils/medal'
|
import { getMedalTitle } from '~/utils/medal'
|
||||||
@@ -100,214 +101,236 @@ import TimeManager from '~/utils/time'
|
|||||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||||
import CommentEditor from '~/components/CommentEditor.vue'
|
import CommentEditor from '~/components/CommentEditor.vue'
|
||||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
|
||||||
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
const CommentItem = {
|
const props = defineProps({
|
||||||
name: 'CommentItem',
|
comment: {
|
||||||
emits: ['deleted'],
|
type: Object,
|
||||||
props: {
|
required: true,
|
||||||
comment: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
level: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
defaultShowReplies: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
level: {
|
||||||
const router = useRouter()
|
type: Number,
|
||||||
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
|
default: 0,
|
||||||
watch(
|
},
|
||||||
() => props.defaultShowReplies,
|
defaultShowReplies: {
|
||||||
(val) => {
|
type: Boolean,
|
||||||
showReplies.value = props.level === 0 ? true : val
|
default: false,
|
||||||
},
|
},
|
||||||
)
|
postAuthorId: {
|
||||||
const showEditor = ref(false)
|
type: [Number, String],
|
||||||
const editorWrapper = ref(null)
|
required: true,
|
||||||
const isWaitingForReply = ref(false)
|
},
|
||||||
const lightboxVisible = ref(false)
|
})
|
||||||
const lightboxIndex = ref(0)
|
|
||||||
const lightboxImgs = ref([])
|
|
||||||
const loggedIn = computed(() => authState.loggedIn)
|
|
||||||
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
|
||||||
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
|
||||||
const toggleReplies = () => {
|
|
||||||
showReplies.value = !showReplies.value
|
|
||||||
}
|
|
||||||
const toggleEditor = () => {
|
|
||||||
showEditor.value = !showEditor.value
|
|
||||||
if (showEditor.value) {
|
|
||||||
setTimeout(() => {
|
|
||||||
editorWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并所有子回复为一个扁平数组
|
const emit = defineEmits(['deleted'])
|
||||||
const flattenReplies = (list) => {
|
|
||||||
let result = []
|
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
|
||||||
for (const r of list) {
|
watch(
|
||||||
result.push(r)
|
() => props.defaultShowReplies,
|
||||||
if (r.reply && r.reply.length > 0) {
|
(val) => {
|
||||||
result = result.concat(flattenReplies(r.reply))
|
showReplies.value = props.level === 0 ? true : val
|
||||||
}
|
},
|
||||||
}
|
)
|
||||||
return result
|
const showEditor = ref(false)
|
||||||
|
const editorWrapper = ref(null)
|
||||||
|
const isWaitingForReply = ref(false)
|
||||||
|
const lightboxVisible = ref(false)
|
||||||
|
const lightboxIndex = ref(0)
|
||||||
|
const lightboxImgs = ref([])
|
||||||
|
const loggedIn = computed(() => authState.loggedIn)
|
||||||
|
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
||||||
|
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
||||||
|
|
||||||
|
const toggleReplies = () => {
|
||||||
|
showReplies.value = !showReplies.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleEditor = () => {
|
||||||
|
showEditor.value = !showEditor.value
|
||||||
|
if (showEditor.value) {
|
||||||
|
setTimeout(() => {
|
||||||
|
editorWrapper.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flattenReplies = (list) => {
|
||||||
|
let result = []
|
||||||
|
for (const r of list) {
|
||||||
|
result.push(r)
|
||||||
|
if (r.reply && r.reply.length > 0) {
|
||||||
|
result = result.concat(flattenReplies(r.reply))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
const replyList = computed(() => {
|
const replyList = computed(() => {
|
||||||
if (props.level < 1) {
|
if (props.level < 1) {
|
||||||
return props.comment.reply
|
return props.comment.reply
|
||||||
}
|
}
|
||||||
|
|
||||||
return flattenReplies(props.comment.reply || [])
|
return flattenReplies(props.comment.reply || [])
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAuthor = computed(() => authState.username === props.comment.userName)
|
||||||
|
const isPostAuthor = computed(() => Number(authState.userId) === Number(props.postAuthorId))
|
||||||
|
const isAdmin = computed(() => authState.role === 'ADMIN')
|
||||||
|
const commentMenuItems = computed(() => {
|
||||||
|
const items = []
|
||||||
|
if (isAuthor.value || isAdmin.value) {
|
||||||
|
items.push({ text: '删除评论', color: 'red', onClick: () => deleteComment() })
|
||||||
|
}
|
||||||
|
if (isAdmin.value || isPostAuthor.value) {
|
||||||
|
if (props.comment.pinned) {
|
||||||
|
items.push({ text: '取消置顶', onClick: () => unpinComment() })
|
||||||
|
} else {
|
||||||
|
items.push({ text: '置顶', onClick: () => pinComment() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
const deleteComment = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.debug('Deleting comment', props.comment.id)
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
console.debug('Delete comment response status', res.status)
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('已删除')
|
||||||
|
emit('deleted', props.comment.id)
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const submitReply = async (parentUserName, text, clear) => {
|
||||||
|
if (!text.trim()) return
|
||||||
|
isWaitingForReply.value = true
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
isWaitingForReply.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.debug('Submitting reply', { parentId: props.comment.id, text })
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}/replies`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ content: text }),
|
||||||
})
|
})
|
||||||
|
console.debug('Submit reply response status', res.status)
|
||||||
const isAuthor = computed(() => authState.username === props.comment.userName)
|
if (res.ok) {
|
||||||
const isAdmin = computed(() => authState.role === 'ADMIN')
|
const data = await res.json()
|
||||||
const commentMenuItems = computed(() =>
|
console.debug('Submit reply response data', data)
|
||||||
isAuthor.value || isAdmin.value
|
const replyList = props.comment.reply || (props.comment.reply = [])
|
||||||
? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }]
|
replyList.push({
|
||||||
: [],
|
id: data.id,
|
||||||
)
|
userName: data.author.username,
|
||||||
const deleteComment = async () => {
|
time: TimeManager.format(data.createdAt),
|
||||||
const token = getToken()
|
avatar: data.author.avatar,
|
||||||
if (!token) {
|
medal: data.author.displayMedal,
|
||||||
toast.error('请先登录')
|
text: data.content,
|
||||||
return
|
parentUserName: parentUserName,
|
||||||
}
|
reactions: [],
|
||||||
console.debug('Deleting comment', props.comment.id)
|
reply: (data.replies || []).map((r) => ({
|
||||||
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}`, {
|
id: r.id,
|
||||||
method: 'DELETE',
|
userName: r.author.username,
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
time: TimeManager.format(r.createdAt),
|
||||||
|
avatar: r.author.avatar,
|
||||||
|
text: r.content,
|
||||||
|
reactions: r.reactions || [],
|
||||||
|
reply: [],
|
||||||
|
openReplies: false,
|
||||||
|
src: r.author.avatar,
|
||||||
|
iconClick: () => navigateTo(`/users/${r.author.id}`),
|
||||||
|
})),
|
||||||
|
openReplies: false,
|
||||||
|
src: data.author.avatar,
|
||||||
|
iconClick: () => navigateTo(`/users/${data.author.id}`),
|
||||||
})
|
})
|
||||||
console.debug('Delete comment response status', res.status)
|
clear()
|
||||||
if (res.ok) {
|
showEditor.value = false
|
||||||
toast.success('已删除')
|
toast.success('回复成功')
|
||||||
emit('deleted', props.comment.id)
|
} else if (res.status === 429) {
|
||||||
} else {
|
toast.error('回复过于频繁,请稍后再试')
|
||||||
toast.error('操作失败')
|
} else {
|
||||||
}
|
toast.error(`回复失败: ${res.status} ${res.statusText}`)
|
||||||
}
|
}
|
||||||
const submitReply = async (parentUserName, text, clear) => {
|
} catch (e) {
|
||||||
if (!text.trim()) return
|
console.debug('Submit reply error', e)
|
||||||
isWaitingForReply.value = true
|
toast.error(`回复失败: ${e.message}`)
|
||||||
const token = getToken()
|
} finally {
|
||||||
if (!token) {
|
isWaitingForReply.value = false
|
||||||
toast.error('请先登录')
|
}
|
||||||
isWaitingForReply.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.debug('Submitting reply', { parentId: props.comment.id, text })
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/comments/${props.comment.id}/replies`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
||||||
body: JSON.stringify({ content: text }),
|
|
||||||
})
|
|
||||||
console.debug('Submit reply response status', res.status)
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
console.debug('Submit reply response data', data)
|
|
||||||
const replyList = props.comment.reply || (props.comment.reply = [])
|
|
||||||
replyList.push({
|
|
||||||
id: data.id,
|
|
||||||
userName: data.author.username,
|
|
||||||
time: TimeManager.format(data.createdAt),
|
|
||||||
avatar: data.author.avatar,
|
|
||||||
medal: data.author.displayMedal,
|
|
||||||
text: data.content,
|
|
||||||
parentUserName: parentUserName,
|
|
||||||
reactions: [],
|
|
||||||
reply: (data.replies || []).map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
userName: r.author.username,
|
|
||||||
time: TimeManager.format(r.createdAt),
|
|
||||||
avatar: r.author.avatar,
|
|
||||||
text: r.content,
|
|
||||||
reactions: r.reactions || [],
|
|
||||||
reply: [],
|
|
||||||
openReplies: false,
|
|
||||||
src: r.author.avatar,
|
|
||||||
iconClick: () => router.push(`/users/${r.author.id}`),
|
|
||||||
})),
|
|
||||||
openReplies: false,
|
|
||||||
src: data.author.avatar,
|
|
||||||
iconClick: () => router.push(`/users/${data.author.id}`),
|
|
||||||
})
|
|
||||||
clear()
|
|
||||||
showEditor.value = false
|
|
||||||
toast.success('回复成功')
|
|
||||||
} else if (res.status === 429) {
|
|
||||||
toast.error('回复过于频繁,请稍后再试')
|
|
||||||
} else {
|
|
||||||
toast.error(`回复失败: ${res.status} ${res.statusText}`)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.debug('Submit reply error', e)
|
|
||||||
toast.error(`回复失败: ${e.message}`)
|
|
||||||
} finally {
|
|
||||||
isWaitingForReply.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const copyCommentLink = () => {
|
|
||||||
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
|
|
||||||
navigator.clipboard.writeText(link).then(() => {
|
|
||||||
toast.success('已复制')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const handleContentClick = (e) => {
|
|
||||||
handleMarkdownClick(e)
|
|
||||||
if (e.target.tagName === 'IMG') {
|
|
||||||
const container = e.target.parentNode
|
|
||||||
const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
|
|
||||||
lightboxImgs.value = imgs
|
|
||||||
lightboxIndex.value = imgs.indexOf(e.target.src)
|
|
||||||
lightboxVisible.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
showReplies,
|
|
||||||
toggleReplies,
|
|
||||||
showEditor,
|
|
||||||
toggleEditor,
|
|
||||||
submitReply,
|
|
||||||
copyCommentLink,
|
|
||||||
renderMarkdown,
|
|
||||||
isWaitingForReply,
|
|
||||||
commentMenuItems,
|
|
||||||
deleteComment,
|
|
||||||
lightboxVisible,
|
|
||||||
lightboxIndex,
|
|
||||||
lightboxImgs,
|
|
||||||
handleContentClick,
|
|
||||||
loggedIn,
|
|
||||||
replyCount,
|
|
||||||
replyList,
|
|
||||||
getMedalTitle,
|
|
||||||
editorWrapper,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CommentItem.components = {
|
const pinComment = async () => {
|
||||||
CommentItem,
|
const token = getToken()
|
||||||
CommentEditor,
|
if (!token) {
|
||||||
BaseTimeline,
|
toast.error('请先登录')
|
||||||
ReactionsGroup,
|
return
|
||||||
DropdownMenu,
|
}
|
||||||
VueEasyLightbox,
|
const url = isAdmin.value
|
||||||
LoginOverlay,
|
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/pin`
|
||||||
|
: `${API_BASE_URL}/api/comments/${props.comment.id}/pin`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
props.comment.pinned = true
|
||||||
|
toast.success('已置顶')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unpinComment = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = isAdmin.value
|
||||||
|
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/unpin`
|
||||||
|
: `${API_BASE_URL}/api/comments/${props.comment.id}/unpin`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
props.comment.pinned = false
|
||||||
|
toast.success('已取消置顶')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CommentItem
|
const copyCommentLink = () => {
|
||||||
|
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
|
||||||
|
navigator.clipboard.writeText(link).then(() => {
|
||||||
|
toast.success('已复制')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContentClick = (e) => {
|
||||||
|
handleMarkdownClick(e)
|
||||||
|
if (e.target.tagName === 'IMG') {
|
||||||
|
const container = e.target.parentNode
|
||||||
|
const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
|
||||||
|
lightboxImgs.value = imgs
|
||||||
|
lightboxIndex.value = imgs.indexOf(e.target.src)
|
||||||
|
lightboxVisible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -370,6 +393,12 @@ export default CommentItem
|
|||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pin-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 10px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes highlight {
|
@keyframes highlight {
|
||||||
from {
|
from {
|
||||||
background-color: yellow;
|
background-color: yellow;
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -312,7 +312,7 @@ export default {
|
|||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ export default {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
z-index: 1300;
|
z-index: 1300;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -3,22 +3,24 @@
|
|||||||
<div class="dropdown-trigger" @click="toggle">
|
<div class="dropdown-trigger" @click="toggle">
|
||||||
<slot name="trigger"></slot>
|
<slot name="trigger"></slot>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="visible" class="dropdown-menu-container">
|
<Transition name="dropdown-menu">
|
||||||
<div
|
<div v-if="visible" class="dropdown-menu-container">
|
||||||
v-for="(item, idx) in items"
|
<div
|
||||||
:key="idx"
|
v-for="(item, idx) in items"
|
||||||
class="dropdown-item"
|
:key="idx"
|
||||||
:style="{ color: item.color || 'inherit' }"
|
class="dropdown-item"
|
||||||
@click="handle(item)"
|
:style="{ color: item.color || 'inherit' }"
|
||||||
>
|
@click="handle(item)"
|
||||||
{{ item.text }}
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
export default {
|
export default {
|
||||||
name: 'DropdownMenu',
|
name: 'DropdownMenu',
|
||||||
props: {
|
props: {
|
||||||
@@ -61,17 +63,28 @@ export default {
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-trigger {
|
.dropdown-trigger {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu-enter-active,
|
||||||
|
.dropdown-menu-leave-active {
|
||||||
|
transition: all 0.4s;
|
||||||
|
}
|
||||||
|
.dropdown-menu-enter-from,
|
||||||
|
.dropdown-menu-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-16px);
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-menu-container {
|
.dropdown-menu-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -82,7 +95,9 @@ export default {
|
|||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item:hover {
|
.dropdown-item:hover {
|
||||||
background-color: var(--menu-selected-background-color);
|
background-color: var(--menu-selected-background-color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,95 +11,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import ActivityPopup from '~/components/ActivityPopup.vue'
|
import ActivityPopup from '~/components/ActivityPopup.vue'
|
||||||
import MedalPopup from '~/components/MedalPopup.vue'
|
import MedalPopup from '~/components/MedalPopup.vue'
|
||||||
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
|
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
|
||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import { authState } from '~/utils/auth'
|
import { authState } from '~/utils/auth'
|
||||||
|
|
||||||
export default {
|
const config = useRuntimeConfig()
|
||||||
name: 'GlobalPopups',
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
components: { ActivityPopup, MedalPopup, NotificationSettingPopup },
|
|
||||||
data() {
|
const showMilkTeaPopup = ref(false)
|
||||||
return {
|
const milkTeaIcon = ref('')
|
||||||
showMilkTeaPopup: false,
|
const showNotificationPopup = ref(false)
|
||||||
milkTeaIcon: '',
|
const showMedalPopup = ref(false)
|
||||||
showNotificationPopup: false,
|
const newMedals = ref([])
|
||||||
showMedalPopup: false,
|
|
||||||
newMedals: [],
|
onMounted(async () => {
|
||||||
|
await checkMilkTeaActivity()
|
||||||
|
if (showMilkTeaPopup.value) return
|
||||||
|
|
||||||
|
await checkNotificationSetting()
|
||||||
|
if (showNotificationPopup.value) return
|
||||||
|
|
||||||
|
await checkNewMedals()
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkMilkTeaActivity = async () => {
|
||||||
|
if (!process.client) return
|
||||||
|
if (localStorage.getItem('milkTeaActivityPopupShown')) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||||
|
if (res.ok) {
|
||||||
|
const list = await res.json()
|
||||||
|
const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended)
|
||||||
|
if (a) {
|
||||||
|
milkTeaIcon.value = a.icon
|
||||||
|
showMilkTeaPopup.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
async mounted() {
|
// ignore network errors
|
||||||
await this.checkMilkTeaActivity()
|
}
|
||||||
if (this.showMilkTeaPopup) return
|
}
|
||||||
|
const closeMilkTeaPopup = () => {
|
||||||
await this.checkNotificationSetting()
|
if (!process.client) return
|
||||||
if (this.showNotificationPopup) return
|
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||||
|
showMilkTeaPopup.value = false
|
||||||
await this.checkNewMedals()
|
checkNotificationSetting()
|
||||||
},
|
}
|
||||||
methods: {
|
const checkNotificationSetting = async () => {
|
||||||
async checkMilkTeaActivity() {
|
if (!process.client) return
|
||||||
if (!process.client) return
|
if (!authState.loggedIn) return
|
||||||
if (localStorage.getItem('milkTeaActivityPopupShown')) return
|
if (localStorage.getItem('notificationSettingPopupShown')) return
|
||||||
try {
|
showNotificationPopup.value = true
|
||||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
}
|
||||||
if (res.ok) {
|
const closeNotificationPopup = () => {
|
||||||
const list = await res.json()
|
if (!process.client) return
|
||||||
const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended)
|
localStorage.setItem('notificationSettingPopupShown', 'true')
|
||||||
if (a) {
|
showNotificationPopup.value = false
|
||||||
this.milkTeaIcon = a.icon
|
checkNewMedals()
|
||||||
this.showMilkTeaPopup = true
|
}
|
||||||
}
|
const checkNewMedals = async () => {
|
||||||
}
|
if (!process.client) return
|
||||||
} catch (e) {
|
if (!authState.loggedIn || !authState.userId) return
|
||||||
// ignore network errors
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const medals = await res.json()
|
||||||
|
const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]')
|
||||||
|
const m = medals.filter((i) => i.completed && !seen.includes(i.type))
|
||||||
|
if (m.length > 0) {
|
||||||
|
newMedals.value = m
|
||||||
|
showMedalPopup.value = true
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
closeMilkTeaPopup() {
|
} catch (e) {
|
||||||
if (!process.client) return
|
// ignore errors
|
||||||
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
}
|
||||||
this.showMilkTeaPopup = false
|
}
|
||||||
this.checkNotificationSetting()
|
const closeMedalPopup = () => {
|
||||||
},
|
if (!process.client) return
|
||||||
async checkNotificationSetting() {
|
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
|
||||||
if (!process.client) return
|
newMedals.value.forEach((m) => seen.add(m.type))
|
||||||
if (!authState.loggedIn) return
|
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
|
||||||
if (localStorage.getItem('notificationSettingPopupShown')) return
|
showMedalPopup.value = false
|
||||||
this.showNotificationPopup = true
|
|
||||||
},
|
|
||||||
closeNotificationPopup() {
|
|
||||||
if (!process.client) return
|
|
||||||
localStorage.setItem('notificationSettingPopupShown', 'true')
|
|
||||||
this.showNotificationPopup = false
|
|
||||||
this.checkNewMedals()
|
|
||||||
},
|
|
||||||
async checkNewMedals() {
|
|
||||||
if (!process.client) return
|
|
||||||
if (!authState.loggedIn || !authState.userId) return
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
|
|
||||||
if (res.ok) {
|
|
||||||
const medals = await res.json()
|
|
||||||
const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]')
|
|
||||||
const m = medals.filter((i) => i.completed && !seen.includes(i.type))
|
|
||||||
if (m.length > 0) {
|
|
||||||
this.newMedals = m
|
|
||||||
this.showMedalPopup = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// ignore errors
|
|
||||||
}
|
|
||||||
},
|
|
||||||
closeMedalPopup() {
|
|
||||||
if (!process.client) return
|
|
||||||
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
|
|
||||||
this.newMedals.forEach((m) => seen.add(m.type))
|
|
||||||
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
|
|
||||||
this.showMedalPopup = false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="header-content-left">
|
<div class="header-content-left">
|
||||||
<div v-if="showMenuBtn" class="menu-btn-wrapper">
|
<div v-if="showMenuBtn" class="menu-btn-wrapper">
|
||||||
<button class="menu-btn" @click="$emit('toggle-menu')">
|
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
|
||||||
<i class="fas fa-bars"></i>
|
<i class="fas fa-bars"></i>
|
||||||
</button>
|
</button>
|
||||||
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="logo-container" @click="goToHome">
|
<NuxtLink class="logo-container" :to="`/`" @click="refrechData">
|
||||||
<img
|
<img
|
||||||
alt="OpenIsle"
|
alt="OpenIsle"
|
||||||
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
||||||
@@ -16,15 +16,26 @@
|
|||||||
height="60"
|
height="60"
|
||||||
/>
|
/>
|
||||||
<div class="logo-text">OpenIsle</div>
|
<div class="logo-text">OpenIsle</div>
|
||||||
</div>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div v-if="isLogin" class="header-content-right">
|
<div class="header-content-right">
|
||||||
<div v-if="isMobile" class="search-icon" @click="search">
|
<div v-if="isMobile" class="search-icon" @click="search">
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search"></i>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
|
||||||
|
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
|
||||||
|
<i :class="iconClass"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
|
||||||
|
<div class="new-post-icon" @click="goToNewPost">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</div>
|
||||||
|
</ToolTip>
|
||||||
|
|
||||||
|
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<div class="avatar-container">
|
<div class="avatar-container">
|
||||||
<img class="avatar-img" :src="avatar" alt="avatar" />
|
<img class="avatar-img" :src="avatar" alt="avatar" />
|
||||||
@@ -32,14 +43,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="header-content-right">
|
<div v-if="!isLogin" class="auth-btns">
|
||||||
<div v-if="isMobile" class="search-icon" @click="search">
|
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
||||||
<i class="fas fa-search"></i>
|
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
|
||||||
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
||||||
@@ -48,154 +56,139 @@
|
|||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
|
||||||
import { watch, nextTick, ref, computed } from 'vue'
|
|
||||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
|
||||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
|
||||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
|
||||||
import { useIsMobile } from '~/utils/screen'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { ClientOnly } from '#components'
|
import { ClientOnly } from '#components'
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||||
|
import ToolTip from '~/components/ToolTip.vue'
|
||||||
|
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||||
|
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||||
|
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||||
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: 'HeaderComponent',
|
showMenuBtn: {
|
||||||
components: { DropdownMenu, SearchDropdown },
|
type: Boolean,
|
||||||
props: {
|
default: true,
|
||||||
showMenuBtn: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
setup() {
|
})
|
||||||
const isLogin = computed(() => authState.loggedIn)
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const unreadCount = computed(() => notificationState.unreadCount)
|
|
||||||
const router = useRouter()
|
|
||||||
const avatar = ref('')
|
|
||||||
const showSearch = ref(false)
|
|
||||||
const searchDropdown = ref(null)
|
|
||||||
const userMenu = ref(null)
|
|
||||||
|
|
||||||
const goToHome = () => {
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
router.push('/').then(() => {
|
const isMobile = useIsMobile()
|
||||||
window.location.reload()
|
const unreadCount = computed(() => notificationState.unreadCount)
|
||||||
})
|
const avatar = ref('')
|
||||||
}
|
const showSearch = ref(false)
|
||||||
const search = () => {
|
const searchDropdown = ref(null)
|
||||||
showSearch.value = true
|
const userMenu = ref(null)
|
||||||
nextTick(() => {
|
const menuBtn = ref(null)
|
||||||
searchDropdown.value.toggle()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const closeSearch = () => {
|
|
||||||
nextTick(() => {
|
|
||||||
showSearch.value = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const goToLogin = () => {
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
const goToSettings = () => {
|
|
||||||
router.push('/settings')
|
|
||||||
}
|
|
||||||
const goToProfile = async () => {
|
|
||||||
if (!authState.loggedIn) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let id = authState.username || authState.userId
|
|
||||||
if (!id) {
|
|
||||||
const user = await loadCurrentUser()
|
|
||||||
if (user) {
|
|
||||||
id = user.username || user.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (id) {
|
|
||||||
router.push(`/users/${id}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const goToSignup = () => {
|
|
||||||
router.push('/signup')
|
|
||||||
}
|
|
||||||
const goToLogout = () => {
|
|
||||||
clearToken()
|
|
||||||
this.$router.push('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerMenuItems = computed(() => [
|
const search = () => {
|
||||||
{ text: '设置', onClick: goToSettings },
|
showSearch.value = true
|
||||||
{ text: '个人主页', onClick: goToProfile },
|
nextTick(() => {
|
||||||
{ text: '退出', onClick: goToLogout },
|
searchDropdown.value.toggle()
|
||||||
])
|
})
|
||||||
|
}
|
||||||
|
const closeSearch = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
showSearch.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const goToLogin = () => {
|
||||||
|
navigateTo('/login', { replace: true })
|
||||||
|
}
|
||||||
|
const goToSettings = () => {
|
||||||
|
navigateTo('/settings', { replace: true })
|
||||||
|
}
|
||||||
|
const goToProfile = async () => {
|
||||||
|
if (!authState.loggedIn) {
|
||||||
|
navigateTo('/login', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let id = authState.username || authState.userId
|
||||||
|
if (!id) {
|
||||||
|
const user = await loadCurrentUser()
|
||||||
|
if (user) {
|
||||||
|
id = user.username || user.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (id) {
|
||||||
|
navigateTo(`/users/${id}`, { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const goToSignup = () => {
|
||||||
|
navigateTo('/signup', { replace: true })
|
||||||
|
}
|
||||||
|
const goToLogout = () => {
|
||||||
|
clearToken()
|
||||||
|
navigateTo('/login', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
const goToNewPost = () => {
|
||||||
const updateAvatar = async () => {
|
navigateTo('/new-post', { replace: false })
|
||||||
if (authState.loggedIn) {
|
}
|
||||||
const user = await loadCurrentUser()
|
|
||||||
if (user && user.avatar) {
|
|
||||||
avatar.value = user.avatar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const updateUnread = async () => {
|
|
||||||
if (authState.loggedIn) {
|
|
||||||
await fetchUnreadCount()
|
|
||||||
} else {
|
|
||||||
notificationState.unreadCount = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const refrechData = async () => {
|
||||||
|
await fetchUnreadCount()
|
||||||
|
window.dispatchEvent(new Event('refresh-home'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerMenuItems = computed(() => [
|
||||||
|
{ text: '设置', onClick: goToSettings },
|
||||||
|
{ text: '个人主页', onClick: goToProfile },
|
||||||
|
{ text: '退出', onClick: goToLogout },
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 其余逻辑保持不变 */
|
||||||
|
const iconClass = computed(() => {
|
||||||
|
switch (themeState.mode) {
|
||||||
|
case ThemeMode.DARK:
|
||||||
|
return 'fas fa-moon'
|
||||||
|
case ThemeMode.LIGHT:
|
||||||
|
return 'fas fa-sun'
|
||||||
|
default:
|
||||||
|
return 'fas fa-desktop'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const updateAvatar = async () => {
|
||||||
|
if (authState.loggedIn) {
|
||||||
|
const user = await loadCurrentUser()
|
||||||
|
if (user && user.avatar) {
|
||||||
|
avatar.value = user.avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updateUnread = async () => {
|
||||||
|
if (authState.loggedIn) {
|
||||||
|
await fetchUnreadCount()
|
||||||
|
} else {
|
||||||
|
notificationState.unreadCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAvatar()
|
||||||
|
await updateUnread()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => authState.loggedIn,
|
||||||
|
async () => {
|
||||||
await updateAvatar()
|
await updateAvatar()
|
||||||
await updateUnread()
|
await updateUnread()
|
||||||
|
},
|
||||||
watch(
|
)
|
||||||
() => authState.loggedIn,
|
})
|
||||||
async () => {
|
|
||||||
await updateAvatar()
|
|
||||||
await updateUnread()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => router.currentRoute.value.fullPath,
|
|
||||||
() => {
|
|
||||||
if (userMenu.value) userMenu.value.close()
|
|
||||||
showSearch.value = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
isLogin,
|
|
||||||
isMobile,
|
|
||||||
headerMenuItems,
|
|
||||||
unreadCount,
|
|
||||||
goToHome,
|
|
||||||
search,
|
|
||||||
closeSearch,
|
|
||||||
goToLogin,
|
|
||||||
goToSettings,
|
|
||||||
goToProfile,
|
|
||||||
goToSignup,
|
|
||||||
goToLogout,
|
|
||||||
showSearch,
|
|
||||||
searchDropdown,
|
|
||||||
userMenu,
|
|
||||||
avatar,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
background-color: var(--background-color-blur);
|
background-color: var(--background-color-blur);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: var(--blur-10);
|
||||||
color: var(--header-text-color);
|
color: var(--header-text-color);
|
||||||
border-bottom: 1px solid var(--header-border-color);
|
border-bottom: 1px solid var(--header-border-color);
|
||||||
}
|
}
|
||||||
@@ -206,17 +199,18 @@ export default {
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0 auto;
|
|
||||||
max-width: var(--page-max-width);
|
max-width: var(--page-max-width);
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content-left {
|
.header-content-left {
|
||||||
@@ -226,6 +220,14 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-content-right {
|
.header-content-right {
|
||||||
|
display: flex;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btns {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -307,7 +309,13 @@ export default {
|
|||||||
background-color: var(--menu-selected-background-color);
|
background-color: var(--menu-selected-background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-icon {
|
.search-icon,
|
||||||
|
.theme-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-post-icon {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
186
frontend_nuxt/components/InviteCodeActivityComponent.vue
Normal file
186
frontend_nuxt/components/InviteCodeActivityComponent.vue
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<template>
|
||||||
|
<div class="invite-code-activity">
|
||||||
|
<div class="invite-code-description">
|
||||||
|
<div class="invite-code-description-title">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span class="invite-code-description-title-text">邀请规则说明</span>
|
||||||
|
</div>
|
||||||
|
<div class="invite-code-description-content">
|
||||||
|
<p>邀请好友注册并登录,每次可以获得500积分</p>
|
||||||
|
<p>邀请链接的有效期为1个月</p>
|
||||||
|
<p>每一个邀请链接的邀请人数上限为3人</p>
|
||||||
|
<p>通过邀请链接注册,无需注册审核</p>
|
||||||
|
<p>每人每天仅能生产3个邀请链接</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="inviteLink" class="invite-code-link-content">
|
||||||
|
<p>
|
||||||
|
邀请链接:{{ inviteLink }}
|
||||||
|
<span @click="copyLink"><i class="fas fa-copy copy-icon"></i></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="['generate-button', { disabled: !user || loadingInvite }]" @click="generateInvite">
|
||||||
|
生成邀请链接
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { toast } from '~/main'
|
||||||
|
import { fetchCurrentUser, getToken } from '~/utils/auth'
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
|
|
||||||
|
const user = ref(null)
|
||||||
|
const isLoadingUser = ref(true)
|
||||||
|
const inviteCode = ref('')
|
||||||
|
const loadingInvite = ref(false)
|
||||||
|
|
||||||
|
const inviteLink = computed(() =>
|
||||||
|
inviteCode.value ? `${WEBSITE_BASE_URL}/signup?invite_token=${inviteCode.value}` : '',
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
isLoadingUser.value = true
|
||||||
|
user.value = await fetchCurrentUser()
|
||||||
|
isLoadingUser.value = false
|
||||||
|
if (user.value) {
|
||||||
|
await fetchInvite(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchInvite = async (showToast = true) => {
|
||||||
|
loadingInvite.value = true
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
loadingInvite.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/invite/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
inviteCode.value = data.token
|
||||||
|
if (showToast) toast.success('邀请链接已生成')
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
toast.error(data.error || '生成邀请链接失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('生成邀请链接失败')
|
||||||
|
} finally {
|
||||||
|
loadingInvite.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateInvite = () => fetchInvite(true)
|
||||||
|
|
||||||
|
const copyLink = async () => {
|
||||||
|
if (!inviteLink.value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteLink.value)
|
||||||
|
toast.success('已复制')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.invite-code-description-title-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-code-description-content {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-code-activity {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-button {
|
||||||
|
margin-top: 20px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: fit-content;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-button:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-button.disabled {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-button.disabled:hover {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-code-status-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-code-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-level-text {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-code-link-content {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.invite-code-status-container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,18 +9,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { useRouter } from 'vue-router'
|
const goLogin = () => {
|
||||||
|
navigateTo('/login', { replace: true })
|
||||||
export default {
|
|
||||||
name: 'LoginOverlay',
|
|
||||||
setup() {
|
|
||||||
const router = useRouter()
|
|
||||||
const goLogin = () => {
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
return { goLogin }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -44,7 +35,7 @@ export default {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: var(--blur-4);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,33 +16,25 @@
|
|||||||
</BasePopup>
|
</BasePopup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import BasePopup from '~/components/BasePopup.vue'
|
import BasePopup from '~/components/BasePopup.vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { authState } from '~/utils/auth'
|
import { authState } from '~/utils/auth'
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
name: 'MedalPopup',
|
visible: { type: Boolean, default: false },
|
||||||
components: { BasePopup },
|
medals: { type: Array, default: () => [] },
|
||||||
props: {
|
})
|
||||||
visible: { type: Boolean, default: false },
|
const emit = defineEmits(['close'])
|
||||||
medals: { type: Array, default: () => [] },
|
|
||||||
},
|
const gotoMedals = () => {
|
||||||
emits: ['close'],
|
emit('close')
|
||||||
setup(props, { emit }) {
|
if (authState.username) {
|
||||||
const router = useRouter()
|
navigateTo(`/users/${authState.username}?tab=achievements`, { replace: true })
|
||||||
const gotoMedals = () => {
|
} else {
|
||||||
emit('close')
|
navigateTo('/', { replace: true })
|
||||||
if (authState.username) {
|
}
|
||||||
router.push(`/users/${authState.username}?tab=achievements`)
|
|
||||||
} else {
|
|
||||||
router.push('/')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const close = () => emit('close')
|
|
||||||
return { gotoMedals, close }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
const close = () => emit('close')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,250 +1,258 @@
|
|||||||
<template>
|
<template>
|
||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<nav v-if="visible" class="menu">
|
<nav v-if="visible" class="menu">
|
||||||
<div class="menu-item-container">
|
<div class="menu-content">
|
||||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleHomeClick">
|
<div class="menu-item-container">
|
||||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||||
<span class="menu-item-text">话题</span>
|
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||||
</NuxtLink>
|
<span class="menu-item-text">话题</span>
|
||||||
<NuxtLink
|
</NuxtLink>
|
||||||
class="menu-item"
|
<NuxtLink
|
||||||
exact-active-class="selected"
|
class="menu-item"
|
||||||
to="/message"
|
exact-active-class="selected"
|
||||||
@click="handleItemClick"
|
to="/new-post"
|
||||||
>
|
@click="handleItemClick"
|
||||||
<i class="menu-item-icon fas fa-envelope"></i>
|
|
||||||
<span class="menu-item-text">我的消息</span>
|
|
||||||
<span v-if="unreadCount > 0" class="unread-container">
|
|
||||||
<span class="unread"> {{ showUnreadCount }} </span>
|
|
||||||
</span>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
class="menu-item"
|
|
||||||
exact-active-class="selected"
|
|
||||||
to="/about"
|
|
||||||
@click="handleItemClick"
|
|
||||||
>
|
|
||||||
<i class="menu-item-icon fas fa-info-circle"></i>
|
|
||||||
<span class="menu-item-text">关于</span>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
class="menu-item"
|
|
||||||
exact-active-class="selected"
|
|
||||||
to="/activities"
|
|
||||||
@click="handleItemClick"
|
|
||||||
>
|
|
||||||
<i class="menu-item-icon fas fa-gift"></i>
|
|
||||||
<span class="menu-item-text">🔥 活动</span>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="shouldShowStats"
|
|
||||||
class="menu-item"
|
|
||||||
exact-active-class="selected"
|
|
||||||
to="/about/stats"
|
|
||||||
@click="handleItemClick"
|
|
||||||
>
|
|
||||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
|
||||||
<span class="menu-item-text">站点统计</span>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
class="menu-item"
|
|
||||||
exact-active-class="selected"
|
|
||||||
to="/new-post"
|
|
||||||
@click="handleItemClick"
|
|
||||||
>
|
|
||||||
<i class="menu-item-icon fas fa-edit"></i>
|
|
||||||
<span class="menu-item-text">发帖</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="menu-section">
|
|
||||||
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
|
||||||
<span>类别</span>
|
|
||||||
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
|
||||||
</div>
|
|
||||||
<div v-if="categoryOpen" class="section-items">
|
|
||||||
<div v-if="isLoadingCategory" class="menu-loading-container">
|
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
v-for="c in categoryData"
|
|
||||||
:key="c.id"
|
|
||||||
class="section-item"
|
|
||||||
@click="gotoCategory(c)"
|
|
||||||
>
|
>
|
||||||
<template v-if="c.smallIcon || c.icon">
|
<i class="menu-item-icon fas fa-edit"></i>
|
||||||
<img
|
<span class="menu-item-text">发帖</span>
|
||||||
v-if="isImageIcon(c.smallIcon || c.icon)"
|
</NuxtLink>
|
||||||
:src="c.smallIcon || c.icon"
|
<NuxtLink
|
||||||
class="section-item-icon"
|
class="menu-item"
|
||||||
:alt="c.name"
|
exact-active-class="selected"
|
||||||
/>
|
to="/message"
|
||||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
@click="handleItemClick"
|
||||||
</template>
|
>
|
||||||
<span class="section-item-text">
|
<i class="menu-item-icon fas fa-envelope"></i>
|
||||||
{{ c.name }}
|
<span class="menu-item-text">我的消息</span>
|
||||||
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
|
<span v-if="unreadCount > 0" class="unread-container">
|
||||||
|
<span class="unread"> {{ showUnreadCount }} </span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
class="menu-item"
|
||||||
|
exact-active-class="selected"
|
||||||
|
to="/about"
|
||||||
|
@click="handleItemClick"
|
||||||
|
>
|
||||||
|
<i class="menu-item-icon fas fa-info-circle"></i>
|
||||||
|
<span class="menu-item-text">关于</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
class="menu-item"
|
||||||
|
exact-active-class="selected"
|
||||||
|
to="/activities"
|
||||||
|
@click="handleItemClick"
|
||||||
|
>
|
||||||
|
<i class="menu-item-icon fas fa-gift"></i>
|
||||||
|
<span class="menu-item-text">🔥 活动</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="shouldShowStats"
|
||||||
|
class="menu-item"
|
||||||
|
exact-active-class="selected"
|
||||||
|
to="/about/stats"
|
||||||
|
@click="handleItemClick"
|
||||||
|
>
|
||||||
|
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||||
|
<span class="menu-item-text">站点统计</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="authState.loggedIn"
|
||||||
|
class="menu-item"
|
||||||
|
exact-active-class="selected"
|
||||||
|
to="/points"
|
||||||
|
@click="handleItemClick"
|
||||||
|
>
|
||||||
|
<i class="menu-item-icon fas fa-coins"></i>
|
||||||
|
<span class="menu-item-text">
|
||||||
|
积分商城
|
||||||
|
<span v-if="myPoint !== null" class="point-count">{{ myPoint }}</span>
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="menu-section">
|
<div class="menu-section">
|
||||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||||
<span>tag</span>
|
<span>类别</span>
|
||||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||||
</div>
|
|
||||||
<div v-if="tagOpen" class="section-items">
|
|
||||||
<div v-if="isLoadingTag" class="menu-loading-container">
|
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
<div v-if="categoryOpen" class="section-items">
|
||||||
<img
|
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||||
v-if="isImageIcon(t.smallIcon || t.icon)"
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
:src="t.smallIcon || t.icon"
|
</div>
|
||||||
class="section-item-icon"
|
<div
|
||||||
:alt="t.name"
|
v-else
|
||||||
/>
|
v-for="c in categoryData"
|
||||||
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
:key="c.id"
|
||||||
<span class="section-item-text"
|
class="section-item"
|
||||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
@click="gotoCategory(c)"
|
||||||
>
|
>
|
||||||
|
<template v-if="c.smallIcon || c.icon">
|
||||||
|
<img
|
||||||
|
v-if="isImageIcon(c.smallIcon || c.icon)"
|
||||||
|
:src="c.smallIcon || c.icon"
|
||||||
|
class="section-item-icon"
|
||||||
|
:alt="c.name"
|
||||||
|
/>
|
||||||
|
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||||
|
</template>
|
||||||
|
<span class="section-item-text">
|
||||||
|
{{ c.name }}
|
||||||
|
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-section">
|
||||||
|
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||||
|
<span>tag</span>
|
||||||
|
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||||
|
</div>
|
||||||
|
<div v-if="tagOpen" class="section-items">
|
||||||
|
<div v-if="isLoadingTag" class="menu-loading-container">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
</div>
|
||||||
|
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
||||||
|
<img
|
||||||
|
v-if="isImageIcon(t.smallIcon || t.icon)"
|
||||||
|
:src="t.smallIcon || t.icon"
|
||||||
|
class="section-item-icon"
|
||||||
|
:alt="t.name"
|
||||||
|
/>
|
||||||
|
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
||||||
|
<span class="section-item-text"
|
||||||
|
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="menu-footer">
|
<!-- 解决动态样式的水合错误 -->
|
||||||
<div class="menu-footer-btn" @click="cycleTheme">
|
<ClientOnly v-if="!isMobile">
|
||||||
<i :class="iconClass"></i>
|
<div class="menu-footer">
|
||||||
|
<div class="menu-footer-btn" @click="cycleTheme">
|
||||||
|
<i :class="iconClass"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ClientOnly>
|
||||||
</nav>
|
</nav>
|
||||||
</transition>
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { authState } from '~/utils/auth'
|
import { authState, fetchCurrentUser } from '~/utils/auth'
|
||||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
import { API_BASE_URL } from '~/main'
|
import { cycleTheme, ThemeMode, themeState } from '~/utils/theme'
|
||||||
|
|
||||||
export default {
|
const isMobile = useIsMobile()
|
||||||
name: 'MenuComponent',
|
|
||||||
props: {
|
const config = useRuntimeConfig()
|
||||||
visible: {
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: true },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['item-click'])
|
||||||
|
|
||||||
|
const categoryOpen = ref(true)
|
||||||
|
const tagOpen = ref(true)
|
||||||
|
const myPoint = ref(null)
|
||||||
|
|
||||||
|
/** ✅ 用 useAsyncData 替换原生 fetch,避免 SSR+CSR 二次请求 */
|
||||||
|
const {
|
||||||
|
data: categoryData,
|
||||||
|
pending: isLoadingCategory,
|
||||||
|
error: categoryError,
|
||||||
|
} = await useAsyncData(
|
||||||
|
// 稳定 key:避免 hydration 期误判
|
||||||
|
'menu:categories',
|
||||||
|
() => $fetch(`${API_BASE_URL}/api/categories`),
|
||||||
|
{
|
||||||
|
server: true, // SSR 预取
|
||||||
|
default: () => [], // 初始默认值,减少空判断
|
||||||
|
// 5 分钟内复用缓存,避免路由往返重复请求
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: tagData,
|
||||||
|
pending: isLoadingTag,
|
||||||
|
error: tagError,
|
||||||
|
} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), {
|
||||||
|
server: true,
|
||||||
|
default: () => [],
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 其余逻辑保持不变 */
|
||||||
|
const iconClass = computed(() => {
|
||||||
|
switch (themeState.mode) {
|
||||||
|
case ThemeMode.DARK:
|
||||||
|
return 'fas fa-moon'
|
||||||
|
case ThemeMode.LIGHT:
|
||||||
|
return 'fas fa-sun'
|
||||||
|
default:
|
||||||
|
return 'fas fa-desktop'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const unreadCount = computed(() => notificationState.unreadCount)
|
||||||
|
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
||||||
|
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
||||||
|
|
||||||
|
const loadPoint = async () => {
|
||||||
|
if (authState.loggedIn) {
|
||||||
|
const user = await fetchCurrentUser()
|
||||||
|
myPoint.value = user ? user.point : null
|
||||||
|
} else {
|
||||||
|
myPoint.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCount = async () => {
|
||||||
|
if (authState.loggedIn) {
|
||||||
|
await fetchUnreadCount()
|
||||||
|
} else {
|
||||||
|
notificationState.unreadCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([updateCount(), loadPoint()])
|
||||||
|
// 登录态变化时再拉一次未读数和积分;与 useAsyncData 无关
|
||||||
|
watch(
|
||||||
|
() => authState.loggedIn,
|
||||||
|
() => {
|
||||||
|
updateCount()
|
||||||
|
loadPoint()
|
||||||
},
|
},
|
||||||
},
|
)
|
||||||
async setup(props, { emit }) {
|
})
|
||||||
const router = useRouter()
|
|
||||||
const categories = ref([])
|
|
||||||
const tags = ref([])
|
|
||||||
const categoryOpen = ref(true)
|
|
||||||
const tagOpen = ref(true)
|
|
||||||
const isLoadingCategory = ref(false)
|
|
||||||
const isLoadingTag = ref(false)
|
|
||||||
const categoryData = ref([])
|
|
||||||
const tagData = ref([])
|
|
||||||
|
|
||||||
const fetchCategoryData = async () => {
|
const handleItemClick = () => {
|
||||||
isLoadingCategory.value = true
|
if (window.innerWidth <= 768) emit('item-click')
|
||||||
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
}
|
||||||
const data = await res.json()
|
|
||||||
categoryData.value = data
|
|
||||||
isLoadingCategory.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchTagData = async () => {
|
const isImageIcon = (icon) => {
|
||||||
isLoadingTag.value = true
|
if (!icon) return false
|
||||||
const res = await fetch(`${API_BASE_URL}/api/tags?limit=10`)
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
const data = await res.json()
|
}
|
||||||
tagData.value = data
|
|
||||||
isLoadingTag.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconClass = computed(() => {
|
const gotoCategory = (c) => {
|
||||||
switch (themeState.mode) {
|
const value = encodeURIComponent(c.id ?? c.name)
|
||||||
case ThemeMode.DARK:
|
navigateTo({ path: '/', query: { category: value } }, { replace: true })
|
||||||
return 'fas fa-moon'
|
handleItemClick()
|
||||||
case ThemeMode.LIGHT:
|
}
|
||||||
return 'fas fa-sun'
|
|
||||||
default:
|
|
||||||
return 'fas fa-desktop'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const unreadCount = computed(() => notificationState.unreadCount)
|
const gotoTag = (t) => {
|
||||||
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
const value = encodeURIComponent(t.id ?? t.name)
|
||||||
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||||
|
handleItemClick()
|
||||||
const updateCount = async () => {
|
|
||||||
if (authState.loggedIn) {
|
|
||||||
await fetchUnreadCount()
|
|
||||||
} else {
|
|
||||||
notificationState.unreadCount = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await updateCount()
|
|
||||||
watch(() => authState.loggedIn, updateCount)
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleHomeClick = () => {
|
|
||||||
router.push('/').then(() => {
|
|
||||||
window.location.reload()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleItemClick = () => {
|
|
||||||
if (window.innerWidth <= 768) emit('item-click')
|
|
||||||
}
|
|
||||||
|
|
||||||
const isImageIcon = (icon) => {
|
|
||||||
if (!icon) return false
|
|
||||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const gotoCategory = (c) => {
|
|
||||||
const value = encodeURIComponent(c.id ?? c.name)
|
|
||||||
router.push({ path: '/', query: { category: value } }).then(() => {
|
|
||||||
window.location.reload()
|
|
||||||
})
|
|
||||||
handleItemClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
const gotoTag = (t) => {
|
|
||||||
const value = encodeURIComponent(t.id ?? t.name)
|
|
||||||
router.push({ path: '/', query: { tags: value } }).then(() => {
|
|
||||||
window.location.reload()
|
|
||||||
})
|
|
||||||
handleItemClick()
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([fetchCategoryData(), fetchTagData()])
|
|
||||||
|
|
||||||
return {
|
|
||||||
categoryData,
|
|
||||||
tagData,
|
|
||||||
categoryOpen,
|
|
||||||
tagOpen,
|
|
||||||
isLoadingCategory,
|
|
||||||
isLoadingTag,
|
|
||||||
iconClass,
|
|
||||||
unreadCount,
|
|
||||||
showUnreadCount,
|
|
||||||
shouldShowStats,
|
|
||||||
cycleTheme,
|
|
||||||
handleHomeClick,
|
|
||||||
handleItemClick,
|
|
||||||
isImageIcon,
|
|
||||||
gotoCategory,
|
|
||||||
gotoTag,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -252,20 +260,29 @@ export default {
|
|||||||
.menu {
|
.menu {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: var(--header-height);
|
top: var(--header-height);
|
||||||
width: 200px;
|
width: 220px;
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
height: calc(100vh - 20px - var(--header-height));
|
height: calc(100vh - 20px - var(--header-height));
|
||||||
border-right: 1px solid var(--menu-border-color);
|
border-right: 1px solid var(--menu-border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 10px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item-container {
|
.menu-content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px 10px 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* .menu-item-container { */
|
||||||
|
/**/
|
||||||
|
/* } */
|
||||||
|
|
||||||
.menu-item {
|
.menu-item {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -304,6 +321,12 @@ export default {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.point-count {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
.menu-item-icon {
|
.menu-item-icon {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -311,10 +334,8 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-footer {
|
.menu-footer {
|
||||||
position: fixed;
|
position: relation;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
bottom: 10px;
|
|
||||||
right: 10px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -402,6 +423,10 @@ export default {
|
|||||||
background-color: var(--background-color-blur);
|
background-color: var(--background-color-blur);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-content {
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.slide-enter-active,
|
.slide-enter-active,
|
||||||
.slide-leave-active {
|
.slide-leave-active {
|
||||||
transition:
|
transition:
|
||||||
|
|||||||
@@ -40,90 +40,75 @@
|
|||||||
兑换
|
兑换
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="redeem-button disabled">兑换</div>
|
<div v-else class="redeem-button disabled">兑换</div>
|
||||||
<BasePopup :visible="dialogVisible" @close="closeDialog">
|
<RedeemPopup
|
||||||
<div class="redeem-dialog-content">
|
:visible="dialogVisible"
|
||||||
<BaseInput
|
v-model="contact"
|
||||||
textarea=""
|
:loading="loading"
|
||||||
rows="5"
|
@close="closeDialog"
|
||||||
v-model="contact"
|
@submit="submitRedeem"
|
||||||
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
|
/>
|
||||||
/>
|
|
||||||
<div class="redeem-actions">
|
|
||||||
<div class="redeem-submit-button" @click="submitRedeem" :disabled="loading">提交</div>
|
|
||||||
<div class="redeem-cancel-button" @click="closeDialog">取消</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</BasePopup>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { fetchCurrentUser, getToken } from '~/utils/auth'
|
import { fetchCurrentUser, getToken } from '~/utils/auth'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
|
||||||
import BasePopup from '~/components/BasePopup.vue'
|
|
||||||
import LevelProgress from '~/components/LevelProgress.vue'
|
import LevelProgress from '~/components/LevelProgress.vue'
|
||||||
import ProgressBar from '~/components/ProgressBar.vue'
|
import ProgressBar from '~/components/ProgressBar.vue'
|
||||||
|
import RedeemPopup from '~/components/RedeemPopup.vue'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const info = ref({ redeemCount: 0, ended: false })
|
||||||
name: 'MilkTeaActivityComponent',
|
const user = ref(null)
|
||||||
components: { ProgressBar, LevelProgress, BaseInput, BasePopup },
|
const dialogVisible = ref(false)
|
||||||
data() {
|
const contact = ref('')
|
||||||
return {
|
const loading = ref(false)
|
||||||
info: { redeemCount: 0, ended: false },
|
const isLoadingUser = ref(true)
|
||||||
user: null,
|
|
||||||
dialogVisible: false,
|
onMounted(async () => {
|
||||||
contact: '',
|
await loadInfo()
|
||||||
loading: false,
|
isLoadingUser.value = true
|
||||||
isLoadingUser: true,
|
user.value = await fetchCurrentUser()
|
||||||
|
isLoadingUser.value = false
|
||||||
|
})
|
||||||
|
const loadInfo = async () => {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
|
||||||
|
if (res.ok) {
|
||||||
|
info.value = await res.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const openDialog = () => {
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
const closeDialog = () => {
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
const submitRedeem = async () => {
|
||||||
|
if (!contact.value) return
|
||||||
|
loading.value = true
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ contact: contact.value }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.message === 'updated') {
|
||||||
|
toast.success('您已提交过兑换,本次更新兑换信息')
|
||||||
|
} else {
|
||||||
|
toast.success('兑换成功!')
|
||||||
}
|
}
|
||||||
},
|
dialogVisible.value = false
|
||||||
async mounted() {
|
await loadInfo()
|
||||||
await this.loadInfo()
|
} else {
|
||||||
this.isLoadingUser = true
|
toast.error('兑换失败')
|
||||||
this.user = await fetchCurrentUser()
|
}
|
||||||
this.isLoadingUser = false
|
loading.value = false
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async loadInfo() {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
|
|
||||||
if (res.ok) {
|
|
||||||
this.info = await res.json()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
openDialog() {
|
|
||||||
this.dialogVisible = true
|
|
||||||
},
|
|
||||||
closeDialog() {
|
|
||||||
this.dialogVisible = false
|
|
||||||
},
|
|
||||||
async submitRedeem() {
|
|
||||||
if (!this.contact) return
|
|
||||||
this.loading = true
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ contact: this.contact }),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.message === 'updated') {
|
|
||||||
toast.success('您已提交过兑换,本次更新兑换信息')
|
|
||||||
} else {
|
|
||||||
toast.success('兑换成功!')
|
|
||||||
}
|
|
||||||
this.dialogVisible = false
|
|
||||||
await this.loadInfo()
|
|
||||||
} else {
|
|
||||||
toast.error('兑换失败')
|
|
||||||
}
|
|
||||||
this.loading = false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -192,56 +177,6 @@ export default {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.redeem-dialog-content {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
background-color: var(--background-color);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.redeem-actions {
|
|
||||||
margin-top: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 20px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.redeem-submit-button {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: #fff;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.redeem-submit-button:disabled {
|
|
||||||
background-color: var(--primary-color-disabled);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.redeem-submit-button:hover {
|
|
||||||
background-color: var(--primary-color-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.redeem-submit-button:disabled:hover {
|
|
||||||
background-color: var(--primary-color-disabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
.redeem-cancel-button {
|
|
||||||
color: var(--primary-color);
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.redeem-cancel-button:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-level-text {
|
.user-level-text {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -254,9 +189,5 @@ export default {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.redeem-dialog-content {
|
|
||||||
min-width: 300px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,27 +11,19 @@
|
|||||||
</BasePopup>
|
</BasePopup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import BasePopup from '~/components/BasePopup.vue'
|
import BasePopup from '~/components/BasePopup.vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
name: 'NotificationSettingPopup',
|
visible: { type: Boolean, default: false },
|
||||||
components: { BasePopup },
|
})
|
||||||
props: {
|
const emit = defineEmits(['close'])
|
||||||
visible: { type: Boolean, default: false },
|
|
||||||
},
|
const gotoSetting = () => {
|
||||||
emits: ['close'],
|
emit('close')
|
||||||
setup(props, { emit }) {
|
navigateTo('/message?tab=control', { replace: true })
|
||||||
const router = useRouter()
|
|
||||||
const gotoSetting = () => {
|
|
||||||
emit('close')
|
|
||||||
router.push('/message?tab=control')
|
|
||||||
}
|
|
||||||
const close = () => emit('close')
|
|
||||||
return { gotoSetting, close }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
const close = () => emit('close')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -46,11 +46,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { reactionEmojiMap } from '~/utils/reactions'
|
import { reactionEmojiMap } from '~/utils/reactions'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Array, default: () => [] },
|
||||||
|
contentType: { type: String, required: true },
|
||||||
|
contentId: { type: [Number, String], required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(v) => (reactions.value = v),
|
||||||
|
)
|
||||||
|
|
||||||
|
const reactions = ref(props.modelValue)
|
||||||
|
const reactionTypes = ref([])
|
||||||
|
|
||||||
let cachedTypes = null
|
let cachedTypes = null
|
||||||
const fetchTypes = async () => {
|
const fetchTypes = async () => {
|
||||||
@@ -71,151 +87,118 @@ const fetchTypes = async () => {
|
|||||||
return cachedTypes
|
return cachedTypes
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
onMounted(async () => {
|
||||||
name: 'ReactionsGroup',
|
reactionTypes.value = await fetchTypes()
|
||||||
props: {
|
})
|
||||||
modelValue: { type: Array, default: () => [] },
|
|
||||||
contentType: { type: String, required: true },
|
|
||||||
contentId: { type: [Number, String], required: true },
|
|
||||||
},
|
|
||||||
emits: ['update:modelValue'],
|
|
||||||
setup(props, { emit }) {
|
|
||||||
const reactions = ref(props.modelValue)
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(v) => (reactions.value = v),
|
|
||||||
)
|
|
||||||
|
|
||||||
const reactionTypes = ref([])
|
const counts = computed(() => {
|
||||||
onMounted(async () => {
|
const c = {}
|
||||||
reactionTypes.value = await fetchTypes()
|
for (const r of reactions.value) {
|
||||||
|
c[r.type] = (c[r.type] || 0) + 1
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
||||||
|
const likeCount = computed(() => counts.value['LIKE'] || 0)
|
||||||
|
|
||||||
|
const userReacted = (type) =>
|
||||||
|
reactions.value.some((r) => r.type === type && r.user === authState.username)
|
||||||
|
|
||||||
|
const displayedReactions = computed(() => {
|
||||||
|
return Object.entries(counts.value)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(([type]) => ({ type }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE'))
|
||||||
|
|
||||||
|
const panelVisible = ref(false)
|
||||||
|
let hideTimer = null
|
||||||
|
const openPanel = () => {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
panelVisible.value = true
|
||||||
|
}
|
||||||
|
const scheduleHide = () => {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
hideTimer = setTimeout(() => {
|
||||||
|
panelVisible.value = false
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
const cancelHide = () => {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleReaction = async (type) => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url =
|
||||||
|
props.contentType === 'post'
|
||||||
|
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
|
||||||
|
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
|
||||||
|
|
||||||
|
// optimistic update
|
||||||
|
const existingIdx = reactions.value.findIndex(
|
||||||
|
(r) => r.type === type && r.user === authState.username,
|
||||||
|
)
|
||||||
|
let tempReaction = null
|
||||||
|
let removedReaction = null
|
||||||
|
if (existingIdx > -1) {
|
||||||
|
removedReaction = reactions.value.splice(existingIdx, 1)[0]
|
||||||
|
} else {
|
||||||
|
tempReaction = { type, user: authState.username }
|
||||||
|
reactions.value.push(tempReaction)
|
||||||
|
}
|
||||||
|
emit('update:modelValue', reactions.value)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ type }),
|
||||||
})
|
})
|
||||||
|
if (res.ok) {
|
||||||
const counts = computed(() => {
|
if (res.status === 204) {
|
||||||
const c = {}
|
// removal already reflected
|
||||||
for (const r of reactions.value) {
|
|
||||||
c[r.type] = (c[r.type] || 0) + 1
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
|
||||||
const likeCount = computed(() => counts.value['LIKE'] || 0)
|
|
||||||
|
|
||||||
const userReacted = (type) =>
|
|
||||||
reactions.value.some((r) => r.type === type && r.user === authState.username)
|
|
||||||
|
|
||||||
const displayedReactions = computed(() => {
|
|
||||||
return Object.entries(counts.value)
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(([type]) => ({ type }))
|
|
||||||
})
|
|
||||||
|
|
||||||
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE'))
|
|
||||||
|
|
||||||
const panelVisible = ref(false)
|
|
||||||
let hideTimer = null
|
|
||||||
const openPanel = () => {
|
|
||||||
clearTimeout(hideTimer)
|
|
||||||
panelVisible.value = true
|
|
||||||
}
|
|
||||||
const scheduleHide = () => {
|
|
||||||
clearTimeout(hideTimer)
|
|
||||||
hideTimer = setTimeout(() => {
|
|
||||||
panelVisible.value = false
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
const cancelHide = () => {
|
|
||||||
clearTimeout(hideTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleReaction = async (type) => {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) {
|
|
||||||
toast.error('请先登录')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const url =
|
|
||||||
props.contentType === 'post'
|
|
||||||
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
|
|
||||||
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
|
|
||||||
|
|
||||||
// optimistic update
|
|
||||||
const existingIdx = reactions.value.findIndex(
|
|
||||||
(r) => r.type === type && r.user === authState.username,
|
|
||||||
)
|
|
||||||
let tempReaction = null
|
|
||||||
let removedReaction = null
|
|
||||||
if (existingIdx > -1) {
|
|
||||||
removedReaction = reactions.value.splice(existingIdx, 1)[0]
|
|
||||||
} else {
|
} else {
|
||||||
tempReaction = { type, user: authState.username }
|
const data = await res.json()
|
||||||
reactions.value.push(tempReaction)
|
const idx = tempReaction ? reactions.value.indexOf(tempReaction) : -1
|
||||||
|
if (idx > -1) {
|
||||||
|
reactions.value.splice(idx, 1, data)
|
||||||
|
} else if (removedReaction) {
|
||||||
|
// server added back reaction even though we removed? restore data
|
||||||
|
reactions.value.push(data)
|
||||||
|
}
|
||||||
|
if (data.reward && data.reward > 0) {
|
||||||
|
toast.success(`获得 ${data.reward} 经验值`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
emit('update:modelValue', reactions.value)
|
emit('update:modelValue', reactions.value)
|
||||||
|
} else {
|
||||||
try {
|
// revert optimistic update on failure
|
||||||
const res = await fetch(url, {
|
if (tempReaction) {
|
||||||
method: 'POST',
|
const idx = reactions.value.indexOf(tempReaction)
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
if (idx > -1) reactions.value.splice(idx, 1)
|
||||||
body: JSON.stringify({ type }),
|
} else if (removedReaction) {
|
||||||
})
|
reactions.value.push(removedReaction)
|
||||||
if (res.ok) {
|
|
||||||
if (res.status === 204) {
|
|
||||||
// removal already reflected
|
|
||||||
} else {
|
|
||||||
const data = await res.json()
|
|
||||||
const idx = tempReaction ? reactions.value.indexOf(tempReaction) : -1
|
|
||||||
if (idx > -1) {
|
|
||||||
reactions.value.splice(idx, 1, data)
|
|
||||||
} else if (removedReaction) {
|
|
||||||
// server added back reaction even though we removed? restore data
|
|
||||||
reactions.value.push(data)
|
|
||||||
}
|
|
||||||
if (data.reward && data.reward > 0) {
|
|
||||||
toast.success(`获得 ${data.reward} 经验值`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
emit('update:modelValue', reactions.value)
|
|
||||||
} else {
|
|
||||||
// revert optimistic update on failure
|
|
||||||
if (tempReaction) {
|
|
||||||
const idx = reactions.value.indexOf(tempReaction)
|
|
||||||
if (idx > -1) reactions.value.splice(idx, 1)
|
|
||||||
} else if (removedReaction) {
|
|
||||||
reactions.value.push(removedReaction)
|
|
||||||
}
|
|
||||||
emit('update:modelValue', reactions.value)
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (tempReaction) {
|
|
||||||
const idx = reactions.value.indexOf(tempReaction)
|
|
||||||
if (idx > -1) reactions.value.splice(idx, 1)
|
|
||||||
} else if (removedReaction) {
|
|
||||||
reactions.value.push(removedReaction)
|
|
||||||
}
|
|
||||||
emit('update:modelValue', reactions.value)
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
}
|
||||||
|
emit('update:modelValue', reactions.value)
|
||||||
|
toast.error('操作失败')
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
return {
|
if (tempReaction) {
|
||||||
reactionEmojiMap,
|
const idx = reactions.value.indexOf(tempReaction)
|
||||||
counts,
|
if (idx > -1) reactions.value.splice(idx, 1)
|
||||||
totalCount,
|
} else if (removedReaction) {
|
||||||
likeCount,
|
reactions.value.push(removedReaction)
|
||||||
displayedReactions,
|
|
||||||
panelTypes,
|
|
||||||
panelVisible,
|
|
||||||
openPanel,
|
|
||||||
scheduleHide,
|
|
||||||
cancelHide,
|
|
||||||
toggleReaction,
|
|
||||||
userReacted,
|
|
||||||
}
|
}
|
||||||
},
|
emit('update:modelValue', reactions.value)
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
103
frontend_nuxt/components/RedeemPopup.vue
Normal file
103
frontend_nuxt/components/RedeemPopup.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<BasePopup :visible="visible" @close="onClose">
|
||||||
|
<div class="redeem-dialog-content">
|
||||||
|
<BaseInput
|
||||||
|
textarea
|
||||||
|
rows="5"
|
||||||
|
v-model="innerContact"
|
||||||
|
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
|
||||||
|
/>
|
||||||
|
<div class="redeem-actions">
|
||||||
|
<div class="redeem-submit-button" @click="submit" :disabled="loading">提交</div>
|
||||||
|
<div class="redeem-cancel-button" @click="onClose">取消</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BasePopup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
|
import BasePopup from '~/components/BasePopup.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
modelValue: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'submit', 'close'])
|
||||||
|
|
||||||
|
const innerContact = ref(props.modelValue)
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(v) => {
|
||||||
|
innerContact.value = v
|
||||||
|
},
|
||||||
|
)
|
||||||
|
watch(innerContact, (v) => emit('update:modelValue', v))
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
emit('submit')
|
||||||
|
}
|
||||||
|
const onClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.redeem-dialog-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-submit-button {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-submit-button:disabled {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-submit-button:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-submit-button:disabled:hover {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-cancel-button {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-cancel-button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.redeem-dialog-content {
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -36,98 +36,84 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import { stripMarkdown } from '~/utils/markdown'
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const emit = defineEmits(['close'])
|
||||||
name: 'SearchDropdown',
|
|
||||||
components: { Dropdown },
|
|
||||||
emits: ['close'],
|
|
||||||
setup(props, { emit }) {
|
|
||||||
const router = useRouter()
|
|
||||||
const keyword = ref('')
|
|
||||||
const selected = ref(null)
|
|
||||||
const results = ref([])
|
|
||||||
const dropdown = ref(null)
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
|
|
||||||
const toggle = () => {
|
const keyword = ref('')
|
||||||
dropdown.value.toggle()
|
const selected = ref(null)
|
||||||
}
|
const results = ref([])
|
||||||
|
const dropdown = ref(null)
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const onClose = () => emit('close')
|
const toggle = () => {
|
||||||
|
dropdown.value.toggle()
|
||||||
const fetchResults = async (kw) => {
|
|
||||||
if (!kw) return []
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
|
|
||||||
if (!res.ok) return []
|
|
||||||
const data = await res.json()
|
|
||||||
results.value = data.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
text: r.text,
|
|
||||||
type: r.type,
|
|
||||||
subText: r.subText,
|
|
||||||
extra: r.extra,
|
|
||||||
postId: r.postId,
|
|
||||||
}))
|
|
||||||
return results.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlight = (text) => {
|
|
||||||
text = stripMarkdown(text)
|
|
||||||
if (!keyword.value) return text
|
|
||||||
const reg = new RegExp(keyword.value, 'gi')
|
|
||||||
const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconMap = {
|
|
||||||
user: 'fas fa-user',
|
|
||||||
post: 'fas fa-file-alt',
|
|
||||||
comment: 'fas fa-comment',
|
|
||||||
category: 'fas fa-folder',
|
|
||||||
tag: 'fas fa-hashtag',
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(selected, (val) => {
|
|
||||||
if (!val) return
|
|
||||||
const opt = results.value.find((r) => r.id === val)
|
|
||||||
if (!opt) return
|
|
||||||
if (opt.type === 'post' || opt.type === 'post_title') {
|
|
||||||
router.push(`/posts/${opt.id}`)
|
|
||||||
} else if (opt.type === 'user') {
|
|
||||||
router.push(`/users/${opt.id}`)
|
|
||||||
} else if (opt.type === 'comment') {
|
|
||||||
if (opt.postId) {
|
|
||||||
router.push(`/posts/${opt.postId}#comment-${opt.id}`)
|
|
||||||
}
|
|
||||||
} else if (opt.type === 'category') {
|
|
||||||
router.push({ path: '/', query: { category: opt.id } })
|
|
||||||
} else if (opt.type === 'tag') {
|
|
||||||
router.push({ path: '/', query: { tags: opt.id } })
|
|
||||||
}
|
|
||||||
selected.value = null
|
|
||||||
keyword.value = ''
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
keyword,
|
|
||||||
selected,
|
|
||||||
fetchResults,
|
|
||||||
highlight,
|
|
||||||
iconMap,
|
|
||||||
isMobile,
|
|
||||||
dropdown,
|
|
||||||
onClose,
|
|
||||||
toggle,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onClose = () => emit('close')
|
||||||
|
|
||||||
|
const fetchResults = async (kw) => {
|
||||||
|
if (!kw) return []
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/search/global?keyword=${encodeURIComponent(kw)}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
results.value = data.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
text: r.text,
|
||||||
|
type: r.type,
|
||||||
|
subText: r.subText,
|
||||||
|
extra: r.extra,
|
||||||
|
postId: r.postId,
|
||||||
|
}))
|
||||||
|
return results.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlight = (text) => {
|
||||||
|
text = stripMarkdown(text)
|
||||||
|
if (!keyword.value) return text
|
||||||
|
const reg = new RegExp(keyword.value, 'gi')
|
||||||
|
const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
user: 'fas fa-user',
|
||||||
|
post: 'fas fa-file-alt',
|
||||||
|
comment: 'fas fa-comment',
|
||||||
|
category: 'fas fa-folder',
|
||||||
|
tag: 'fas fa-hashtag',
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selected, (val) => {
|
||||||
|
if (!val) return
|
||||||
|
const opt = results.value.find((r) => r.id === val)
|
||||||
|
if (!opt) return
|
||||||
|
if (opt.type === 'post' || opt.type === 'post_title') {
|
||||||
|
navigateTo(`/posts/${opt.id}`, { replace: true })
|
||||||
|
} else if (opt.type === 'user') {
|
||||||
|
navigateTo(`/users/${opt.id}`, { replace: true })
|
||||||
|
} else if (opt.type === 'comment') {
|
||||||
|
if (opt.postId) {
|
||||||
|
navigateTo(`/posts/${opt.postId}#comment-${opt.id}`, { replace: true })
|
||||||
|
}
|
||||||
|
} else if (opt.type === 'category') {
|
||||||
|
navigateTo({ path: '/', query: { category: opt.id } }, { replace: true })
|
||||||
|
} else if (opt.type === 'tag') {
|
||||||
|
navigateTo({ path: '/', query: { tags: opt.id } }, { replace: true })
|
||||||
|
}
|
||||||
|
selected.value = null
|
||||||
|
keyword.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
toggle,
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -149,7 +135,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-input {
|
.text-input {
|
||||||
background-color: var(--menu-background-color);
|
background-color: var(--app-menu-background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -28,114 +28,105 @@
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const emit = defineEmits(['update:modelValue'])
|
||||||
name: 'TagSelect',
|
const props = defineProps({
|
||||||
components: { Dropdown },
|
modelValue: { type: Array, default: () => [] },
|
||||||
props: {
|
creatable: { type: Boolean, default: false },
|
||||||
modelValue: { type: Array, default: () => [] },
|
options: { type: Array, default: () => [] },
|
||||||
creatable: { type: Boolean, default: false },
|
})
|
||||||
options: { type: Array, default: () => [] },
|
|
||||||
|
const localTags = ref([])
|
||||||
|
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.options,
|
||||||
|
(val) => {
|
||||||
|
providedTags.value = Array.isArray(val) ? [...val] : []
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
)
|
||||||
setup(props, { emit }) {
|
|
||||||
const localTags = ref([])
|
|
||||||
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
|
|
||||||
|
|
||||||
watch(
|
const mergedOptions = computed(() => {
|
||||||
() => props.options,
|
const arr = [...providedTags.value, ...localTags.value]
|
||||||
(val) => {
|
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
|
||||||
providedTags.value = Array.isArray(val) ? [...val] : []
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const mergedOptions = computed(() => {
|
const isImageIcon = (icon) => {
|
||||||
const arr = [...providedTags.value, ...localTags.value]
|
if (!icon) return false
|
||||||
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
})
|
|
||||||
|
|
||||||
const isImageIcon = (icon) => {
|
|
||||||
if (!icon) return false
|
|
||||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildTagsUrl = (kw = '') => {
|
|
||||||
const base = API_BASE_URL || (process.client ? window.location.origin : '')
|
|
||||||
const url = new URL('/api/tags', base)
|
|
||||||
|
|
||||||
if (kw) url.searchParams.set('keyword', kw)
|
|
||||||
url.searchParams.set('limit', '10')
|
|
||||||
|
|
||||||
return url.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchTags = async (kw = '') => {
|
|
||||||
const defaultOption = { id: 0, name: '无标签' }
|
|
||||||
|
|
||||||
// 1) 先拼 URL(自动兜底到 window.location.origin)
|
|
||||||
const url = buildTagsUrl(kw)
|
|
||||||
|
|
||||||
// 2) 拉数据
|
|
||||||
let data = []
|
|
||||||
try {
|
|
||||||
const res = await fetch(url)
|
|
||||||
if (res.ok) data = await res.json()
|
|
||||||
} catch {
|
|
||||||
toast.error('获取标签失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) 合并、去重、可创建
|
|
||||||
let options = [...data, ...localTags.value]
|
|
||||||
|
|
||||||
if (
|
|
||||||
props.creatable &&
|
|
||||||
kw &&
|
|
||||||
!options.some((t) => t.name.toLowerCase() === kw.toLowerCase())
|
|
||||||
) {
|
|
||||||
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
|
|
||||||
}
|
|
||||||
|
|
||||||
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
|
|
||||||
|
|
||||||
// 4) 最终结果
|
|
||||||
return [defaultOption, ...options]
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (v) => {
|
|
||||||
if (Array.isArray(v)) {
|
|
||||||
if (v.includes(0)) {
|
|
||||||
emit('update:modelValue', [])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (v.length > 2) {
|
|
||||||
toast.error('最多选择两个标签')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
v = v.map((id) => {
|
|
||||||
if (typeof id === 'string' && id.startsWith('__create__:')) {
|
|
||||||
const name = id.slice(11)
|
|
||||||
const newId = `__new__:${name}`
|
|
||||||
if (!localTags.value.find((t) => t.id === newId)) {
|
|
||||||
localTags.value.push({ id: newId, name })
|
|
||||||
}
|
|
||||||
return newId
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
})
|
|
||||||
}
|
|
||||||
emit('update:modelValue', v)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return { fetchTags, selected, isImageIcon, mergedOptions }
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildTagsUrl = (kw = '') => {
|
||||||
|
const base = API_BASE_URL || (process.client ? window.location.origin : '')
|
||||||
|
const url = new URL('/api/tags', base)
|
||||||
|
|
||||||
|
if (kw) url.searchParams.set('keyword', kw)
|
||||||
|
url.searchParams.set('limit', '10')
|
||||||
|
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTags = async (kw = '') => {
|
||||||
|
const defaultOption = { id: 0, name: '无标签' }
|
||||||
|
|
||||||
|
// 1) 先拼 URL(自动兜底到 window.location.origin)
|
||||||
|
const url = buildTagsUrl(kw)
|
||||||
|
|
||||||
|
// 2) 拉数据
|
||||||
|
let data = []
|
||||||
|
try {
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (res.ok) data = await res.json()
|
||||||
|
} catch {
|
||||||
|
toast.error('获取标签失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 合并、去重、可创建
|
||||||
|
let options = [...data, ...localTags.value]
|
||||||
|
|
||||||
|
if (props.creatable && kw && !options.some((t) => t.name.toLowerCase() === kw.toLowerCase())) {
|
||||||
|
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
|
||||||
|
}
|
||||||
|
|
||||||
|
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
|
||||||
|
|
||||||
|
// 4) 最终结果
|
||||||
|
return [defaultOption, ...options]
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => {
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
if (v.includes(0)) {
|
||||||
|
emit('update:modelValue', [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (v.length > 2) {
|
||||||
|
toast.error('最多选择两个标签')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v = v.map((id) => {
|
||||||
|
if (typeof id === 'string' && id.startsWith('__create__:')) {
|
||||||
|
const name = id.slice(11)
|
||||||
|
const newId = `__new__:${name}`
|
||||||
|
if (!localTags.value.find((t) => t.id === newId)) {
|
||||||
|
localTags.value.push({ id: newId, name })
|
||||||
|
}
|
||||||
|
return newId
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
emit('update:modelValue', v)
|
||||||
|
},
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
547
frontend_nuxt/components/ToolTip.vue
Normal file
547
frontend_nuxt/components/ToolTip.vue
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tooltip-wrapper" ref="wrapperRef">
|
||||||
|
<!-- 触发器 -->
|
||||||
|
<div
|
||||||
|
class="tooltip-trigger"
|
||||||
|
:tabindex="focusable ? 0 : -1"
|
||||||
|
:aria-describedby="visible ? ariaId : undefined"
|
||||||
|
@mouseenter="onTriggerMouseEnter"
|
||||||
|
@mouseleave="onTriggerMouseLeave"
|
||||||
|
@click="onTriggerClick"
|
||||||
|
@focus="onTriggerFocus"
|
||||||
|
@blur="onTriggerBlur"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 提示内容(Teleport 到 body) -->
|
||||||
|
<Teleport to="body" v-if="mounted">
|
||||||
|
<Transition name="tooltip-fade">
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
:id="ariaId"
|
||||||
|
ref="tooltipRef"
|
||||||
|
class="tooltip-content"
|
||||||
|
:class="[
|
||||||
|
`tooltip-${currentPlacement}`,
|
||||||
|
dark ? 'tooltip-dark' : 'tooltip-light',
|
||||||
|
props.trigger === 'hover' ? 'tooltip-noninteractive' : '',
|
||||||
|
]"
|
||||||
|
:style="tooltipInlineStyle"
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
<div class="tooltip-inner">
|
||||||
|
<slot name="content">
|
||||||
|
{{ content }}
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 箭头 -->
|
||||||
|
<div
|
||||||
|
class="tooltip-arrow"
|
||||||
|
:class="`tooltip-arrow-${currentPlacement}`"
|
||||||
|
:style="arrowStyle"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ref,
|
||||||
|
computed,
|
||||||
|
onMounted,
|
||||||
|
onBeforeUnmount,
|
||||||
|
nextTick,
|
||||||
|
watch,
|
||||||
|
defineProps,
|
||||||
|
defineEmits,
|
||||||
|
defineOptions,
|
||||||
|
useId,
|
||||||
|
} from 'vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'Tooltip' })
|
||||||
|
|
||||||
|
type Trigger = 'hover' | 'click' | 'focus' | 'manual'
|
||||||
|
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
content: { type: String, default: '' },
|
||||||
|
trigger: {
|
||||||
|
type: String as () => Trigger,
|
||||||
|
default: 'hover',
|
||||||
|
validator: (v: string) => ['hover', 'click', 'focus', 'manual'].includes(v),
|
||||||
|
},
|
||||||
|
placement: {
|
||||||
|
type: String as () => Placement,
|
||||||
|
default: 'top',
|
||||||
|
validator: (v: string) => ['top', 'bottom', 'left', 'right'].includes(v),
|
||||||
|
},
|
||||||
|
dark: { type: Boolean, default: false },
|
||||||
|
delay: { type: Number, default: 100 },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
focusable: { type: Boolean, default: true },
|
||||||
|
offset: { type: Number, default: 8 },
|
||||||
|
maxWidth: { type: [String, Number], default: '200px' },
|
||||||
|
/** 隐藏延时(毫秒),hover 离开后等待一点点以防抖 */
|
||||||
|
hideDelay: { type: Number, default: 80 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'show'): void
|
||||||
|
(e: 'hide'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const wrapperRef = ref<HTMLElement | null>(null)
|
||||||
|
const tooltipRef = ref<HTMLElement | null>(null)
|
||||||
|
const visible = ref(false)
|
||||||
|
const currentPlacement = ref<Placement>(props.placement)
|
||||||
|
const ariaId = ref(`tooltip-${useId()}`)
|
||||||
|
const mounted = ref(false)
|
||||||
|
|
||||||
|
let showTimer: number | null = null
|
||||||
|
let hideTimer: number | null = null
|
||||||
|
let ro: ResizeObserver | null = null
|
||||||
|
let rafId: number | null = null
|
||||||
|
|
||||||
|
const maxWidthValue = computed(() => {
|
||||||
|
return typeof props.maxWidth === 'number' ? `${props.maxWidth}px` : props.maxWidth
|
||||||
|
})
|
||||||
|
|
||||||
|
const tooltipTransform = ref('translate3d(-9999px, -9999px, 0)')
|
||||||
|
|
||||||
|
const tooltipInlineStyle = computed(() => ({
|
||||||
|
position: 'fixed',
|
||||||
|
top: '0px',
|
||||||
|
left: '0px',
|
||||||
|
zIndex: 2000,
|
||||||
|
maxWidth: maxWidthValue.value,
|
||||||
|
transform: tooltipTransform.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const arrowStyle = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
const clearTimers = () => {
|
||||||
|
if (showTimer) {
|
||||||
|
window.clearTimeout(showTimer)
|
||||||
|
showTimer = null
|
||||||
|
}
|
||||||
|
if (hideTimer) {
|
||||||
|
window.clearTimeout(hideTimer)
|
||||||
|
hideTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = async () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
clearTimers()
|
||||||
|
showTimer = window.setTimeout(async () => {
|
||||||
|
visible.value = true
|
||||||
|
emit('show')
|
||||||
|
await nextTick()
|
||||||
|
updatePosition()
|
||||||
|
}, props.delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
clearTimers()
|
||||||
|
hideTimer = window.setTimeout(() => {
|
||||||
|
visible.value = false
|
||||||
|
emit('hide')
|
||||||
|
}, props.hideDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showImmediately = async () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
clearTimers()
|
||||||
|
visible.value = true
|
||||||
|
emit('show')
|
||||||
|
await nextTick()
|
||||||
|
updatePosition()
|
||||||
|
}
|
||||||
|
const hideImmediately = () => {
|
||||||
|
clearTimers()
|
||||||
|
visible.value = false
|
||||||
|
emit('hide')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发器事件
|
||||||
|
const onTriggerMouseEnter = () => {
|
||||||
|
if (props.trigger === 'hover') show()
|
||||||
|
}
|
||||||
|
const onTriggerMouseLeave = () => {
|
||||||
|
// 关键修改:hover 模式下,离开触发区即开始隐藏计时,不再保持可交互
|
||||||
|
if (props.trigger === 'hover') hide()
|
||||||
|
}
|
||||||
|
const onTriggerClick = () => {
|
||||||
|
if (props.trigger !== 'click') return
|
||||||
|
visible.value ? hideImmediately() : showImmediately()
|
||||||
|
}
|
||||||
|
const onTriggerFocus = () => {
|
||||||
|
if (props.trigger === 'focus') showImmediately()
|
||||||
|
}
|
||||||
|
const onTriggerBlur = () => {
|
||||||
|
if (props.trigger === 'focus') hideImmediately()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击外部关闭(只对 click 模式)
|
||||||
|
const onClickOutside = (e: MouseEvent) => {
|
||||||
|
if (props.trigger !== 'click') return
|
||||||
|
const w = wrapperRef.value
|
||||||
|
const t = tooltipRef.value
|
||||||
|
const target = e.target as Node
|
||||||
|
if (w && !w.contains(target) && t && !t.contains(target)) {
|
||||||
|
hideImmediately()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定位算法
|
||||||
|
function clamp(n: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(max, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeBasePosition(
|
||||||
|
placement: Placement,
|
||||||
|
triggerRect: DOMRect,
|
||||||
|
tooltipRect: DOMRect,
|
||||||
|
offset: number,
|
||||||
|
) {
|
||||||
|
const centerX = triggerRect.left + triggerRect.width / 2
|
||||||
|
const centerY = triggerRect.top + triggerRect.height / 2
|
||||||
|
|
||||||
|
switch (placement) {
|
||||||
|
case 'top':
|
||||||
|
return {
|
||||||
|
top: triggerRect.top - tooltipRect.height - offset,
|
||||||
|
left: centerX - tooltipRect.width / 2,
|
||||||
|
}
|
||||||
|
case 'bottom':
|
||||||
|
return {
|
||||||
|
top: triggerRect.bottom + offset,
|
||||||
|
left: centerX - tooltipRect.width / 2,
|
||||||
|
}
|
||||||
|
case 'left':
|
||||||
|
return {
|
||||||
|
top: centerY - tooltipRect.height / 2,
|
||||||
|
left: triggerRect.left - tooltipRect.width - offset,
|
||||||
|
}
|
||||||
|
case 'right':
|
||||||
|
return {
|
||||||
|
top: centerY - tooltipRect.height / 2,
|
||||||
|
left: triggerRect.right + offset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionWithSmartFlip(
|
||||||
|
preferred: Placement,
|
||||||
|
triggerRect: DOMRect,
|
||||||
|
tooltipRect: DOMRect,
|
||||||
|
offset: number,
|
||||||
|
) {
|
||||||
|
const padding = 8
|
||||||
|
const vw = window.innerWidth
|
||||||
|
const vh = window.innerHeight
|
||||||
|
|
||||||
|
let placement: Placement = preferred
|
||||||
|
let { top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!
|
||||||
|
|
||||||
|
const outTop = top < padding
|
||||||
|
const outBottom = top + tooltipRect.height > vh - padding
|
||||||
|
const outLeft = left < padding
|
||||||
|
const outRight = left + tooltipRect.width > vw - padding
|
||||||
|
|
||||||
|
if (
|
||||||
|
placement === 'top' &&
|
||||||
|
outTop &&
|
||||||
|
triggerRect.bottom + offset + tooltipRect.height <= vh - padding
|
||||||
|
) {
|
||||||
|
placement = 'bottom'
|
||||||
|
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||||
|
} else if (
|
||||||
|
placement === 'bottom' &&
|
||||||
|
outBottom &&
|
||||||
|
triggerRect.top - offset - tooltipRect.height >= padding
|
||||||
|
) {
|
||||||
|
placement = 'top'
|
||||||
|
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||||
|
} else if (
|
||||||
|
placement === 'left' &&
|
||||||
|
outLeft &&
|
||||||
|
triggerRect.right + offset + tooltipRect.width <= vw - padding
|
||||||
|
) {
|
||||||
|
placement = 'right'
|
||||||
|
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||||
|
} else if (
|
||||||
|
placement === 'right' &&
|
||||||
|
outRight &&
|
||||||
|
triggerRect.left - offset - tooltipRect.width >= padding
|
||||||
|
) {
|
||||||
|
placement = 'left'
|
||||||
|
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
top = clamp(top, padding, vh - tooltipRect.height - padding)
|
||||||
|
left = clamp(left, padding, vw - tooltipRect.width - padding)
|
||||||
|
|
||||||
|
const triggerCenterX = triggerRect.left + triggerRect.width / 2
|
||||||
|
const triggerCenterY = triggerRect.top + triggerRect.height / 2
|
||||||
|
const arrowLeft = clamp(triggerCenterX - left, 10, tooltipRect.width - 10)
|
||||||
|
const arrowTop = clamp(triggerCenterY - top, 10, tooltipRect.height - 10)
|
||||||
|
|
||||||
|
return { placement, top, left, arrowLeft, arrowTop }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (!wrapperRef.value || !tooltipRef.value || !visible.value) return
|
||||||
|
if (rafId) cancelAnimationFrame(rafId)
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
const triggerEl = wrapperRef.value!.querySelector('.tooltip-trigger') as HTMLElement | null
|
||||||
|
const tooltipEl = tooltipRef.value!
|
||||||
|
if (!triggerEl) return
|
||||||
|
|
||||||
|
const triggerRect = triggerEl.getBoundingClientRect()
|
||||||
|
const tooltipRect = tooltipEl.getBoundingClientRect()
|
||||||
|
const { placement, top, left, arrowLeft, arrowTop } = positionWithSmartFlip(
|
||||||
|
props.placement,
|
||||||
|
triggerRect,
|
||||||
|
tooltipRect,
|
||||||
|
props.offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
currentPlacement.value = placement
|
||||||
|
tooltipTransform.value = `translate3d(${Math.round(left)}px, ${Math.round(top)}px, 0)`
|
||||||
|
if (placement === 'top' || placement === 'bottom') {
|
||||||
|
arrowStyle.value = { '--arrow-left': `${Math.round(arrowLeft)}px` } as any
|
||||||
|
} else {
|
||||||
|
arrowStyle.value = { '--arrow-top': `${Math.round(arrowTop)}px` } as any
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnvChanged = () => {
|
||||||
|
if (visible.value) updatePosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.disabled,
|
||||||
|
(v) => {
|
||||||
|
if (v && visible.value) hideImmediately()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => props.placement,
|
||||||
|
() => {
|
||||||
|
if (visible.value) nextTick(updatePosition)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
watch(visible, (v) => {
|
||||||
|
if (!mounted.value) return
|
||||||
|
if (v) {
|
||||||
|
if ('ResizeObserver' in window && !ro) {
|
||||||
|
ro = new ResizeObserver(() => updatePosition())
|
||||||
|
if (tooltipRef.value) ro.observe(tooltipRef.value)
|
||||||
|
const triggerEl = wrapperRef.value?.querySelector('.tooltip-trigger') as HTMLElement | null
|
||||||
|
if (triggerEl) ro.observe(triggerEl)
|
||||||
|
}
|
||||||
|
updatePosition()
|
||||||
|
} else {
|
||||||
|
if (ro) {
|
||||||
|
ro.disconnect()
|
||||||
|
ro = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
mounted.value = true
|
||||||
|
window.addEventListener('resize', onEnvChanged, { passive: true })
|
||||||
|
window.addEventListener('scroll', onEnvChanged, { passive: true, capture: true })
|
||||||
|
document.addEventListener('click', onClickOutside, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimers()
|
||||||
|
if (rafId) cancelAnimationFrame(rafId)
|
||||||
|
if (ro) {
|
||||||
|
ro.disconnect()
|
||||||
|
ro = null
|
||||||
|
}
|
||||||
|
document.removeEventListener('click', onClickOutside, true)
|
||||||
|
window.removeEventListener('resize', onEnvChanged)
|
||||||
|
window.removeEventListener('scroll', onEnvChanged, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 暴露给父组件(manual 可用)
|
||||||
|
defineExpose({ show: showImmediately, hide: hideImmediately, updatePosition })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tooltip-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.tooltip-trigger {
|
||||||
|
display: inline-block;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.tooltip-trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-content {
|
||||||
|
will-change: transform;
|
||||||
|
pointer-events: auto; /* 默认允许交互(click/focus 模式) */
|
||||||
|
}
|
||||||
|
.tooltip-noninteractive {
|
||||||
|
/* hover 模式下禁用指针事件,避免移入浮层导致保持显示 */
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-inner {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-wrap: break-word;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题 */
|
||||||
|
.tooltip-light .tooltip-inner {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
border-color: var(--normal-border-color);
|
||||||
|
}
|
||||||
|
.tooltip-dark .tooltip-inner {
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 箭头(用 CSS 变量控制偏移) */
|
||||||
|
.tooltip-arrow {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部 */
|
||||||
|
.tooltip-top .tooltip-arrow-top {
|
||||||
|
bottom: -6px;
|
||||||
|
left: var(--arrow-left, 50%);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-width: 6px 6px 0 6px;
|
||||||
|
}
|
||||||
|
.tooltip-light.tooltip-top .tooltip-arrow-top {
|
||||||
|
border-color: var(--normal-border-color) transparent transparent transparent;
|
||||||
|
}
|
||||||
|
.tooltip-light.tooltip-top .tooltip-arrow-top::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -7px;
|
||||||
|
left: -6px;
|
||||||
|
border-width: 6px 6px 0 6px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--background-color) transparent transparent transparent;
|
||||||
|
}
|
||||||
|
.tooltip-dark.tooltip-top .tooltip-arrow-top {
|
||||||
|
border-color: rgba(0, 0, 0, 0.9) transparent transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部 */
|
||||||
|
.tooltip-bottom .tooltip-arrow-bottom {
|
||||||
|
top: -6px;
|
||||||
|
left: var(--arrow-left, 50%);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-width: 0 6px 6px 6px;
|
||||||
|
}
|
||||||
|
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom {
|
||||||
|
border-color: transparent transparent var(--normal-border-color) transparent;
|
||||||
|
}
|
||||||
|
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
left: -6px;
|
||||||
|
border-width: 0 6px 6px 6px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: transparent transparent var(--background-color) transparent;
|
||||||
|
}
|
||||||
|
.tooltip-dark.tooltip-bottom .tooltip-arrow-bottom {
|
||||||
|
border-color: transparent transparent rgba(0, 0, 0, 0.9) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧 */
|
||||||
|
.tooltip-left .tooltip-arrow-left {
|
||||||
|
right: -6px;
|
||||||
|
top: var(--arrow-top, 50%);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border-width: 6px 0 6px 6px;
|
||||||
|
}
|
||||||
|
.tooltip-light.tooltip-left .tooltip-arrow-left {
|
||||||
|
border-color: transparent transparent transparent var(--normal-border-color);
|
||||||
|
}
|
||||||
|
.tooltip-light.tooltip-left .tooltip-arrow-left::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: -7px;
|
||||||
|
border-width: 6px 0 6px 6px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: transparent transparent transparent var(--background-color);
|
||||||
|
}
|
||||||
|
.tooltip-dark.tooltip-left .tooltip-arrow-left {
|
||||||
|
border-color: transparent transparent transparent rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧 */
|
||||||
|
.tooltip-right .tooltip-arrow-right {
|
||||||
|
left: -6px;
|
||||||
|
top: var(--arrow-top, 50%);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border-width: 6px 6px 6px 0;
|
||||||
|
}
|
||||||
|
.tooltip-light.tooltip-right .tooltip-arrow-right {
|
||||||
|
border-color: transparent var(--normal-border-color) transparent transparent;
|
||||||
|
}
|
||||||
|
.tooltip-light.tooltip-right .tooltip-arrow-right::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: 1px;
|
||||||
|
border-width: 6px 6px 6px 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: transparent var(--background-color) transparent transparent;
|
||||||
|
}
|
||||||
|
.tooltip-dark.tooltip-right .tooltip-arrow-right {
|
||||||
|
border-color: transparent rgba(0, 0, 0, 0.9) transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 过渡动画 */
|
||||||
|
.tooltip-fade-enter-active,
|
||||||
|
.tooltip-fade-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.tooltip-fade-enter-from,
|
||||||
|
.tooltip-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 4px, 0) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式微调 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tooltip-inner {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,20 +11,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
name: 'UserList',
|
users: { type: Array, default: () => [] },
|
||||||
components: { BasePlaceholder },
|
})
|
||||||
props: {
|
|
||||||
users: { type: Array, default: () => [] },
|
const handleUserClick = (user) => {
|
||||||
},
|
navigateTo(`/users/${user.id}`, { replace: true })
|
||||||
methods: {
|
|
||||||
handleUserClick(user) {
|
|
||||||
this.$router.push(`/users/${user.id}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const WEBSITE_BASE_URL = 'https://www.open-isle.com'
|
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
export const API_BASE_URL = 'https://www.open-isle.com'
|
|
||||||
// export const API_BASE_URL = 'http://127.0.0.1:8081'
|
|
||||||
// export const API_BASE_URL = 'http://30.211.97.238:8081'
|
|
||||||
export const GOOGLE_CLIENT_ID =
|
|
||||||
'777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
|
|
||||||
export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ'
|
|
||||||
export const DISCORD_CLIENT_ID = '1394985417044000779'
|
|
||||||
export const TWITTER_CLIENT_ID = 'ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ'
|
|
||||||
|
|
||||||
// 重新导出 toast 功能,使用 composable 方式
|
|
||||||
export { toast } from './composables/useToast'
|
export { toast } from './composables/useToast'
|
||||||
|
|
||||||
|
export const API_DOMAIN = 'https://www.open-isle.com'
|
||||||
|
export const API_PORT = ''
|
||||||
|
export const API_BASE_URL = API_PORT ? `${API_DOMAIN}:${API_PORT}` : API_DOMAIN
|
||||||
|
|||||||
@@ -2,9 +2,20 @@ import { defineNuxtConfig } from 'nuxt/config'
|
|||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
ssr: true,
|
ssr: true,
|
||||||
// Ensure Vditor styles load before our overrides in global.css
|
runtimeConfig: {
|
||||||
css: ['vditor/dist/index.css', '~/assets/global.css'],
|
public: {
|
||||||
|
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || '',
|
||||||
|
websiteBaseUrl: process.env.NUXT_PUBLIC_WEBSITE_BASE_URL || '',
|
||||||
|
googleClientId: process.env.NUXT_PUBLIC_GOOGLE_CLIENT_ID || '',
|
||||||
|
githubClientId: process.env.NUXT_PUBLIC_GITHUB_CLIENT_ID || '',
|
||||||
|
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID || '',
|
||||||
|
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 确保 Vditor 样式在 global.css 覆盖前加载
|
||||||
|
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||||
app: {
|
app: {
|
||||||
|
pageTransition: { name: 'page', mode: 'out-in' },
|
||||||
head: {
|
head: {
|
||||||
script: [
|
script: [
|
||||||
{
|
{
|
||||||
@@ -16,7 +27,31 @@ export default defineNuxtConfig({
|
|||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
const theme = mode === 'dark' || mode === 'light' ? mode : (prefersDark ? 'dark' : 'light');
|
const theme = mode === 'dark' || mode === 'light' ? mode : (prefersDark ? 'dark' : 'light');
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
|
let themeColor = '#fff';
|
||||||
|
let themeStatus = 'default';
|
||||||
|
if (theme === 'dark') {
|
||||||
|
themeColor = '#333';
|
||||||
|
themeStatus = 'black-translucent';
|
||||||
|
} else {
|
||||||
|
themeColor = '#ffffff';
|
||||||
|
themeStatus = 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
const androidMeta = document.createElement('meta');
|
||||||
|
androidMeta.name = 'theme-color';
|
||||||
|
androidMeta.content = themeColor;
|
||||||
|
|
||||||
|
const iosMeta = document.createElement('meta');
|
||||||
|
iosMeta.name = 'apple-mobile-web-app-status-bar-style';
|
||||||
|
iosMeta.content = themeStatus;
|
||||||
|
|
||||||
|
document.head.appendChild(androidMeta);
|
||||||
|
document.head.appendChild(iosMeta);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Theme initialization failed:', e);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
@@ -42,5 +77,35 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
baseURL: '/',
|
||||||
|
buildAssetsDir: '/_nuxt/',
|
||||||
|
},
|
||||||
|
vue: {
|
||||||
|
compilerOptions: {
|
||||||
|
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
// increase warning limit and split large libraries into separate chunks
|
||||||
|
chunkSizeWarningLimit: 1024,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (id.includes('node_modules')) {
|
||||||
|
if (id.includes('vditor')) {
|
||||||
|
return 'vditor'
|
||||||
|
}
|
||||||
|
if (id.includes('echarts')) {
|
||||||
|
return 'echarts'
|
||||||
|
}
|
||||||
|
if (id.includes('highlight.js')) {
|
||||||
|
return 'highlight'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
4950
frontend_nuxt/package-lock.json
generated
4950
frontend_nuxt/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -41,8 +41,9 @@ import { use } from 'echarts/core'
|
|||||||
import { CanvasRenderer } from 'echarts/renderers'
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import VChart from 'vue-echarts'
|
import VChart from 'vue-echarts'
|
||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import { getToken } from '~/utils/auth'
|
import { getToken } from '~/utils/auth'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])
|
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])
|
||||||
|
|
||||||
|
|||||||
@@ -25,39 +25,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MilkTeaActivityComponent v-if="a.type === 'MILK_TEA'" />
|
<MilkTeaActivityComponent v-if="a.type === 'MILK_TEA'" />
|
||||||
|
<InviteCodeActivityComponent v-if="a.type === 'INVITE_POINTS'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
|
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
|
||||||
|
import InviteCodeActivityComponent from '~/components/InviteCodeActivityComponent.vue'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const activities = ref([])
|
||||||
name: 'ActivityListPageView',
|
const isLoadingActivities = ref(false)
|
||||||
components: { MilkTeaActivityComponent },
|
|
||||||
data() {
|
onMounted(async () => {
|
||||||
return {
|
isLoadingActivities.value = true
|
||||||
activities: [],
|
try {
|
||||||
TimeManager,
|
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||||
isLoadingActivities: false,
|
if (res.ok) {
|
||||||
|
activities.value = await res.json()
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
async mounted() {
|
console.error(e)
|
||||||
this.isLoadingActivities = true
|
} finally {
|
||||||
try {
|
isLoadingActivities.value = false
|
||||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
}
|
||||||
if (res.ok) {
|
})
|
||||||
this.activities = await res.json()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
this.isLoadingActivities = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -82,6 +77,7 @@ export default {
|
|||||||
background-color: var(--activity-card-background-color);
|
background-color: var(--activity-card-background-color);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-card-left-avatar-img {
|
.activity-card-left-avatar-img {
|
||||||
@@ -148,6 +144,10 @@ export default {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-card-normal-right {
|
||||||
|
width: calc(100% - 150px);
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.activity-card-left-avatar-img {
|
.activity-card-left-avatar-img {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
|
|||||||
@@ -1,25 +1,43 @@
|
|||||||
|
<!-- pages/discord-callback.vue -->
|
||||||
<template>
|
<template>
|
||||||
<CallbackPage />
|
<CallbackPage />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import CallbackPage from '~/components/CallbackPage.vue'
|
import CallbackPage from '~/components/CallbackPage.vue'
|
||||||
import { discordExchange } from '~/utils/discord'
|
import { discordExchange } from '~/utils/discord'
|
||||||
|
|
||||||
export default {
|
onMounted(async () => {
|
||||||
name: 'DiscordCallbackPageView',
|
const url = new URL(window.location.href)
|
||||||
components: { CallbackPage },
|
const code = url.searchParams.get('code') || ''
|
||||||
async mounted() {
|
const stateStr = url.searchParams.get('state') || ''
|
||||||
const url = new URL(window.location.href)
|
|
||||||
const code = url.searchParams.get('code')
|
|
||||||
const state = url.searchParams.get('state')
|
|
||||||
const result = await discordExchange(code, state, '')
|
|
||||||
|
|
||||||
if (result.needReason) {
|
// 从 state 解析 invite_token;兜底支持 query ?invite_token=
|
||||||
this.$router.push('/signup-reason?token=' + result.token)
|
let inviteToken = ''
|
||||||
} else {
|
if (stateStr) {
|
||||||
this.$router.push('/')
|
try {
|
||||||
}
|
const s = new URLSearchParams(stateStr)
|
||||||
},
|
inviteToken = s.get('invite_token') || s.get('invitetoken') || ''
|
||||||
}
|
} catch {}
|
||||||
|
}
|
||||||
|
// if (!inviteToken) {
|
||||||
|
// inviteToken =
|
||||||
|
// url.searchParams.get('invite_token') ||
|
||||||
|
// url.searchParams.get('invitetoken') ||
|
||||||
|
// ''
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
navigateTo('/login', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await discordExchange(code, inviteToken, '')
|
||||||
|
|
||||||
|
if (result.needReason) {
|
||||||
|
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||||
|
} else {
|
||||||
|
navigateTo('/', { replace: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<div class="forgot-page">
|
<div class="forgot-page">
|
||||||
<div class="forgot-content">
|
<div class="forgot-content">
|
||||||
<div class="forgot-title">找回密码</div>
|
<div class="forgot-title">找回密码</div>
|
||||||
|
|
||||||
<div v-if="step === 0" class="step-content">
|
<div v-if="step === 0" class="step-content">
|
||||||
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
||||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||||
@@ -19,109 +20,110 @@
|
|||||||
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
|
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
|
||||||
<div class="primary-button disabled" v-else>提交中...</div>
|
<div class="primary-button disabled" v-else>提交中...</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hint-message">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
使用 Google 注册的用户可使用对应的邮箱进行找回密码
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
export default {
|
import { useRoute } from 'vue-router'
|
||||||
name: 'ForgotPasswordPageView',
|
|
||||||
components: { BaseInput },
|
const config = useRuntimeConfig()
|
||||||
data() {
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
return {
|
|
||||||
step: 0,
|
const step = ref(0)
|
||||||
email: '',
|
const email = ref('')
|
||||||
code: '',
|
const code = ref('')
|
||||||
password: '',
|
const password = ref('')
|
||||||
token: '',
|
const token = ref('')
|
||||||
emailError: '',
|
const emailError = ref('')
|
||||||
passwordError: '',
|
const passwordError = ref('')
|
||||||
isSending: false,
|
const isSending = ref(false)
|
||||||
isVerifying: false,
|
const isVerifying = ref(false)
|
||||||
isResetting: false,
|
const isResetting = ref(false)
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (route.query.email) {
|
||||||
|
email.value = decodeURIComponent(route.query.email)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const sendCode = async () => {
|
||||||
|
if (!email.value) {
|
||||||
|
emailError.value = '邮箱不能为空'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
isSending.value = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email.value }),
|
||||||
|
})
|
||||||
|
isSending.value = false
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('验证码已发送')
|
||||||
|
step.value = 1
|
||||||
|
} else {
|
||||||
|
toast.error('请填写已注册邮箱')
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
mounted() {
|
isSending.value = false
|
||||||
if (this.$route.query.email) {
|
toast.error('发送失败')
|
||||||
this.email = decodeURIComponent(this.$route.query.email)
|
}
|
||||||
|
}
|
||||||
|
const verifyCode = async () => {
|
||||||
|
try {
|
||||||
|
isVerifying.value = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email.value, code: code.value }),
|
||||||
|
})
|
||||||
|
isVerifying.value = false
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
token.value = data.token
|
||||||
|
step.value = 2
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '验证失败')
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
methods: {
|
isVerifying.value = false
|
||||||
async sendCode() {
|
toast.error('验证失败')
|
||||||
if (!this.email) {
|
}
|
||||||
this.emailError = '邮箱不能为空'
|
}
|
||||||
return
|
const resetPassword = async () => {
|
||||||
}
|
if (!password.value) {
|
||||||
try {
|
passwordError.value = '密码不能为空'
|
||||||
this.isSending = true
|
return
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
|
}
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
isResetting.value = true
|
||||||
body: JSON.stringify({ email: this.email }),
|
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
|
||||||
})
|
method: 'POST',
|
||||||
this.isSending = false
|
headers: { 'Content-Type': 'application/json' },
|
||||||
if (res.ok) {
|
body: JSON.stringify({ token: token.value, password: password.value }),
|
||||||
toast.success('验证码已发送')
|
})
|
||||||
this.step = 1
|
isResetting.value = false
|
||||||
} else {
|
const data = await res.json()
|
||||||
toast.error('请填写已注册邮箱')
|
if (res.ok) {
|
||||||
}
|
toast.success('密码已重置')
|
||||||
} catch (e) {
|
navigateTo('/login', { replace: true })
|
||||||
this.isSending = false
|
} else if (data.field === 'password') {
|
||||||
toast.error('发送失败')
|
passwordError.value = data.error
|
||||||
}
|
} else {
|
||||||
},
|
toast.error(data.error || '重置失败')
|
||||||
async verifyCode() {
|
}
|
||||||
try {
|
} catch (e) {
|
||||||
this.isVerifying = true
|
isResetting.value = false
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
|
toast.error('重置失败')
|
||||||
method: 'POST',
|
}
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email: this.email, code: this.code }),
|
|
||||||
})
|
|
||||||
this.isVerifying = false
|
|
||||||
const data = await res.json()
|
|
||||||
if (res.ok) {
|
|
||||||
this.token = data.token
|
|
||||||
this.step = 2
|
|
||||||
} else {
|
|
||||||
toast.error(data.error || '验证失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.isVerifying = false
|
|
||||||
toast.error('验证失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async resetPassword() {
|
|
||||||
if (!this.password) {
|
|
||||||
this.passwordError = '密码不能为空'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.isResetting = true
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: this.token, password: this.password }),
|
|
||||||
})
|
|
||||||
this.isResetting = false
|
|
||||||
const data = await res.json()
|
|
||||||
if (res.ok) {
|
|
||||||
toast.success('密码已重置')
|
|
||||||
this.$router.push('/login')
|
|
||||||
} else if (data.field === 'password') {
|
|
||||||
this.passwordError = data.error
|
|
||||||
} else {
|
|
||||||
toast.error(data.error || '重置失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.isResetting = false
|
|
||||||
toast.error('重置失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -143,6 +145,21 @@ export default {
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.forgot-content .hint-message {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--blockquote-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-message i {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
.step-content {
|
.step-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,25 +1,44 @@
|
|||||||
|
<!-- pages/github-callback.vue -->
|
||||||
<template>
|
<template>
|
||||||
<CallbackPage />
|
<CallbackPage />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import CallbackPage from '~/components/CallbackPage.vue'
|
import CallbackPage from '~/components/CallbackPage.vue'
|
||||||
import { githubExchange } from '~/utils/github'
|
import { githubExchange } from '~/utils/github'
|
||||||
|
|
||||||
export default {
|
onMounted(async () => {
|
||||||
name: 'GithubCallbackPageView',
|
const url = new URL(window.location.href)
|
||||||
components: { CallbackPage },
|
const code = url.searchParams.get('code') || ''
|
||||||
async mounted() {
|
const state = url.searchParams.get('state') || ''
|
||||||
const url = new URL(window.location.href)
|
|
||||||
const code = url.searchParams.get('code')
|
|
||||||
const state = url.searchParams.get('state')
|
|
||||||
const result = await githubExchange(code, state, '')
|
|
||||||
|
|
||||||
if (result.needReason) {
|
// 从 state 中解析 invite_token(githubAuthorize 已把它放进 state)
|
||||||
this.$router.push('/signup-reason?token=' + result.token)
|
let inviteToken = ''
|
||||||
} else {
|
if (state) {
|
||||||
this.$router.push('/')
|
try {
|
||||||
}
|
const s = new URLSearchParams(state)
|
||||||
},
|
inviteToken = s.get('invite_token') || s.get('invitetoken') || ''
|
||||||
}
|
} catch {}
|
||||||
|
}
|
||||||
|
// 兜底:也支持直接跟在回调URL的查询参数上
|
||||||
|
// if (!inviteToken) {
|
||||||
|
// inviteToken =
|
||||||
|
// url.searchParams.get('invite_token') ||
|
||||||
|
// url.searchParams.get('invitetoken') ||
|
||||||
|
// ''
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
navigateTo('/login', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await githubExchange(code, inviteToken, '')
|
||||||
|
|
||||||
|
if (result.needReason) {
|
||||||
|
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||||
|
} else {
|
||||||
|
navigateTo('/', { replace: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,29 +2,41 @@
|
|||||||
<CallbackPage />
|
<CallbackPage />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import CallbackPage from '~/components/CallbackPage.vue'
|
import CallbackPage from '~/components/CallbackPage.vue'
|
||||||
import { googleAuthWithToken } from '~/utils/google'
|
import { googleAuthWithToken } from '~/utils/google'
|
||||||
|
|
||||||
export default {
|
onMounted(async () => {
|
||||||
name: 'GoogleCallbackPageView',
|
const hash = new URLSearchParams(window.location.hash.substring(1))
|
||||||
components: { CallbackPage },
|
const idToken = hash.get('id_token')
|
||||||
async mounted() {
|
|
||||||
const hash = new URLSearchParams(window.location.hash.substring(1))
|
// 优先从 state 中解析
|
||||||
const idToken = hash.get('id_token')
|
let inviteToken = ''
|
||||||
if (idToken) {
|
const stateStr = hash.get('state') || ''
|
||||||
await googleAuthWithToken(
|
if (stateStr) {
|
||||||
idToken,
|
const state = new URLSearchParams(stateStr)
|
||||||
() => {
|
inviteToken = state.get('invite_token') || ''
|
||||||
this.$router.push('/')
|
}
|
||||||
},
|
|
||||||
(token) => {
|
// 兜底:如果之前把 invite_token 放在回调 URL 的查询参数中
|
||||||
this.$router.push('/signup-reason?token=' + token)
|
// if (!inviteToken) {
|
||||||
},
|
// const query = new URLSearchParams(window.location.search)
|
||||||
)
|
// inviteToken = query.get('invite_token') || ''
|
||||||
} else {
|
// }
|
||||||
this.$router.push('/login')
|
|
||||||
}
|
if (idToken) {
|
||||||
},
|
await googleAuthWithToken(
|
||||||
}
|
idToken,
|
||||||
|
() => {
|
||||||
|
navigateTo('/', { replace: true })
|
||||||
|
},
|
||||||
|
(token) => {
|
||||||
|
navigateTo(`/signup-reason?token=${token}`, { replace: true })
|
||||||
|
},
|
||||||
|
{ inviteToken },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
navigateTo('/login', { replace: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home-page">
|
<div class="home-page">
|
||||||
<div v-if="!isMobile" class="search-container">
|
<div v-if="!isMobile" class="search-container">
|
||||||
<div class="search-title">一切可能,从此刻启航</div>
|
<div class="search-title">一切可能,从此刻启航,在此遇见灵感与共鸣</div>
|
||||||
<div class="search-subtitle">
|
|
||||||
愿你在此遇见灵感与共鸣。若有疑惑,欢迎发问,亦可在知识的海洋中搜寻答案。
|
|
||||||
</div>
|
|
||||||
<SearchDropdown />
|
<SearchDropdown />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isLoadingPosts && articles.length === 0" class="loading-container">
|
<div v-if="pendingFirst" class="loading-container">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,7 +57,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="article-item" v-for="article in articles" :key="article.id">
|
<div
|
||||||
|
v-if="!pendingFirst"
|
||||||
|
class="article-item"
|
||||||
|
v-for="article in articles"
|
||||||
|
:key="article.id"
|
||||||
|
>
|
||||||
<div class="article-main-container">
|
<div class="article-main-container">
|
||||||
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
|
<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.pinned" class="fas fa-thumbtack pinned-icon"></i>
|
||||||
@@ -104,320 +106,274 @@
|
|||||||
热门帖子功能开发中,敬请期待。
|
热门帖子功能开发中,敬请期待。
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
||||||
<div v-if="isLoadingPosts && articles.length > 0" class="loading-container bottom-loading">
|
<div v-if="isLoadingMore && articles.length > 0" class="loading-container bottom-loading">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<script setup>
|
||||||
<script>
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { ref, watch } from 'vue'
|
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||||
import { useRoute } from 'vue-router'
|
import ArticleTags from '~/components/ArticleTags.vue'
|
||||||
|
import CategorySelect from '~/components/CategorySelect.vue'
|
||||||
|
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||||
|
import TagSelect from '~/components/TagSelect.vue'
|
||||||
|
import { getToken } from '~/utils/auth'
|
||||||
import { useScrollLoadMore } from '~/utils/loadMore'
|
import { useScrollLoadMore } from '~/utils/loadMore'
|
||||||
import { stripMarkdown } from '~/utils/markdown'
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import { getToken } from '~/utils/auth'
|
|
||||||
import TimeManager from '~/utils/time'
|
|
||||||
import CategorySelect from '~/components/CategorySelect.vue'
|
|
||||||
import TagSelect from '~/components/TagSelect.vue'
|
|
||||||
import ArticleTags from '~/components/ArticleTags.vue'
|
|
||||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
|
||||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
import TimeManager from '~/utils/time'
|
||||||
|
|
||||||
export default {
|
useHead({
|
||||||
name: 'HomePageView',
|
title: 'OpenIsle - 全面开源的自由社区',
|
||||||
components: {
|
meta: [
|
||||||
CategorySelect,
|
{
|
||||||
TagSelect,
|
name: 'description',
|
||||||
ArticleTags,
|
content:
|
||||||
ArticleCategory,
|
'OpenIsle 是一个开放的技术与交流社区,致力于为开发者、技术爱好者和创作者们提供一个自由、友好、包容的讨论与协作环境。我们鼓励用户在这里分享知识、交流经验、提出问题、展示作品,并共同推动技术进步与社区成长。',
|
||||||
SearchDropdown,
|
},
|
||||||
ClientOnly: () =>
|
],
|
||||||
import('vue').then((m) =>
|
})
|
||||||
m.defineAsyncComponent(() => import('vue').then(() => ({ template: '<slot />' }))),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
async setup() {
|
|
||||||
useHead({
|
|
||||||
title: 'OpenIsle - 全面开源的自由社区',
|
|
||||||
meta: [
|
|
||||||
{
|
|
||||||
name: 'description',
|
|
||||||
content:
|
|
||||||
'OpenIsle 是一个开放的技术与交流社区,致力于为开发者、技术爱好者和创作者们提供一个自由、友好、包容的讨论与协作环境。我们鼓励用户在这里分享知识、交流经验、提出问题、展示作品,并共同推动技术进步与社区成长。',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
const route = useRoute()
|
|
||||||
const selectedCategory = ref('')
|
|
||||||
if (route.query.category) {
|
|
||||||
const c = decodeURIComponent(route.query.category)
|
|
||||||
selectedCategory.value = isNaN(c) ? c : Number(c)
|
|
||||||
}
|
|
||||||
const selectedTags = ref([])
|
|
||||||
if (route.query.tags) {
|
|
||||||
const t = Array.isArray(route.query.tags) ? route.query.tags.join(',') : route.query.tags
|
|
||||||
selectedTags.value = t
|
|
||||||
.split(',')
|
|
||||||
.filter((v) => v)
|
|
||||||
.map((v) => decodeURIComponent(v))
|
|
||||||
.map((v) => (isNaN(v) ? v : Number(v)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagOptions = ref([])
|
const config = useRuntimeConfig()
|
||||||
const categoryOptions = ref([])
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const isLoadingPosts = ref(false)
|
const selectedCategory = ref('')
|
||||||
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
const selectedTags = ref([])
|
||||||
const selectedTopic = ref(
|
const route = useRoute()
|
||||||
route.query.view === 'ranking'
|
const tagOptions = ref([])
|
||||||
? '排行榜'
|
const categoryOptions = ref([])
|
||||||
: route.query.view === 'latest'
|
|
||||||
? '最新'
|
|
||||||
: '最新回复',
|
|
||||||
)
|
|
||||||
|
|
||||||
const articles = ref([])
|
const isLoadingMore = ref(false)
|
||||||
const page = ref(0)
|
|
||||||
const pageSize = 10
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const allLoaded = ref(false)
|
|
||||||
|
|
||||||
const loadOptions = async () => {
|
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||||
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
const selectedTopicCookie = useCookie('homeTab')
|
||||||
try {
|
const selectedTopic = ref(
|
||||||
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
|
selectedTopicCookie.value
|
||||||
if (res.ok) {
|
? selectedTopicCookie.value
|
||||||
categoryOptions.value = [await res.json()]
|
: route.query.view === 'ranking'
|
||||||
}
|
? '排行榜'
|
||||||
} catch (e) {
|
: route.query.view === 'latest'
|
||||||
/* ignore */
|
? '最新'
|
||||||
}
|
: '最新回复',
|
||||||
}
|
)
|
||||||
|
if (!selectedTopicCookie.value) selectedTopicCookie.value = selectedTopic.value
|
||||||
|
const articles = ref([])
|
||||||
|
const page = ref(0)
|
||||||
|
const pageSize = 10
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const allLoaded = ref(false)
|
||||||
|
|
||||||
if (selectedTags.value.length) {
|
/** URL 参数 -> 本地筛选值 **/
|
||||||
const arr = []
|
const selectedCategorySet = (category) => {
|
||||||
for (const t of selectedTags.value) {
|
const c = decodeURIComponent(category)
|
||||||
if (!isNaN(t)) {
|
selectedCategory.value = isNaN(c) ? c : Number(c)
|
||||||
try {
|
|
||||||
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
|
|
||||||
if (r.ok) arr.push(await r.json())
|
|
||||||
} catch (e) {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tagOptions.value = arr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildUrl = () => {
|
|
||||||
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
|
|
||||||
if (selectedCategory.value) {
|
|
||||||
url += `&categoryId=${selectedCategory.value}`
|
|
||||||
}
|
|
||||||
if (selectedTags.value.length) {
|
|
||||||
selectedTags.value.forEach((t) => {
|
|
||||||
url += `&tagIds=${t}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildRankUrl = () => {
|
|
||||||
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}`
|
|
||||||
if (selectedCategory.value) {
|
|
||||||
url += `&categoryId=${selectedCategory.value}`
|
|
||||||
}
|
|
||||||
if (selectedTags.value.length) {
|
|
||||||
selectedTags.value.forEach((t) => {
|
|
||||||
url += `&tagIds=${t}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildReplyUrl = () => {
|
|
||||||
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}`
|
|
||||||
if (selectedCategory.value) {
|
|
||||||
url += `&categoryId=${selectedCategory.value}`
|
|
||||||
}
|
|
||||||
if (selectedTags.value.length) {
|
|
||||||
selectedTags.value.forEach((t) => {
|
|
||||||
url += `&tagIds=${t}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPosts = async (reset = false) => {
|
|
||||||
if (reset) {
|
|
||||||
page.value = 0
|
|
||||||
allLoaded.value = false
|
|
||||||
articles.value = []
|
|
||||||
}
|
|
||||||
if (isLoadingPosts.value || allLoaded.value) return
|
|
||||||
try {
|
|
||||||
isLoadingPosts.value = true
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(buildUrl(), {
|
|
||||||
headers: {
|
|
||||||
Authorization: token ? `Bearer ${token}` : '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
isLoadingPosts.value = false
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
articles.value.push(
|
|
||||||
...data.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
|
||||||
description: p.content,
|
|
||||||
category: p.category,
|
|
||||||
tags: p.tags || [],
|
|
||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
|
||||||
comments: p.commentCount,
|
|
||||||
views: p.views,
|
|
||||||
time: TimeManager.format(p.createdAt),
|
|
||||||
pinned: !!p.pinnedAt,
|
|
||||||
type: p.type,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
if (data.length < pageSize) {
|
|
||||||
allLoaded.value = true
|
|
||||||
} else {
|
|
||||||
page.value += 1
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchRanking = async (reset = false) => {
|
|
||||||
if (reset) {
|
|
||||||
page.value = 0
|
|
||||||
allLoaded.value = false
|
|
||||||
articles.value = []
|
|
||||||
}
|
|
||||||
if (isLoadingPosts.value || allLoaded.value) return
|
|
||||||
try {
|
|
||||||
isLoadingPosts.value = true
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(buildRankUrl(), {
|
|
||||||
headers: {
|
|
||||||
Authorization: token ? `Bearer ${token}` : '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
isLoadingPosts.value = false
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
articles.value.push(
|
|
||||||
...data.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
|
||||||
description: p.content,
|
|
||||||
category: p.category,
|
|
||||||
tags: p.tags || [],
|
|
||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
|
||||||
comments: p.commentCount,
|
|
||||||
views: p.views,
|
|
||||||
time: TimeManager.format(p.createdAt),
|
|
||||||
pinned: !!p.pinnedAt,
|
|
||||||
type: p.type,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
if (data.length < pageSize) {
|
|
||||||
allLoaded.value = true
|
|
||||||
} else {
|
|
||||||
page.value += 1
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchLatestReply = async (reset = false) => {
|
|
||||||
if (reset) {
|
|
||||||
page.value = 0
|
|
||||||
allLoaded.value = false
|
|
||||||
articles.value = []
|
|
||||||
}
|
|
||||||
if (isLoadingPosts.value || allLoaded.value) return
|
|
||||||
try {
|
|
||||||
isLoadingPosts.value = true
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(buildReplyUrl(), {
|
|
||||||
headers: {
|
|
||||||
Authorization: token ? `Bearer ${token}` : '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
isLoadingPosts.value = false
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
articles.value.push(
|
|
||||||
...data.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
|
||||||
description: p.content,
|
|
||||||
category: p.category,
|
|
||||||
tags: p.tags || [],
|
|
||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
|
||||||
comments: p.commentCount,
|
|
||||||
views: p.views,
|
|
||||||
time: TimeManager.format(p.lastReplyAt || p.createdAt),
|
|
||||||
pinned: !!p.pinnedAt,
|
|
||||||
type: p.type,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
if (data.length < pageSize) {
|
|
||||||
allLoaded.value = true
|
|
||||||
} else {
|
|
||||||
page.value += 1
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchContent = async (reset = false) => {
|
|
||||||
if (selectedTopic.value === '排行榜') {
|
|
||||||
await fetchRanking(reset)
|
|
||||||
} else if (selectedTopic.value === '最新回复') {
|
|
||||||
await fetchLatestReply(reset)
|
|
||||||
} else {
|
|
||||||
await fetchPosts(reset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useScrollLoadMore(fetchContent)
|
|
||||||
|
|
||||||
watch([selectedCategory, selectedTags], () => {
|
|
||||||
fetchContent(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(selectedTopic, () => {
|
|
||||||
fetchContent(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
const sanitizeDescription = (text) => stripMarkdown(text)
|
|
||||||
|
|
||||||
await Promise.all([loadOptions(), fetchContent()])
|
|
||||||
|
|
||||||
return {
|
|
||||||
topics,
|
|
||||||
selectedTopic,
|
|
||||||
articles,
|
|
||||||
sanitizeDescription,
|
|
||||||
isLoadingPosts,
|
|
||||||
selectedCategory,
|
|
||||||
selectedTags,
|
|
||||||
tagOptions,
|
|
||||||
categoryOptions,
|
|
||||||
isMobile,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
const selectedTagsSet = (tags) => {
|
||||||
|
const t = Array.isArray(tags) ? tags.join(',') : tags
|
||||||
|
selectedTags.value = t
|
||||||
|
.split(',')
|
||||||
|
.filter((v) => v)
|
||||||
|
.map((v) => decodeURIComponent(v))
|
||||||
|
.map((v) => (isNaN(v) ? v : Number(v)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化:仅在客户端首渲染时根据路由同步一次 **/
|
||||||
|
onMounted(() => {
|
||||||
|
const { category, tags } = route.query
|
||||||
|
if (category) selectedCategorySet(category)
|
||||||
|
if (tags) selectedTagsSet(tags)
|
||||||
|
|
||||||
|
const saved = localStorage.getItem('homeTab')
|
||||||
|
if (saved) {
|
||||||
|
selectedTopic.value = saved
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 路由变更时同步筛选 **/
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
(query) => {
|
||||||
|
const category = query.category
|
||||||
|
const tags = query.tags
|
||||||
|
category && selectedCategorySet(category)
|
||||||
|
tags && selectedTagsSet(tags)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 选项加载(分类/标签名称回填) **/
|
||||||
|
const loadOptions = async () => {
|
||||||
|
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/categories/`)
|
||||||
|
if (res.ok) categoryOptions.value = [await res.json()]
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (selectedTags.value.length) {
|
||||||
|
const arr = []
|
||||||
|
for (const t of selectedTags.value) {
|
||||||
|
if (!isNaN(t)) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
|
||||||
|
if (r.ok) arr.push(await r.json())
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tagOptions.value = arr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表 API 路径与查询参数 **/
|
||||||
|
const baseQuery = computed(() => ({
|
||||||
|
categoryId: selectedCategory.value || undefined,
|
||||||
|
tagIds: selectedTags.value.length ? selectedTags.value : undefined,
|
||||||
|
}))
|
||||||
|
const listApiPath = computed(() => {
|
||||||
|
if (selectedTopic.value === '排行榜') return '/api/posts/ranking'
|
||||||
|
if (selectedTopic.value === '最新回复') return '/api/posts/latest-reply'
|
||||||
|
return '/api/posts'
|
||||||
|
})
|
||||||
|
const buildUrl = ({ pageNo }) => {
|
||||||
|
const url = new URL(`${API_BASE_URL}${listApiPath.value}`)
|
||||||
|
url.searchParams.set('page', pageNo)
|
||||||
|
url.searchParams.set('pageSize', pageSize)
|
||||||
|
if (baseQuery.value.categoryId) url.searchParams.set('categoryId', baseQuery.value.categoryId)
|
||||||
|
if (baseQuery.value.tagIds)
|
||||||
|
for (const t of baseQuery.value.tagIds) url.searchParams.append('tagIds', t)
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
const tokenHeader = computed(() => {
|
||||||
|
const token = getToken()
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** —— 首屏数据托管(SSR) —— **/
|
||||||
|
const asyncKey = computed(() => [
|
||||||
|
'home:firstpage',
|
||||||
|
selectedTopic.value,
|
||||||
|
String(baseQuery.value.categoryId ?? ''),
|
||||||
|
JSON.stringify(baseQuery.value.tagIds ?? []),
|
||||||
|
])
|
||||||
|
const {
|
||||||
|
data: firstPage,
|
||||||
|
pending: pendingFirst,
|
||||||
|
refresh: refreshFirst,
|
||||||
|
} = await useAsyncData(
|
||||||
|
() => asyncKey.value.join('::'),
|
||||||
|
async () => {
|
||||||
|
const res = await $fetch(buildUrl({ pageNo: 0 }), { headers: tokenHeader.value })
|
||||||
|
const data = Array.isArray(res) ? res : []
|
||||||
|
return data.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
description: p.content,
|
||||||
|
category: p.category,
|
||||||
|
tags: p.tags || [],
|
||||||
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
|
comments: p.commentCount,
|
||||||
|
views: p.views,
|
||||||
|
time: TimeManager.format(
|
||||||
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
|
),
|
||||||
|
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||||
|
type: p.type,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
server: true,
|
||||||
|
default: () => [],
|
||||||
|
watch: [selectedTopic, baseQuery],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 首屏/筛选变更:重置分页并灌入 firstPage **/
|
||||||
|
watch(
|
||||||
|
firstPage,
|
||||||
|
(data) => {
|
||||||
|
page.value = 0
|
||||||
|
articles.value = [...(data || [])]
|
||||||
|
allLoaded.value = (data?.length || 0) < pageSize
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** —— 滚动加载更多 —— **/
|
||||||
|
let inflight = null
|
||||||
|
const fetchNextPage = async () => {
|
||||||
|
if (allLoaded.value || pendingFirst.value || inflight) return
|
||||||
|
const nextPage = page.value + 1
|
||||||
|
isLoadingMore.value = true
|
||||||
|
inflight = $fetch(buildUrl({ pageNo: nextPage }), { headers: tokenHeader.value })
|
||||||
|
.then((res) => {
|
||||||
|
const data = Array.isArray(res) ? res : []
|
||||||
|
const mapped = data.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
description: p.content,
|
||||||
|
category: p.category,
|
||||||
|
tags: p.tags || [],
|
||||||
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
|
comments: p.commentCount,
|
||||||
|
views: p.views,
|
||||||
|
time: TimeManager.format(
|
||||||
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
|
),
|
||||||
|
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||||
|
type: p.type,
|
||||||
|
}))
|
||||||
|
articles.value.push(...mapped)
|
||||||
|
if (data.length < pageSize) {
|
||||||
|
allLoaded.value = true
|
||||||
|
} else {
|
||||||
|
page.value = nextPage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight = null
|
||||||
|
isLoadingMore.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定滚动加载(避免挂载瞬间触发) **/
|
||||||
|
let initialReady = false
|
||||||
|
const loadMoreGuarded = async () => {
|
||||||
|
if (!initialReady) return
|
||||||
|
await fetchNextPage()
|
||||||
|
}
|
||||||
|
useScrollLoadMore(loadMoreGuarded)
|
||||||
|
watch(
|
||||||
|
articles,
|
||||||
|
() => {
|
||||||
|
if (!initialReady && articles.value.length) initialReady = true
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 切换分类/标签/Tab:useAsyncData 已 watch,这里只需确保 options 加载 **/
|
||||||
|
watch([selectedCategory, selectedTags], () => {
|
||||||
|
loadOptions()
|
||||||
|
})
|
||||||
|
watch(selectedTopic, (val) => {
|
||||||
|
// 仅当需要额外选项时加载
|
||||||
|
loadOptions()
|
||||||
|
selectedTopicCookie.value = val
|
||||||
|
if (process.client) {
|
||||||
|
localStorage.setItem('homeTab', val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
||||||
|
if (import.meta.server) {
|
||||||
|
await loadOptions()
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
|
||||||
|
|
||||||
|
window.addEventListener('refresh-home', refreshFirst)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 其他工具函数 **/
|
||||||
|
const sanitizeDescription = (text) => stripMarkdown(text)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -431,8 +387,8 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
margin-top: 100px;
|
margin-top: 32px;
|
||||||
padding: 20px;
|
padding: 20px 20px 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -444,10 +400,6 @@ export default {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-subtitle {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -482,6 +434,7 @@ export default {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-item-container {
|
.topic-item-container {
|
||||||
@@ -601,6 +554,7 @@ export default {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: gray;
|
color: gray;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
|
line-clamp: 3;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -696,6 +650,7 @@ export default {
|
|||||||
.header-item.activity {
|
.header-item.activity {
|
||||||
width: 10%;
|
width: 10%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-member-avatar-item:nth-child(n + 4) {
|
.article-member-avatar-item:nth-child(n + 4) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
>找回密码</a
|
>找回密码</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hint-message">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
使用右侧第三方OAuth注册/登录的用户可使用对应的邮箱进行重设密码
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -51,8 +55,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { setToken, loadCurrentUser } from '~/utils/auth'
|
import { setToken, loadCurrentUser } from '~/utils/auth'
|
||||||
import { googleAuthorize } from '~/utils/google'
|
import { googleAuthorize } from '~/utils/google'
|
||||||
import { githubAuthorize } from '~/utils/github'
|
import { githubAuthorize } from '~/utils/github'
|
||||||
@@ -60,63 +64,56 @@ import { discordAuthorize } from '~/utils/discord'
|
|||||||
import { twitterAuthorize } from '~/utils/twitter'
|
import { twitterAuthorize } from '~/utils/twitter'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import { registerPush } from '~/utils/push'
|
import { registerPush } from '~/utils/push'
|
||||||
export default {
|
const config = useRuntimeConfig()
|
||||||
name: 'LoginPageView',
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
components: { BaseInput },
|
const username = ref('')
|
||||||
setup() {
|
const password = ref('')
|
||||||
return { googleAuthorize }
|
const isWaitingForLogin = ref(false)
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
isWaitingForLogin: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async submitLogin() {
|
|
||||||
try {
|
|
||||||
this.isWaitingForLogin = true
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username: this.username, password: this.password }),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (res.ok && data.token) {
|
|
||||||
setToken(data.token)
|
|
||||||
await loadCurrentUser()
|
|
||||||
toast.success('登录成功')
|
|
||||||
registerPush()
|
|
||||||
this.$router.push('/')
|
|
||||||
} else if (data.reason_code === 'NOT_VERIFIED') {
|
|
||||||
toast.info('当前邮箱未验证,已经为您重新发送验证码')
|
|
||||||
this.$router.push({ path: '/signup', query: { verify: 1, u: this.username } })
|
|
||||||
} else if (data.reason_code === 'IS_APPROVING') {
|
|
||||||
toast.info('您的注册正在审批中, 请留意邮件')
|
|
||||||
this.$router.push('/')
|
|
||||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
|
||||||
this.$router.push('/signup-reason?token=' + data.token)
|
|
||||||
} else {
|
|
||||||
toast.error(data.error || '登录失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('登录失败')
|
|
||||||
} finally {
|
|
||||||
this.isWaitingForLogin = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
loginWithGithub() {
|
const submitLogin = async () => {
|
||||||
githubAuthorize()
|
try {
|
||||||
},
|
isWaitingForLogin.value = true
|
||||||
loginWithDiscord() {
|
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||||
discordAuthorize()
|
method: 'POST',
|
||||||
},
|
headers: { 'Content-Type': 'application/json' },
|
||||||
loginWithTwitter() {
|
body: JSON.stringify({ username: username.value, password: password.value }),
|
||||||
twitterAuthorize()
|
})
|
||||||
},
|
const data = await res.json()
|
||||||
},
|
if (res.ok && data.token) {
|
||||||
|
setToken(data.token)
|
||||||
|
await loadCurrentUser()
|
||||||
|
toast.success('登录成功')
|
||||||
|
registerPush()
|
||||||
|
await navigateTo('/', { replace: true })
|
||||||
|
} else if (data.reason_code === 'NOT_VERIFIED') {
|
||||||
|
toast.info('当前邮箱未验证,已经为您重新发送验证码')
|
||||||
|
await navigateTo(
|
||||||
|
{ path: '/signup', query: { verify: '1', u: username.value } },
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
|
toast.info('您的注册正在审批中, 请留意邮件')
|
||||||
|
await navigateTo('/', { replace: true })
|
||||||
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
|
await navigateTo({ path: '/signup-reason', query: { token: data.token } }, { replace: true })
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '登录失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('登录失败')
|
||||||
|
} finally {
|
||||||
|
isWaitingForLogin.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginWithGithub = () => {
|
||||||
|
githubAuthorize()
|
||||||
|
}
|
||||||
|
const loginWithDiscord = () => {
|
||||||
|
discordAuthorize()
|
||||||
|
}
|
||||||
|
const loginWithTwitter = () => {
|
||||||
|
twitterAuthorize()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -266,6 +263,11 @@ export default {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hint-message {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.login-page {
|
.login-page {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -130,6 +130,12 @@
|
|||||||
申请进行奶茶兑换,联系方式是:{{ item.content }}
|
申请进行奶茶兑换,联系方式是:{{ item.content }}
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POINT_REDEEM' && !item.parentComment">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<span class="notif-user">{{ item.fromUser.username }} </span>
|
||||||
|
申请积分兑换,联系方式是:{{ item.content }}
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
<template v-else-if="item.type === 'REACTION' && item.post && !item.comment">
|
<template v-else-if="item.type === 'REACTION' && item.post && !item.comment">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章
|
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章
|
||||||
@@ -185,6 +191,32 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.type === 'LOTTERY_WIN'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
恭喜你在抽奖贴
|
||||||
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
中获奖
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'LOTTERY_DRAW'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
您的抽奖贴
|
||||||
|
<router-link
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</router-link>
|
||||||
|
已开奖
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
<template v-else-if="item.type === 'POST_UPDATED'">
|
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
您关注的帖子
|
您关注的帖子
|
||||||
@@ -478,347 +510,127 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { API_BASE_URL } from '~/main'
|
|
||||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
|
||||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||||
|
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||||
import NotificationContainer from '~/components/NotificationContainer.vue'
|
import NotificationContainer from '~/components/NotificationContainer.vue'
|
||||||
import { getToken, authState } from '~/utils/auth'
|
|
||||||
import { markNotificationsRead, fetchUnreadCount, notificationState } from '~/utils/notification'
|
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { stripMarkdownLength } from '~/utils/markdown'
|
import { stripMarkdownLength } from '~/utils/markdown'
|
||||||
|
import {
|
||||||
|
fetchNotifications,
|
||||||
|
fetchUnreadCount,
|
||||||
|
isLoadingMessage,
|
||||||
|
markRead,
|
||||||
|
notifications,
|
||||||
|
markAllRead,
|
||||||
|
} from '~/utils/notification'
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
import { reactionEmojiMap } from '~/utils/reactions'
|
|
||||||
|
|
||||||
export default {
|
const config = useRuntimeConfig()
|
||||||
name: 'MessagePageView',
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
components: { BaseTimeline, BasePlaceholder, NotificationContainer },
|
const route = useRoute()
|
||||||
setup() {
|
const selectedTab = ref(
|
||||||
const router = useRouter()
|
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
|
||||||
const route = useRoute()
|
)
|
||||||
const notifications = ref([])
|
const notificationPrefs = ref([])
|
||||||
const isLoadingMessage = ref(false)
|
const filteredNotifications = computed(() =>
|
||||||
const selectedTab = ref(
|
selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read),
|
||||||
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
|
)
|
||||||
)
|
|
||||||
const notificationPrefs = ref([])
|
|
||||||
const filteredNotifications = computed(() =>
|
|
||||||
selectedTab.value === 'all'
|
|
||||||
? notifications.value
|
|
||||||
: notifications.value.filter((n) => !n.read),
|
|
||||||
)
|
|
||||||
|
|
||||||
const markRead = async (id) => {
|
const fetchPrefs = async () => {
|
||||||
if (!id) return
|
notificationPrefs.value = await fetchNotificationPreferences()
|
||||||
const n = notifications.value.find((n) => n.id === id)
|
|
||||||
if (!n || n.read) return
|
|
||||||
n.read = true
|
|
||||||
if (notificationState.unreadCount > 0) notificationState.unreadCount--
|
|
||||||
const ok = await markNotificationsRead([id])
|
|
||||||
if (!ok) {
|
|
||||||
n.read = false
|
|
||||||
notificationState.unreadCount++
|
|
||||||
} else {
|
|
||||||
fetchUnreadCount()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const markAllRead = async () => {
|
|
||||||
// 除了 REGISTER_REQUEST 类型消息
|
|
||||||
const idsToMark = notifications.value
|
|
||||||
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
|
|
||||||
.map((n) => n.id)
|
|
||||||
if (idsToMark.length === 0) return
|
|
||||||
notifications.value.forEach((n) => {
|
|
||||||
if (n.type !== 'REGISTER_REQUEST') n.read = true
|
|
||||||
})
|
|
||||||
notificationState.unreadCount = notifications.value.filter((n) => !n.read).length
|
|
||||||
const ok = await markNotificationsRead(idsToMark)
|
|
||||||
if (!ok) {
|
|
||||||
notifications.value.forEach((n) => {
|
|
||||||
if (idsToMark.includes(n.id)) n.read = false
|
|
||||||
})
|
|
||||||
await fetchUnreadCount()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fetchUnreadCount()
|
|
||||||
if (authState.role === 'ADMIN') {
|
|
||||||
toast.success('已读所有消息(注册请求除外)')
|
|
||||||
} else {
|
|
||||||
toast.success('已读所有消息')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconMap = {
|
|
||||||
POST_VIEWED: 'fas fa-eye',
|
|
||||||
COMMENT_REPLY: 'fas fa-reply',
|
|
||||||
POST_REVIEWED: 'fas fa-shield-alt',
|
|
||||||
POST_REVIEW_REQUEST: 'fas fa-gavel',
|
|
||||||
POST_UPDATED: 'fas fa-comment-dots',
|
|
||||||
USER_ACTIVITY: 'fas fa-user',
|
|
||||||
FOLLOWED_POST: 'fas fa-feather-alt',
|
|
||||||
USER_FOLLOWED: 'fas fa-user-plus',
|
|
||||||
USER_UNFOLLOWED: 'fas fa-user-minus',
|
|
||||||
POST_SUBSCRIBED: 'fas fa-bookmark',
|
|
||||||
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
|
||||||
REGISTER_REQUEST: 'fas fa-user-clock',
|
|
||||||
ACTIVITY_REDEEM: 'fas fa-coffee',
|
|
||||||
MENTION: 'fas fa-at',
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchNotifications = async () => {
|
|
||||||
try {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) {
|
|
||||||
toast.error('请先登录')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isLoadingMessage.value = true
|
|
||||||
notifications.value = []
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
isLoadingMessage.value = false
|
|
||||||
if (!res.ok) {
|
|
||||||
toast.error('获取通知失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
for (const n of data) {
|
|
||||||
if (n.type === 'COMMENT_REPLY') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
src: n.comment.author.avatar,
|
|
||||||
iconClick: () => {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/users/${n.comment.author.id}`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'REACTION') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
emoji: reactionEmojiMap[n.reactionType],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.fromUser) {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/users/${n.fromUser.id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'POST_VIEWED') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
src: n.fromUser ? n.fromUser.avatar : null,
|
|
||||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.fromUser) {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/users/${n.fromUser.id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'POST_UPDATED') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
src: n.comment.author.avatar,
|
|
||||||
iconClick: () => {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/users/${n.comment.author.id}`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'USER_ACTIVITY') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
src: n.comment.author.avatar,
|
|
||||||
iconClick: () => {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/users/${n.comment.author.id}`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'MENTION') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
icon: iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.fromUser) {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/users/${n.fromUser.id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
icon: iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.fromUser) {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/users/${n.fromUser.id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'FOLLOWED_POST') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
icon: iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.post) {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/posts/${n.post.id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
icon: iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.post) {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/posts/${n.post.id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'POST_REVIEW_REQUEST') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
src: n.fromUser ? n.fromUser.avatar : null,
|
|
||||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.post) {
|
|
||||||
markRead(n.id)
|
|
||||||
router.push(`/posts/${n.post.id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'REGISTER_REQUEST') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
icon: iconMap[n.type],
|
|
||||||
iconClick: () => {},
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
icon: iconMap[n.type],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPrefs = async () => {
|
|
||||||
notificationPrefs.value = await fetchNotificationPreferences()
|
|
||||||
}
|
|
||||||
|
|
||||||
const togglePref = async (pref) => {
|
|
||||||
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
|
|
||||||
if (ok) {
|
|
||||||
pref.enabled = !pref.enabled
|
|
||||||
await fetchNotifications()
|
|
||||||
await fetchUnreadCount()
|
|
||||||
} else {
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const approve = async (id, nid) => {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) return
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
markRead(nid)
|
|
||||||
toast.success('已同意')
|
|
||||||
} else {
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const reject = async (id, nid) => {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) return
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
markRead(nid)
|
|
||||||
toast.success('已拒绝')
|
|
||||||
} else {
|
|
||||||
toast.error('操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatType = (t) => {
|
|
||||||
switch (t) {
|
|
||||||
case 'POST_VIEWED':
|
|
||||||
return '帖子被查看'
|
|
||||||
case 'COMMENT_REPLY':
|
|
||||||
return '有人回复了你'
|
|
||||||
case 'REACTION':
|
|
||||||
return '有人点赞'
|
|
||||||
case 'POST_REVIEW_REQUEST':
|
|
||||||
return '帖子待审核'
|
|
||||||
case 'POST_REVIEWED':
|
|
||||||
return '帖子审核结果'
|
|
||||||
case 'POST_UPDATED':
|
|
||||||
return '关注的帖子有新评论'
|
|
||||||
case 'FOLLOWED_POST':
|
|
||||||
return '关注的用户发布了新文章'
|
|
||||||
case 'POST_SUBSCRIBED':
|
|
||||||
return '有人订阅了你的文章'
|
|
||||||
case 'POST_UNSUBSCRIBED':
|
|
||||||
return '有人取消订阅你的文章'
|
|
||||||
case 'USER_FOLLOWED':
|
|
||||||
return '有人关注了你'
|
|
||||||
case 'USER_UNFOLLOWED':
|
|
||||||
return '有人取消关注你'
|
|
||||||
case 'USER_ACTIVITY':
|
|
||||||
return '关注的用户有新动态'
|
|
||||||
case 'MENTION':
|
|
||||||
return '有人提到了你'
|
|
||||||
case 'REGISTER_REQUEST':
|
|
||||||
return '有人申请注册'
|
|
||||||
case 'ACTIVITY_REDEEM':
|
|
||||||
return '有人申请兑换奶茶'
|
|
||||||
default:
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchNotifications()
|
|
||||||
fetchPrefs()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
notifications,
|
|
||||||
formatType,
|
|
||||||
isLoadingMessage,
|
|
||||||
stripMarkdownLength,
|
|
||||||
markRead,
|
|
||||||
approve,
|
|
||||||
reject,
|
|
||||||
TimeManager,
|
|
||||||
selectedTab,
|
|
||||||
filteredNotifications,
|
|
||||||
markAllRead,
|
|
||||||
authState,
|
|
||||||
notificationPrefs,
|
|
||||||
togglePref,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const togglePref = async (pref) => {
|
||||||
|
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
|
||||||
|
if (ok) {
|
||||||
|
pref.enabled = !pref.enabled
|
||||||
|
await fetchNotifications()
|
||||||
|
await fetchUnreadCount()
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const approve = async (id, nid) => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
markRead(nid)
|
||||||
|
toast.success('已同意')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reject = async (id, nid) => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
markRead(nid)
|
||||||
|
toast.success('已拒绝')
|
||||||
|
} else {
|
||||||
|
toast.error('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatType = (t) => {
|
||||||
|
switch (t) {
|
||||||
|
case 'POST_VIEWED':
|
||||||
|
return '帖子被查看'
|
||||||
|
case 'COMMENT_REPLY':
|
||||||
|
return '有人回复了你'
|
||||||
|
case 'REACTION':
|
||||||
|
return '有人点赞'
|
||||||
|
case 'POST_REVIEW_REQUEST':
|
||||||
|
return '帖子待审核'
|
||||||
|
case 'POST_REVIEWED':
|
||||||
|
return '帖子审核结果'
|
||||||
|
case 'POST_UPDATED':
|
||||||
|
return '关注的帖子有新评论'
|
||||||
|
case 'FOLLOWED_POST':
|
||||||
|
return '关注的用户发布了新文章'
|
||||||
|
case 'POST_SUBSCRIBED':
|
||||||
|
return '有人订阅了你的文章'
|
||||||
|
case 'POST_UNSUBSCRIBED':
|
||||||
|
return '有人取消订阅你的文章'
|
||||||
|
case 'USER_FOLLOWED':
|
||||||
|
return '有人关注了你'
|
||||||
|
case 'USER_UNFOLLOWED':
|
||||||
|
return '有人取消关注你'
|
||||||
|
case 'USER_ACTIVITY':
|
||||||
|
return '关注的用户有新动态'
|
||||||
|
case 'MENTION':
|
||||||
|
return '有人提到了你'
|
||||||
|
case 'REGISTER_REQUEST':
|
||||||
|
return '有人申请注册'
|
||||||
|
case 'ACTIVITY_REDEEM':
|
||||||
|
return '有人申请兑换奶茶'
|
||||||
|
case 'POINT_REDEEM':
|
||||||
|
return '有人申请积分兑换'
|
||||||
|
case 'LOTTERY_WIN':
|
||||||
|
return '抽奖中奖了'
|
||||||
|
case 'LOTTERY_DRAW':
|
||||||
|
return '抽奖已开奖'
|
||||||
|
default:
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
fetchNotifications()
|
||||||
|
fetchPrefs()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -832,6 +644,8 @@ export default {
|
|||||||
.message-page {
|
.message-page {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
height: calc(100vh - var(--header-height));
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-page-header {
|
.message-page-header {
|
||||||
@@ -843,6 +657,7 @@ export default {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-page-header-right {
|
.message-page-header-right {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import 'flatpickr/dist/flatpickr.css'
|
import 'flatpickr/dist/flatpickr.css'
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import FlatPickr from 'vue-flatpickr-component'
|
import FlatPickr from 'vue-flatpickr-component'
|
||||||
@@ -88,335 +88,299 @@ import LoginOverlay from '~/components/LoginOverlay.vue'
|
|||||||
import PostEditor from '~/components/PostEditor.vue'
|
import PostEditor from '~/components/PostEditor.vue'
|
||||||
import PostTypeSelect from '~/components/PostTypeSelect.vue'
|
import PostTypeSelect from '~/components/PostTypeSelect.vue'
|
||||||
import TagSelect from '~/components/TagSelect.vue'
|
import TagSelect from '~/components/TagSelect.vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const title = ref('')
|
||||||
name: 'NewPostPageView',
|
const content = ref('')
|
||||||
components: {
|
const selectedCategory = ref('')
|
||||||
PostEditor,
|
const selectedTags = ref([])
|
||||||
CategorySelect,
|
const postType = ref('NORMAL')
|
||||||
TagSelect,
|
const prizeIcon = ref('')
|
||||||
LoginOverlay,
|
const prizeIconFile = ref(null)
|
||||||
PostTypeSelect,
|
const tempPrizeIcon = ref('')
|
||||||
AvatarCropper,
|
const showPrizeCropper = ref(false)
|
||||||
FlatPickr,
|
const prizeName = ref('')
|
||||||
},
|
const prizeCount = ref(1)
|
||||||
setup() {
|
const prizeDescription = ref('')
|
||||||
const title = ref('')
|
const endTime = ref(null)
|
||||||
const content = ref('')
|
const startTime = ref(null)
|
||||||
const selectedCategory = ref('')
|
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
|
||||||
const selectedTags = ref([])
|
const isWaitingPosting = ref(false)
|
||||||
const postType = ref('NORMAL')
|
const isAiLoading = ref(false)
|
||||||
const prizeIcon = ref('')
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
const prizeIconFile = ref(null)
|
|
||||||
const tempPrizeIcon = ref('')
|
|
||||||
const showPrizeCropper = ref(false)
|
|
||||||
const prizeName = ref('')
|
|
||||||
const prizeCount = ref(1)
|
|
||||||
const prizeDescription = ref('')
|
|
||||||
const endTime = ref(null)
|
|
||||||
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 onPrizeIconChange = (e) => {
|
||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
if (file) {
|
if (file) {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
tempPrizeIcon.value = reader.result
|
tempPrizeIcon.value = reader.result
|
||||||
showPrizeCropper.value = true
|
showPrizeCropper.value = true
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onPrizeCropped = ({ file, url }) => {
|
const onPrizeCropped = ({ file, url }) => {
|
||||||
prizeIconFile.value = file
|
prizeIconFile.value = file
|
||||||
prizeIcon.value = url
|
prizeIcon.value = url
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(prizeCount, (val) => {
|
watch(prizeCount, (val) => {
|
||||||
if (!val || val < 1) prizeCount.value = 1
|
if (!val || val < 1) prizeCount.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadDraft = async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
|
if (res.ok && res.status !== 204) {
|
||||||
|
const data = await res.json()
|
||||||
|
title.value = data.title || ''
|
||||||
|
content.value = data.content || ''
|
||||||
|
selectedCategory.value = data.categoryId || ''
|
||||||
|
selectedTags.value = data.tagIds || []
|
||||||
|
|
||||||
const loadDraft = async () => {
|
toast.success('草稿已加载')
|
||||||
const token = getToken()
|
|
||||||
if (!token) return
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
if (res.ok && res.status !== 204) {
|
|
||||||
const data = await res.json()
|
|
||||||
title.value = data.title || ''
|
|
||||||
content.value = data.content || ''
|
|
||||||
selectedCategory.value = data.categoryId || ''
|
|
||||||
selectedTags.value = data.tagIds || []
|
|
||||||
|
|
||||||
toast.success('草稿已加载')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadDraft)
|
onMounted(loadDraft)
|
||||||
|
|
||||||
const clearPost = async () => {
|
const clearPost = async () => {
|
||||||
title.value = ''
|
title.value = ''
|
||||||
content.value = ''
|
content.value = ''
|
||||||
selectedCategory.value = ''
|
selectedCategory.value = ''
|
||||||
selectedTags.value = []
|
selectedTags.value = []
|
||||||
postType.value = 'NORMAL'
|
postType.value = 'NORMAL'
|
||||||
prizeIcon.value = ''
|
prizeIcon.value = ''
|
||||||
prizeIconFile.value = null
|
prizeIconFile.value = null
|
||||||
tempPrizeIcon.value = ''
|
tempPrizeIcon.value = ''
|
||||||
showPrizeCropper.value = false
|
showPrizeCropper.value = false
|
||||||
prizeDescription.value = ''
|
prizeDescription.value = ''
|
||||||
prizeCount.value = 1
|
prizeCount.value = 1
|
||||||
endTime.value = null
|
endTime.value = null
|
||||||
startTime.value = null
|
startTime.value = null
|
||||||
|
|
||||||
// 删除草稿
|
// 删除草稿
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (token) {
|
if (token) {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('草稿已清空')
|
toast.success('草稿已清空')
|
||||||
} else {
|
} else {
|
||||||
toast.error('云端草稿清空失败, 请稍后重试')
|
toast.error('云端草稿清空失败, 请稍后重试')
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saveDraft = async () => {
|
const saveDraft = async () => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
toast.error('请先登录')
|
toast.error('请先登录')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const tagIds = selectedTags.value.filter((t) => typeof t === 'number')
|
const tagIds = selectedTags.value.filter((t) => typeof t === 'number')
|
||||||
const res = await fetch(`${API_BASE_URL}/api/drafts`, {
|
const res = await fetch(`${API_BASE_URL}/api/drafts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: title.value,
|
title: title.value,
|
||||||
content: content.value,
|
content: content.value,
|
||||||
categoryId: selectedCategory.value || null,
|
categoryId: selectedCategory.value || null,
|
||||||
tagIds,
|
tagIds,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('草稿已保存')
|
toast.success('草稿已保存')
|
||||||
} else {
|
} else {
|
||||||
toast.error('保存失败')
|
toast.error('保存失败')
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('保存失败')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const ensureTags = async (token) => {
|
} catch (e) {
|
||||||
for (let i = 0; i < selectedTags.value.length; i++) {
|
toast.error('保存失败')
|
||||||
const t = selectedTags.value[i]
|
}
|
||||||
if (typeof t === 'string' && t.startsWith('__new__:')) {
|
}
|
||||||
const name = t.slice(8)
|
const ensureTags = async (token) => {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/tags`, {
|
for (let i = 0; i < selectedTags.value.length; i++) {
|
||||||
method: 'POST',
|
const t = selectedTags.value[i]
|
||||||
headers: {
|
if (typeof t === 'string' && t.startsWith('__new__:')) {
|
||||||
'Content-Type': 'application/json',
|
const name = t.slice(8)
|
||||||
Authorization: `Bearer ${token}`,
|
const res = await fetch(`${API_BASE_URL}/api/tags`, {
|
||||||
},
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, description: '' }),
|
headers: {
|
||||||
})
|
'Content-Type': 'application/json',
|
||||||
if (res.ok) {
|
Authorization: `Bearer ${token}`,
|
||||||
const data = await res.json()
|
},
|
||||||
selectedTags.value[i] = data.id
|
body: JSON.stringify({ name, description: '' }),
|
||||||
// update local TagSelect options handled by component
|
})
|
||||||
} else {
|
if (res.ok) {
|
||||||
let data
|
|
||||||
try {
|
|
||||||
data = await res.json()
|
|
||||||
} catch (e) {
|
|
||||||
data = null
|
|
||||||
}
|
|
||||||
toast.error((data && data.error) || '创建标签失败')
|
|
||||||
throw new Error('create tag failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiGenerate = async () => {
|
|
||||||
if (!content.value.trim()) {
|
|
||||||
toast.error('内容为空,无法优化')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isAiLoading.value = true
|
|
||||||
try {
|
|
||||||
toast.info('AI 优化中...')
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ text: content.value }),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
content.value = data.content || ''
|
|
||||||
} else if (res.status === 429) {
|
|
||||||
toast.error('今日AI优化次数已用尽')
|
|
||||||
} else {
|
|
||||||
toast.error('AI 优化失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('AI 优化失败')
|
|
||||||
} finally {
|
|
||||||
isAiLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitPost = async () => {
|
|
||||||
if (!title.value.trim()) {
|
|
||||||
toast.error('标题不能为空')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!content.value.trim()) {
|
|
||||||
toast.error('内容不能为空')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!selectedCategory.value) {
|
|
||||||
toast.error('请选择分类')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (selectedTags.value.length === 0) {
|
|
||||||
toast.error('请选择标签')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (postType.value === 'LOTTERY') {
|
|
||||||
if (!prizeIcon.value) {
|
|
||||||
toast.error('请上传奖品图片')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!prizeCount.value || prizeCount.value < 1) {
|
|
||||||
toast.error('奖品数量必须大于0')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!prizeDescription.value) {
|
|
||||||
toast.error('请输入奖品描述')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!endTime.value) {
|
|
||||||
toast.error('请选择抽奖结束时间')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const token = getToken()
|
|
||||||
await ensureTags(token)
|
|
||||||
isWaitingPosting.value = true
|
|
||||||
let prizeIconUrl = prizeIcon.value
|
|
||||||
if (postType.value === 'LOTTERY' && prizeIconFile.value) {
|
|
||||||
const form = new FormData()
|
|
||||||
form.append('file', prizeIconFile.value)
|
|
||||||
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
body: form,
|
|
||||||
})
|
|
||||||
const uploadData = await uploadRes.json()
|
|
||||||
if (!uploadRes.ok || uploadData.code !== 0) {
|
|
||||||
toast.error('奖品图片上传失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prizeIconUrl = uploadData.data.url
|
|
||||||
}
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: title.value,
|
|
||||||
content: content.value,
|
|
||||||
categoryId: selectedCategory.value,
|
|
||||||
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,
|
|
||||||
startTime:
|
|
||||||
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
|
||||||
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
|
|
||||||
endTime:
|
|
||||||
postType.value === 'LOTTERY'
|
|
||||||
? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
|
||||||
: undefined,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
selectedTags.value[i] = data.id
|
||||||
if (data.reward && data.reward > 0) {
|
// update local TagSelect options handled by component
|
||||||
toast.success(`发布成功,获得 ${data.reward} 经验值`)
|
} else {
|
||||||
} else {
|
let data
|
||||||
toast.success('发布成功')
|
try {
|
||||||
}
|
data = await res.json()
|
||||||
if (data.id) {
|
} catch (e) {
|
||||||
window.location.href = `/posts/${data.id}`
|
data = null
|
||||||
}
|
|
||||||
} else if (res.status === 429) {
|
|
||||||
toast.error('发布过于频繁,请稍后再试')
|
|
||||||
} else {
|
|
||||||
toast.error(data.error || '发布失败')
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
toast.error((data && data.error) || '创建标签失败')
|
||||||
toast.error('发布失败')
|
throw new Error('create tag failed')
|
||||||
} finally {
|
|
||||||
isWaitingPosting.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
}
|
||||||
title,
|
}
|
||||||
content,
|
|
||||||
selectedCategory,
|
const aiGenerate = async () => {
|
||||||
selectedTags,
|
if (!content.value.trim()) {
|
||||||
postType,
|
toast.error('内容为空,无法优化')
|
||||||
prizeIcon,
|
return
|
||||||
prizeCount,
|
}
|
||||||
endTime,
|
isAiLoading.value = true
|
||||||
submitPost,
|
try {
|
||||||
saveDraft,
|
toast.info('AI 优化中...')
|
||||||
clearPost,
|
const token = getToken()
|
||||||
isWaitingPosting,
|
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
|
||||||
aiGenerate,
|
method: 'POST',
|
||||||
isAiLoading,
|
headers: {
|
||||||
isLogin,
|
'Content-Type': 'application/json',
|
||||||
onPrizeIconChange,
|
Authorization: `Bearer ${token}`,
|
||||||
onPrizeCropped,
|
},
|
||||||
showPrizeCropper,
|
body: JSON.stringify({ text: content.value }),
|
||||||
tempPrizeIcon,
|
})
|
||||||
dateConfig,
|
if (res.ok) {
|
||||||
prizeName,
|
const data = await res.json()
|
||||||
prizeDescription,
|
content.value = data.content || ''
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
toast.error('今日AI优化次数已用尽')
|
||||||
|
} else {
|
||||||
|
toast.error('AI 优化失败')
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
|
toast.error('AI 优化失败')
|
||||||
|
} finally {
|
||||||
|
isAiLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitPost = async () => {
|
||||||
|
if (!title.value.trim()) {
|
||||||
|
toast.error('标题不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!content.value.trim()) {
|
||||||
|
toast.error('内容不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selectedCategory.value) {
|
||||||
|
toast.error('请选择分类')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedTags.value.length === 0) {
|
||||||
|
toast.error('请选择标签')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (postType.value === 'LOTTERY') {
|
||||||
|
if (!prizeIcon.value) {
|
||||||
|
toast.error('请上传奖品图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!prizeCount.value || prizeCount.value < 1) {
|
||||||
|
toast.error('奖品数量必须大于0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!prizeDescription.value) {
|
||||||
|
toast.error('请输入奖品描述')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!endTime.value) {
|
||||||
|
toast.error('请选择抽奖结束时间')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
await ensureTags(token)
|
||||||
|
isWaitingPosting.value = true
|
||||||
|
let prizeIconUrl = prizeIcon.value
|
||||||
|
if (postType.value === 'LOTTERY' && prizeIconFile.value) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', prizeIconFile.value)
|
||||||
|
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
const uploadData = await uploadRes.json()
|
||||||
|
if (!uploadRes.ok || uploadData.code !== 0) {
|
||||||
|
toast.error('奖品图片上传失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prizeIconUrl = uploadData.data.url
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/posts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title.value,
|
||||||
|
content: content.value,
|
||||||
|
categoryId: selectedCategory.value,
|
||||||
|
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,
|
||||||
|
startTime:
|
||||||
|
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
||||||
|
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
|
||||||
|
endTime:
|
||||||
|
postType.value === 'LOTTERY'
|
||||||
|
? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
if (data.reward && data.reward > 0) {
|
||||||
|
toast.success(`发布成功,获得 ${data.reward} 经验值`)
|
||||||
|
} else {
|
||||||
|
toast.success('发布成功')
|
||||||
|
}
|
||||||
|
if (data.id) {
|
||||||
|
window.location.href = `/posts/${data.id}`
|
||||||
|
}
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
toast.error('发布过于频繁,请稍后再试')
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '发布失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('发布失败')
|
||||||
|
} finally {
|
||||||
|
isWaitingPosting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
229
frontend_nuxt/pages/points.vue
Normal file
229
frontend_nuxt/pages/points.vue
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<template>
|
||||||
|
<div class="point-mall-page">
|
||||||
|
<section class="rules">
|
||||||
|
<div class="section-title">🎉 积分规则</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="loading-points-container" v-if="isLoading">
|
||||||
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="point-info">
|
||||||
|
<p v-if="authState.loggedIn && point !== null">
|
||||||
|
<span><i class="fas fa-coins coin-icon"></i></span>我的积分:<span class="point-value">{{
|
||||||
|
point
|
||||||
|
}}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="goods">
|
||||||
|
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
|
||||||
|
<img class="goods-item-image" :src="good.image" alt="good.name" />
|
||||||
|
<div class="goods-item-name">{{ good.name }}</div>
|
||||||
|
<div class="goods-item-cost">
|
||||||
|
<i class="fas fa-coins"></i>
|
||||||
|
{{ good.cost }} 积分
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="goods-item-button"
|
||||||
|
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
|
||||||
|
@click="openRedeem(good)"
|
||||||
|
>
|
||||||
|
兑换
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<RedeemPopup
|
||||||
|
:visible="dialogVisible"
|
||||||
|
v-model="contact"
|
||||||
|
:loading="loading"
|
||||||
|
@close="closeRedeem"
|
||||||
|
@submit="submitRedeem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { authState, fetchCurrentUser, getToken } from '~/utils/auth'
|
||||||
|
import { toast } from '~/main'
|
||||||
|
import RedeemPopup from '~/components/RedeemPopup.vue'
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
|
const point = ref(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
const pointRules = [
|
||||||
|
'发帖:每天前两次,每次 30 积分',
|
||||||
|
'评论:每天前四条评论可获 10 积分,你的帖子被评论也可获 10 积分',
|
||||||
|
'帖子被点赞:每次 10 积分',
|
||||||
|
'评论被点赞:每次 10 积分',
|
||||||
|
]
|
||||||
|
|
||||||
|
const goods = ref([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const contact = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const selectedGood = ref(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
if (authState.loggedIn) {
|
||||||
|
const user = await fetchCurrentUser()
|
||||||
|
point.value = user ? user.point : null
|
||||||
|
}
|
||||||
|
await loadGoods()
|
||||||
|
isLoading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadGoods = async () => {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/point-goods`)
|
||||||
|
if (res.ok) {
|
||||||
|
goods.value = await res.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openRedeem = (good) => {
|
||||||
|
if (!authState.loggedIn || point.value === null || point.value < good.cost) {
|
||||||
|
toast.error('积分不足')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedGood.value = good
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeRedeem = () => {
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitRedeem = async () => {
|
||||||
|
if (!selectedGood.value || !contact.value) return
|
||||||
|
loading.value = true
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/point-goods/redeem`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ goodId: selectedGood.value.id, contact: contact.value }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
point.value = data.point
|
||||||
|
toast.success('兑换成功!')
|
||||||
|
dialogVisible.value = false
|
||||||
|
contact.value = ''
|
||||||
|
} else {
|
||||||
|
toast.error('兑换失败')
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.point-mall-page {
|
||||||
|
padding-left: 20px;
|
||||||
|
max-width: var(--page-max-width);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-points-container {
|
||||||
|
margin-top: 100px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-info {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-value {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coin-icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules,
|
||||||
|
.goods {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goods {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goods-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goods-item-name {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goods-item-image {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goods-item-cost {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goods-item-button {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goods-item-button:hover {
|
||||||
|
background-color: var(--primary-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goods-item-button.disabled,
|
||||||
|
.goods-item-button.disabled:hover {
|
||||||
|
background-color: var(--primary-color-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -35,186 +35,168 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import PostEditor from '~/components/PostEditor.vue'
|
import PostEditor from '~/components/PostEditor.vue'
|
||||||
import CategorySelect from '~/components/CategorySelect.vue'
|
import CategorySelect from '~/components/CategorySelect.vue'
|
||||||
import TagSelect from '~/components/TagSelect.vue'
|
import TagSelect from '~/components/TagSelect.vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { getToken, authState } from '~/utils/auth'
|
import { getToken, authState } from '~/utils/auth'
|
||||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
export default {
|
const title = ref('')
|
||||||
name: 'EditPostPageView',
|
const content = ref('')
|
||||||
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay },
|
const selectedCategory = ref('')
|
||||||
setup() {
|
const selectedTags = ref([])
|
||||||
const title = ref('')
|
const isWaitingPosting = ref(false)
|
||||||
const content = ref('')
|
const isAiLoading = ref(false)
|
||||||
const selectedCategory = ref('')
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
const selectedTags = ref([])
|
|
||||||
const isWaitingPosting = ref(false)
|
|
||||||
const isAiLoading = ref(false)
|
|
||||||
const isLogin = computed(() => authState.loggedIn)
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const postId = route.params.id
|
||||||
const postId = route.params.id
|
|
||||||
|
|
||||||
const loadPost = async () => {
|
const loadPost = async () => {
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
title.value = data.title || ''
|
title.value = data.title || ''
|
||||||
content.value = data.content || ''
|
content.value = data.content || ''
|
||||||
selectedCategory.value = data.category.id || ''
|
selectedCategory.value = data.category.id || ''
|
||||||
selectedTags.value = (data.tags || []).map((t) => t.id)
|
selectedTags.value = (data.tags || []).map((t) => t.id)
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('加载失败')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('加载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadPost)
|
onMounted(loadPost)
|
||||||
|
|
||||||
const clearPost = () => {
|
const clearPost = () => {
|
||||||
title.value = ''
|
title.value = ''
|
||||||
content.value = ''
|
content.value = ''
|
||||||
selectedCategory.value = ''
|
selectedCategory.value = ''
|
||||||
selectedTags.value = []
|
selectedTags.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureTags = async (token) => {
|
const ensureTags = async (token) => {
|
||||||
for (let i = 0; i < selectedTags.value.length; i++) {
|
for (let i = 0; i < selectedTags.value.length; i++) {
|
||||||
const t = selectedTags.value[i]
|
const t = selectedTags.value[i]
|
||||||
if (typeof t === 'string' && t.startsWith('__new__:')) {
|
if (typeof t === 'string' && t.startsWith('__new__:')) {
|
||||||
const name = t.slice(8)
|
const name = t.slice(8)
|
||||||
const res = await fetch(`${API_BASE_URL}/api/tags`, {
|
const res = await fetch(`${API_BASE_URL}/api/tags`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name, description: '' }),
|
body: JSON.stringify({ name, description: '' }),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
|
||||||
selectedTags.value[i] = data.id
|
|
||||||
// update local TagSelect options handled by component
|
|
||||||
} else {
|
|
||||||
let data
|
|
||||||
try {
|
|
||||||
data = await res.json()
|
|
||||||
} catch (e) {
|
|
||||||
data = null
|
|
||||||
}
|
|
||||||
toast.error((data && data.error) || '创建标签失败')
|
|
||||||
throw new Error('create tag failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiGenerate = async () => {
|
|
||||||
if (!content.value.trim()) {
|
|
||||||
toast.error('内容为空,无法优化')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isAiLoading.value = true
|
|
||||||
try {
|
|
||||||
toast.info('AI 优化中...')
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ text: content.value }),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
content.value = data.content || ''
|
|
||||||
} else if (res.status === 429) {
|
|
||||||
toast.error('今日AI优化次数已用尽')
|
|
||||||
} else {
|
|
||||||
toast.error('AI 优化失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('AI 优化失败')
|
|
||||||
} finally {
|
|
||||||
isAiLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitPost = async () => {
|
|
||||||
if (!title.value.trim()) {
|
|
||||||
toast.error('标题不能为空')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!content.value.trim()) {
|
|
||||||
toast.error('内容不能为空')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!selectedCategory.value) {
|
|
||||||
toast.error('请选择分类')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (selectedTags.value.length === 0) {
|
|
||||||
toast.error('请选择标签')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const token = getToken()
|
|
||||||
await ensureTags(token)
|
|
||||||
isWaitingPosting.value = true
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: title.value,
|
|
||||||
content: content.value,
|
|
||||||
categoryId: selectedCategory.value,
|
|
||||||
tagIds: selectedTags.value,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
selectedTags.value[i] = data.id
|
||||||
toast.success('更新成功')
|
// update local TagSelect options handled by component
|
||||||
window.location.href = `/posts/${postId}`
|
} else {
|
||||||
} else {
|
let data
|
||||||
toast.error(data.error || '更新失败')
|
try {
|
||||||
|
data = await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
data = null
|
||||||
}
|
}
|
||||||
} catch (e) {
|
toast.error((data && data.error) || '创建标签失败')
|
||||||
toast.error('更新失败')
|
throw new Error('create tag failed')
|
||||||
} finally {
|
|
||||||
isWaitingPosting.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cancelEdit = () => {
|
}
|
||||||
router.push(`/posts/${postId}`)
|
}
|
||||||
|
|
||||||
|
const aiGenerate = async () => {
|
||||||
|
if (!content.value.trim()) {
|
||||||
|
toast.error('内容为空,无法优化')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isAiLoading.value = true
|
||||||
|
try {
|
||||||
|
toast.info('AI 优化中...')
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/ai/format`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text: content.value }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
content.value = data.content || ''
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
toast.error('今日AI优化次数已用尽')
|
||||||
|
} else {
|
||||||
|
toast.error('AI 优化失败')
|
||||||
}
|
}
|
||||||
return {
|
} catch (e) {
|
||||||
title,
|
toast.error('AI 优化失败')
|
||||||
content,
|
} finally {
|
||||||
selectedCategory,
|
isAiLoading.value = false
|
||||||
selectedTags,
|
}
|
||||||
submitPost,
|
}
|
||||||
clearPost,
|
|
||||||
cancelEdit,
|
const submitPost = async () => {
|
||||||
isWaitingPosting,
|
if (!title.value.trim()) {
|
||||||
aiGenerate,
|
toast.error('标题不能为空')
|
||||||
isAiLoading,
|
return
|
||||||
isLogin,
|
}
|
||||||
|
if (!content.value.trim()) {
|
||||||
|
toast.error('内容不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selectedCategory.value) {
|
||||||
|
toast.error('请选择分类')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedTags.value.length === 0) {
|
||||||
|
toast.error('请选择标签')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
await ensureTags(token)
|
||||||
|
isWaitingPosting.value = true
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title.value,
|
||||||
|
content: content.value,
|
||||||
|
categoryId: selectedCategory.value,
|
||||||
|
tagIds: selectedTags.value,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('更新成功')
|
||||||
|
window.location.href = `/posts/${postId}`
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '更新失败')
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
|
toast.error('更新失败')
|
||||||
|
} finally {
|
||||||
|
isWaitingPosting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cancelEdit = () => {
|
||||||
|
navigateTo(`/posts/${postId}`, { replace: true })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
|||||||
<BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." />
|
<BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." />
|
||||||
<div class="setting-description">自我介绍会出现在你的个人主页,可以简要介绍自己</div>
|
<div class="setting-description">自我介绍会出现在你的个人主页,可以简要介绍自己</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row switch-row">
|
||||||
|
<div class="setting-title">毛玻璃效果</div>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" v-model="frosted" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="role === 'ADMIN'" class="admin-section">
|
<div v-if="role === 'ADMIN'" class="admin-section">
|
||||||
<h3>管理员设置</h3>
|
<h3>管理员设置</h3>
|
||||||
@@ -64,173 +71,172 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import AvatarCropper from '~/components/AvatarCropper.vue'
|
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
import { API_BASE_URL, toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
||||||
export default {
|
import { frostedState, setFrosted } from '~/utils/frosted'
|
||||||
name: 'SettingsPageView',
|
const config = useRuntimeConfig()
|
||||||
components: { BaseInput, Dropdown, AvatarCropper },
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
data() {
|
const username = ref('')
|
||||||
return {
|
const introduction = ref('')
|
||||||
username: '',
|
const usernameError = ref('')
|
||||||
introduction: '',
|
const avatar = ref('')
|
||||||
usernameError: '',
|
const avatarFile = ref(null)
|
||||||
avatar: '',
|
const tempAvatar = ref('')
|
||||||
avatarFile: null,
|
const showCropper = ref(false)
|
||||||
tempAvatar: '',
|
const role = ref('')
|
||||||
showCropper: false,
|
const publishMode = ref('DIRECT')
|
||||||
role: '',
|
const passwordStrength = ref('LOW')
|
||||||
publishMode: 'DIRECT',
|
const aiFormatLimit = ref(3)
|
||||||
passwordStrength: 'LOW',
|
const registerMode = ref('DIRECT')
|
||||||
aiFormatLimit: 3,
|
const isLoadingPage = ref(false)
|
||||||
registerMode: 'DIRECT',
|
const isSaving = ref(false)
|
||||||
isLoadingPage: false,
|
const frosted = ref(true)
|
||||||
isSaving: false,
|
|
||||||
|
onMounted(async () => {
|
||||||
|
isLoadingPage.value = true
|
||||||
|
const user = await fetchCurrentUser()
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
username.value = user.username
|
||||||
|
introduction.value = user.introduction || ''
|
||||||
|
avatar.value = user.avatar
|
||||||
|
role.value = user.role
|
||||||
|
if (role.value === 'ADMIN') {
|
||||||
|
loadAdminConfig()
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
async mounted() {
|
toast.error('请先登录')
|
||||||
this.isLoadingPage = true
|
navigateTo('/login', { replace: true })
|
||||||
const user = await fetchCurrentUser()
|
}
|
||||||
|
isLoadingPage.value = false
|
||||||
|
frosted.value = frostedState.enabled
|
||||||
|
})
|
||||||
|
|
||||||
if (user) {
|
const onAvatarChange = (e) => {
|
||||||
this.username = user.username
|
const file = e.target.files[0]
|
||||||
this.introduction = user.introduction || ''
|
if (file) {
|
||||||
this.avatar = user.avatar
|
const reader = new FileReader()
|
||||||
this.role = user.role
|
reader.onload = () => {
|
||||||
if (this.role === 'ADMIN') {
|
tempAvatar.value = reader.result
|
||||||
this.loadAdminConfig()
|
showCropper.value = true
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error('请先登录')
|
|
||||||
this.$router.push('/login')
|
|
||||||
}
|
}
|
||||||
this.isLoadingPage = false
|
reader.readAsDataURL(file)
|
||||||
},
|
}
|
||||||
methods: {
|
}
|
||||||
onAvatarChange(e) {
|
watch(frosted, (val) => setFrosted(val))
|
||||||
const file = e.target.files[0]
|
const onCropped = ({ file, url }) => {
|
||||||
if (file) {
|
avatarFile.value = file
|
||||||
const reader = new FileReader()
|
avatar.value = url
|
||||||
reader.onload = () => {
|
}
|
||||||
this.tempAvatar = reader.result
|
const fetchPublishModes = () => {
|
||||||
this.showCropper = true
|
return Promise.resolve([
|
||||||
}
|
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
|
||||||
reader.readAsDataURL(file)
|
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const fetchPasswordStrengths = () => {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ id: 'LOW', name: '低', icon: 'fas fa-lock-open' },
|
||||||
|
{ id: 'MEDIUM', name: '中', icon: 'fas fa-lock' },
|
||||||
|
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const fetchAiLimits = () => {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ id: 3, name: '3次' },
|
||||||
|
{ id: 5, name: '5次' },
|
||||||
|
{ id: 10, name: '10次' },
|
||||||
|
{ id: -1, name: '无限' },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const fetchRegisterModes = () => {
|
||||||
|
return Promise.resolve([
|
||||||
|
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
|
||||||
|
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const loadAdminConfig = async () => {
|
||||||
|
try {
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
publishMode.value = data.publishMode
|
||||||
|
passwordStrength.value = data.passwordStrength
|
||||||
|
aiFormatLimit.value = data.aiFormatLimit
|
||||||
|
registerMode.value = data.registerMode
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const save = async () => {
|
||||||
|
isSaving.value = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
let token = getToken()
|
||||||
|
usernameError.value = ''
|
||||||
|
if (!username.value) {
|
||||||
|
usernameError.value = '用户名不能为空'
|
||||||
|
}
|
||||||
|
if (usernameError.value) {
|
||||||
|
toast.error(usernameError.value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (avatarFile.value) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', avatarFile.value)
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
avatar.value = data.url
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || '上传失败')
|
||||||
|
break
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
onCropped({ file, url }) {
|
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||||
this.avatarFile = file
|
method: 'PUT',
|
||||||
this.avatar = url
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
},
|
body: JSON.stringify({ username: username.value, introduction: introduction.value }),
|
||||||
fetchPublishModes() {
|
})
|
||||||
return Promise.resolve([
|
|
||||||
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
|
|
||||||
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' },
|
|
||||||
])
|
|
||||||
},
|
|
||||||
fetchPasswordStrengths() {
|
|
||||||
return Promise.resolve([
|
|
||||||
{ id: 'LOW', name: '低', icon: 'fas fa-lock-open' },
|
|
||||||
{ id: 'MEDIUM', name: '中', icon: 'fas fa-lock' },
|
|
||||||
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' },
|
|
||||||
])
|
|
||||||
},
|
|
||||||
fetchAiLimits() {
|
|
||||||
return Promise.resolve([
|
|
||||||
{ id: 3, name: '3次' },
|
|
||||||
{ id: 5, name: '5次' },
|
|
||||||
{ id: 10, name: '10次' },
|
|
||||||
{ id: -1, name: '无限' },
|
|
||||||
])
|
|
||||||
},
|
|
||||||
fetchRegisterModes() {
|
|
||||||
return Promise.resolve([
|
|
||||||
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
|
|
||||||
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' },
|
|
||||||
])
|
|
||||||
},
|
|
||||||
async loadAdminConfig() {
|
|
||||||
try {
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
this.publishMode = data.publishMode
|
|
||||||
this.passwordStrength = data.passwordStrength
|
|
||||||
this.aiFormatLimit = data.aiFormatLimit
|
|
||||||
this.registerMode = data.registerMode
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async save() {
|
|
||||||
this.isSaving = true
|
|
||||||
|
|
||||||
do {
|
const data = await res.json()
|
||||||
let token = getToken()
|
if (!res.ok) {
|
||||||
this.usernameError = ''
|
toast.error(data.error || '保存失败')
|
||||||
if (!this.username) {
|
break
|
||||||
this.usernameError = '用户名不能为空'
|
}
|
||||||
}
|
if (data.token) {
|
||||||
if (this.usernameError) {
|
setToken(data.token)
|
||||||
toast.error(this.usernameError)
|
token = data.token
|
||||||
break
|
}
|
||||||
}
|
if (role.value === 'ADMIN') {
|
||||||
if (this.avatarFile) {
|
await fetch(`${API_BASE_URL}/api/admin/config`, {
|
||||||
const form = new FormData()
|
method: 'POST',
|
||||||
form.append('file', this.avatarFile)
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
|
body: JSON.stringify({
|
||||||
method: 'POST',
|
publishMode: publishMode.value,
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
passwordStrength: passwordStrength.value,
|
||||||
body: form,
|
aiFormatLimit: aiFormatLimit.value,
|
||||||
})
|
registerMode: registerMode.value,
|
||||||
const data = await res.json()
|
}),
|
||||||
if (res.ok) {
|
})
|
||||||
this.avatar = data.url
|
}
|
||||||
} else {
|
toast.success('保存成功')
|
||||||
toast.error(data.error || '上传失败')
|
} while (!isSaving.value)
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
||||||
body: JSON.stringify({ username: this.username, introduction: this.introduction }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await res.json()
|
isSaving.value = false
|
||||||
if (!res.ok) {
|
|
||||||
toast.error(data.error || '保存失败')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (data.token) {
|
|
||||||
setToken(data.token)
|
|
||||||
token = data.token
|
|
||||||
}
|
|
||||||
if (this.role === 'ADMIN') {
|
|
||||||
await fetch(`${API_BASE_URL}/api/admin/config`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
||||||
body: JSON.stringify({
|
|
||||||
publishMode: this.publishMode,
|
|
||||||
passwordStrength: this.passwordStrength,
|
|
||||||
aiFormatLimit: this.aiFormatLimit,
|
|
||||||
registerMode: this.registerMode,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
toast.success('保存成功')
|
|
||||||
} while (!this.isSaving)
|
|
||||||
|
|
||||||
this.isSaving = false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -305,6 +311,58 @@ export default {
|
|||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #ccc;
|
||||||
|
transition: 0.2s;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
left: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.2s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
.profile-section {
|
.profile-section {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user