mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-06-17 11:14:23 +00:00
Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1c83b0f68 | |||
| 22c2b1564d | |||
| 628d28c12d | |||
| 2577992ee3 | |||
| 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 |
@@ -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
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ")) {
|
||||||
|
|||||||
@@ -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,26 @@ 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", "邀请码使用次数过多"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
User user = userService.registerWithInvite(
|
||||||
|
req.getUsername(), req.getEmail(), req.getPassword());
|
||||||
|
inviteService.consume(req.getInviteToken());
|
||||||
|
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
|
||||||
|
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());
|
||||||
@@ -58,11 +79,27 @@ public class AuthController {
|
|||||||
public ResponseEntity<?> verify(@RequestBody VerifyRequest req) {
|
public ResponseEntity<?> verify(@RequestBody VerifyRequest req) {
|
||||||
boolean ok = userService.verifyCode(req.getUsername(), req.getCode());
|
boolean ok = userService.verifyCode(req.getUsername(), req.getCode());
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
Optional<User> userOpt = userService.findByUsername(req.getUsername());
|
||||||
|
if (userOpt.isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"));
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = userOpt.get();
|
||||||
|
|
||||||
|
if (user.isApproved()) {
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"message", "Verified and isApproved",
|
||||||
|
"reason_code", "VERIFIED_AND_APPROVED",
|
||||||
|
"token", jwtService.generateToken(req.getUsername())
|
||||||
|
));
|
||||||
|
} else {
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"message", "Verified",
|
"message", "Verified",
|
||||||
|
"reason_code", "VERIFIED",
|
||||||
"token", jwtService.generateReasonToken(req.getUsername())
|
"token", jwtService.generateReasonToken(req.getUsername())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code"));
|
return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,27 +143,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())));
|
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
Optional<AuthResult> resultOpt = googleAuthService.authenticate(
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
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 (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
|
}
|
||||||
|
if (!result.getUser().isApproved()) {
|
||||||
|
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
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 +217,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())));
|
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
Optional<AuthResult> resultOpt = githubAuthService.authenticate(
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
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 (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
|
}
|
||||||
|
if (!result.getUser().isApproved()) {
|
||||||
|
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||||
// 已填写注册理由
|
// 已填写注册理由
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
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 +264,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())));
|
|
||||||
}
|
}
|
||||||
if (!user.get().isApproved()) {
|
Optional<AuthResult> resultOpt = discordAuthService.authenticate(
|
||||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
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 (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||||
|
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||||
|
}
|
||||||
|
if (!result.getUser().isApproved()) {
|
||||||
|
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(Map.of(
|
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 +310,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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 @@ 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 */
|
/** You won a lottery post */
|
||||||
LOTTERY_WIN,
|
LOTTERY_WIN,
|
||||||
/** Your lottery post was drawn */
|
/** Your lottery post was drawn */
|
||||||
|
|||||||
@@ -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 " +
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -567,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
|
||||||
|
|||||||
@@ -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(genCode());
|
||||||
|
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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,6 +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_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_WEBSITE_BASE_URL=https://www.open-isle.com
|
||||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-xxx.apps.googleusercontent.com
|
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||||
|
|||||||
@@ -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
|
||||||
+41
-8
@@ -15,23 +15,26 @@
|
|||||||
<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 {
|
|
||||||
name: 'App',
|
|
||||||
components: { HeaderComponent, MenuComponent, GlobalPopups },
|
|
||||||
setup() {
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const menuVisible = ref(!isMobile.value)
|
const menuVisible = ref(!isMobile.value)
|
||||||
|
|
||||||
|
const showNewPostIcon = computed(() => useRoute().path === '/')
|
||||||
|
|
||||||
const hideMenu = computed(() => {
|
const hideMenu = computed(() => {
|
||||||
return [
|
return [
|
||||||
'/login',
|
'/login',
|
||||||
@@ -65,12 +68,24 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { menuVisible, hideMenu, handleMenuOutside, header }
|
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;
|
||||||
@@ -103,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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,6 +3,7 @@
|
|||||||
<div class="dropdown-trigger" @click="toggle">
|
<div class="dropdown-trigger" @click="toggle">
|
||||||
<slot name="trigger"></slot>
|
<slot name="trigger"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
<Transition name="dropdown-menu">
|
||||||
<div v-if="visible" class="dropdown-menu-container">
|
<div v-if="visible" class="dropdown-menu-container">
|
||||||
<div
|
<div
|
||||||
v-for="(item, idx) in items"
|
v-for="(item, idx) in items"
|
||||||
@@ -14,11 +15,12 @@
|
|||||||
{{ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
/>
|
/>
|
||||||
<NotificationSettingPopup :visible="showNotificationPopup" @close="closeNotificationPopup" />
|
<NotificationSettingPopup :visible="showNotificationPopup" @close="closeNotificationPopup" />
|
||||||
<MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" />
|
<MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" />
|
||||||
|
|
||||||
|
<ActivityPopup
|
||||||
|
:visible="showInviteCodePopup"
|
||||||
|
:icon="inviteCodeIcon"
|
||||||
|
text="邀请码活动开始了,速来参与大伙们🔥🔥🔥"
|
||||||
|
@close="closeInviteCodePopup"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -21,7 +28,10 @@ const config = useRuntimeConfig()
|
|||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
const showMilkTeaPopup = ref(false)
|
const showMilkTeaPopup = ref(false)
|
||||||
|
const showInviteCodePopup = ref(false)
|
||||||
const milkTeaIcon = ref('')
|
const milkTeaIcon = ref('')
|
||||||
|
const inviteCodeIcon = ref('')
|
||||||
|
|
||||||
const showNotificationPopup = ref(false)
|
const showNotificationPopup = ref(false)
|
||||||
const showMedalPopup = ref(false)
|
const showMedalPopup = ref(false)
|
||||||
const newMedals = ref([])
|
const newMedals = ref([])
|
||||||
@@ -30,6 +40,9 @@ onMounted(async () => {
|
|||||||
await checkMilkTeaActivity()
|
await checkMilkTeaActivity()
|
||||||
if (showMilkTeaPopup.value) return
|
if (showMilkTeaPopup.value) return
|
||||||
|
|
||||||
|
await checkInviteCodeActivity()
|
||||||
|
if (showInviteCodePopup.value) return
|
||||||
|
|
||||||
await checkNotificationSetting()
|
await checkNotificationSetting()
|
||||||
if (showNotificationPopup.value) return
|
if (showNotificationPopup.value) return
|
||||||
|
|
||||||
@@ -53,12 +66,38 @@ const checkMilkTeaActivity = async () => {
|
|||||||
// ignore network errors
|
// ignore network errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const checkInviteCodeActivity = async () => {
|
||||||
|
if (!process.client) return
|
||||||
|
if (localStorage.getItem('inviteCodeActivityPopupShown')) 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 === 'INVITE_POINTS' && !i.ended)
|
||||||
|
if (a) {
|
||||||
|
inviteCodeIcon.value = a.icon
|
||||||
|
showInviteCodePopup.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore network errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeInviteCodePopup = () => {
|
||||||
|
if (!process.client) return
|
||||||
|
localStorage.setItem('inviteCodeActivityPopupShown', 'true')
|
||||||
|
showInviteCodePopup.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const closeMilkTeaPopup = () => {
|
const closeMilkTeaPopup = () => {
|
||||||
if (!process.client) return
|
if (!process.client) return
|
||||||
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||||
showMilkTeaPopup.value = false
|
showMilkTeaPopup.value = false
|
||||||
checkNotificationSetting()
|
checkNotificationSetting()
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkNotificationSetting = async () => {
|
const checkNotificationSetting = async () => {
|
||||||
if (!process.client) return
|
if (!process.client) return
|
||||||
if (!authState.loggedIn) return
|
if (!authState.loggedIn) return
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</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,15 +43,12 @@
|
|||||||
</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">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
</div>
|
|
||||||
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
||||||
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
||||||
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
|
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
|
||||||
@@ -51,12 +59,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ClientOnly } from '#components'
|
import { ClientOnly } from '#components'
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||||
|
import ToolTip from '~/components/ToolTip.vue'
|
||||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
showMenuBtn: {
|
showMenuBtn: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -67,16 +77,12 @@ const props = defineProps({
|
|||||||
const isLogin = computed(() => authState.loggedIn)
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const unreadCount = computed(() => notificationState.unreadCount)
|
const unreadCount = computed(() => notificationState.unreadCount)
|
||||||
const router = useRouter()
|
|
||||||
const avatar = ref('')
|
const avatar = ref('')
|
||||||
const showSearch = ref(false)
|
const showSearch = ref(false)
|
||||||
const searchDropdown = ref(null)
|
const searchDropdown = ref(null)
|
||||||
const userMenu = ref(null)
|
const userMenu = ref(null)
|
||||||
const menuBtn = ref(null)
|
const menuBtn = ref(null)
|
||||||
|
|
||||||
const goToHome = async () => {
|
|
||||||
await navigateTo('/', { replace: true })
|
|
||||||
}
|
|
||||||
const search = () => {
|
const search = () => {
|
||||||
showSearch.value = true
|
showSearch.value = true
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -118,12 +124,33 @@ const goToLogout = () => {
|
|||||||
navigateTo('/login', { replace: true })
|
navigateTo('/login', { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goToNewPost = () => {
|
||||||
|
navigateTo('/new-post', { replace: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const refrechData = async () => {
|
||||||
|
await fetchUnreadCount()
|
||||||
|
window.dispatchEvent(new Event('refresh-home'))
|
||||||
|
}
|
||||||
|
|
||||||
const headerMenuItems = computed(() => [
|
const headerMenuItems = computed(() => [
|
||||||
{ text: '设置', onClick: goToSettings },
|
{ text: '设置', onClick: goToSettings },
|
||||||
{ text: '个人主页', onClick: goToProfile },
|
{ text: '个人主页', onClick: goToProfile },
|
||||||
{ text: '退出', onClick: goToLogout },
|
{ 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 () => {
|
onMounted(async () => {
|
||||||
const updateAvatar = async () => {
|
const updateAvatar = async () => {
|
||||||
if (authState.loggedIn) {
|
if (authState.loggedIn) {
|
||||||
@@ -151,25 +178,17 @@ onMounted(async () => {
|
|||||||
await updateUnread()
|
await updateUnread()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
|
||||||
() => router.currentRoute.value.fullPath,
|
|
||||||
() => {
|
|
||||||
if (userMenu.value) userMenu.value.close()
|
|
||||||
showSearch.value = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
</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);
|
||||||
}
|
}
|
||||||
@@ -180,17 +199,18 @@ onMounted(async () => {
|
|||||||
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 {
|
||||||
@@ -200,6 +220,14 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
@@ -281,7 +309,13 @@ onMounted(async () => {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>每人每天仅能生产1个邀请链接</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>
|
||||||
@@ -35,7 +35,7 @@ const goLogin = () => {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<nav v-if="visible" class="menu">
|
<nav v-if="visible" class="menu">
|
||||||
|
<div class="menu-content">
|
||||||
<div class="menu-item-container">
|
<div class="menu-item-container">
|
||||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleHomeClick">
|
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||||
<span class="menu-item-text">话题</span>
|
<span class="menu-item-text">话题</span>
|
||||||
</NuxtLink>
|
</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>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
exact-active-class="selected"
|
exact-active-class="selected"
|
||||||
@@ -47,13 +57,17 @@
|
|||||||
<span class="menu-item-text">站点统计</span>
|
<span class="menu-item-text">站点统计</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
|
v-if="authState.loggedIn"
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
exact-active-class="selected"
|
exact-active-class="selected"
|
||||||
to="/new-post"
|
to="/points"
|
||||||
@click="handleItemClick"
|
@click="handleItemClick"
|
||||||
>
|
>
|
||||||
<i class="menu-item-icon fas fa-edit"></i>
|
<i class="menu-item-icon fas fa-coins"></i>
|
||||||
<span class="menu-item-text">发帖</span>
|
<span class="menu-item-text">
|
||||||
|
积分商城
|
||||||
|
<span v-if="myPoint !== null" class="point-count">{{ myPoint }}</span>
|
||||||
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -113,56 +127,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 解决动态样式的水合错误 -->
|
||||||
|
<ClientOnly v-if="!isMobile">
|
||||||
<div class="menu-footer">
|
<div class="menu-footer">
|
||||||
<div class="menu-footer-btn" @click="cycleTheme">
|
<div class="menu-footer-btn" @click="cycleTheme">
|
||||||
<i :class="iconClass"></i>
|
<i :class="iconClass"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ClientOnly>
|
||||||
</nav>
|
</nav>
|
||||||
</transition>
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
import { authState, fetchCurrentUser } from '~/utils/auth'
|
||||||
import { authState } from '~/utils/auth'
|
|
||||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||||
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
import { cycleTheme, ThemeMode, themeState } from '~/utils/theme'
|
||||||
|
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
visible: { type: Boolean, default: true },
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['item-click'])
|
const emit = defineEmits(['item-click'])
|
||||||
|
|
||||||
const categoryOpen = ref(true)
|
const categoryOpen = ref(true)
|
||||||
const tagOpen = ref(true)
|
const tagOpen = ref(true)
|
||||||
const isLoadingCategory = ref(false)
|
const myPoint = ref(null)
|
||||||
const isLoadingTag = ref(false)
|
|
||||||
const categoryData = ref([])
|
|
||||||
const tagData = ref([])
|
|
||||||
|
|
||||||
const fetchCategoryData = async () => {
|
/** ✅ 用 useAsyncData 替换原生 fetch,避免 SSR+CSR 二次请求 */
|
||||||
isLoadingCategory.value = true
|
const {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
data: categoryData,
|
||||||
const data = await res.json()
|
pending: isLoadingCategory,
|
||||||
categoryData.value = data
|
error: categoryError,
|
||||||
isLoadingCategory.value = false
|
} = await useAsyncData(
|
||||||
}
|
// 稳定 key:避免 hydration 期误判
|
||||||
|
'menu:categories',
|
||||||
|
() => $fetch(`${API_BASE_URL}/api/categories`),
|
||||||
|
{
|
||||||
|
server: true, // SSR 预取
|
||||||
|
default: () => [], // 初始默认值,减少空判断
|
||||||
|
// 5 分钟内复用缓存,避免路由往返重复请求
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const fetchTagData = async () => {
|
const {
|
||||||
isLoadingTag.value = true
|
data: tagData,
|
||||||
const res = await fetch(`${API_BASE_URL}/api/tags?limit=10`)
|
pending: isLoadingTag,
|
||||||
const data = await res.json()
|
error: tagError,
|
||||||
tagData.value = data
|
} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), {
|
||||||
isLoadingTag.value = false
|
server: true,
|
||||||
}
|
default: () => [],
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 其余逻辑保持不变 */
|
||||||
const iconClass = computed(() => {
|
const iconClass = computed(() => {
|
||||||
switch (themeState.mode) {
|
switch (themeState.mode) {
|
||||||
case ThemeMode.DARK:
|
case ThemeMode.DARK:
|
||||||
@@ -178,6 +205,15 @@ const unreadCount = computed(() => notificationState.unreadCount)
|
|||||||
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
||||||
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
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 () => {
|
const updateCount = async () => {
|
||||||
if (authState.loggedIn) {
|
if (authState.loggedIn) {
|
||||||
await fetchUnreadCount()
|
await fetchUnreadCount()
|
||||||
@@ -187,14 +223,17 @@ const updateCount = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await updateCount()
|
await Promise.all([updateCount(), loadPoint()])
|
||||||
watch(() => authState.loggedIn, updateCount)
|
// 登录态变化时再拉一次未读数和积分;与 useAsyncData 无关
|
||||||
|
watch(
|
||||||
|
() => authState.loggedIn,
|
||||||
|
() => {
|
||||||
|
updateCount()
|
||||||
|
loadPoint()
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleHomeClick = () => {
|
|
||||||
navigateTo('/', { replace: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
if (window.innerWidth <= 768) emit('item-click')
|
if (window.innerWidth <= 768) emit('item-click')
|
||||||
}
|
}
|
||||||
@@ -215,28 +254,35 @@ const gotoTag = (t) => {
|
|||||||
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||||
handleItemClick()
|
handleItemClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([fetchCategoryData(), fetchTagData()])
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.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;
|
||||||
@@ -275,6 +321,12 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
|
|||||||
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;
|
||||||
@@ -282,10 +334,8 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
@@ -373,6 +423,10 @@ await Promise.all([fetchCategoryData(), fetchTagData()])
|
|||||||
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,30 +40,22 @@
|
|||||||
兑换
|
兑换
|
||||||
</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
|
|
||||||
textarea=""
|
|
||||||
rows="5"
|
|
||||||
v-model="contact"
|
v-model="contact"
|
||||||
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
|
:loading="loading"
|
||||||
|
@close="closeDialog"
|
||||||
|
@submit="submitRedeem"
|
||||||
/>
|
/>
|
||||||
<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 setup>
|
<script setup>
|
||||||
import { 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 config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -185,56 +177,6 @@ const submitRedeem = async () => {
|
|||||||
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;
|
||||||
@@ -247,9 +189,5 @@ const submitRedeem = async () => {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.redeem-dialog-content {
|
|
||||||
min-width: 300px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -37,10 +37,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { ref, watch } from 'vue'
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
import { stripMarkdown } from '~/utils/markdown'
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
import { ref, watch } from 'vue'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -110,6 +110,10 @@ watch(selected, (val) => {
|
|||||||
selected.value = null
|
selected.value = null
|
||||||
keyword.value = ''
|
keyword.value = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
toggle,
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -131,7 +135,7 @@ watch(selected, (val) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1 +1,5 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ export default defineNuxtConfig({
|
|||||||
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Ensure Vditor styles load before our overrides in global.css
|
// 确保 Vditor 样式在 global.css 覆盖前加载
|
||||||
css: ['vditor/dist/index.css', '~/assets/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: [
|
||||||
{
|
{
|
||||||
@@ -26,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);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
@@ -52,10 +77,35 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
baseURL: '/',
|
||||||
|
buildAssetsDir: '/_nuxt/',
|
||||||
},
|
},
|
||||||
vue: {
|
vue: {
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+3232
-1718
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@
|
|||||||
</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>
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
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 config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ onMounted(async () => {
|
|||||||
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 {
|
||||||
@@ -141,6 +144,10 @@ onMounted(async () => {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-card-normal-right {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
@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,3 +1,4 @@
|
|||||||
|
<!-- pages/discord-callback.vue -->
|
||||||
<template>
|
<template>
|
||||||
<CallbackPage />
|
<CallbackPage />
|
||||||
</template>
|
</template>
|
||||||
@@ -8,9 +9,30 @@ import { discordExchange } from '~/utils/discord'
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
const code = url.searchParams.get('code')
|
const code = url.searchParams.get('code') || ''
|
||||||
const state = url.searchParams.get('state')
|
const stateStr = url.searchParams.get('state') || ''
|
||||||
const result = await discordExchange(code, state, '')
|
|
||||||
|
// 从 state 解析 invite_token;兜底支持 query ?invite_token=
|
||||||
|
let inviteToken = ''
|
||||||
|
if (stateStr) {
|
||||||
|
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) {
|
if (result.needReason) {
|
||||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||||
|
|||||||
@@ -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,6 +20,10 @@
|
|||||||
<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>
|
||||||
@@ -26,6 +31,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -39,6 +46,7 @@ const passwordError = ref('')
|
|||||||
const isSending = ref(false)
|
const isSending = ref(false)
|
||||||
const isVerifying = ref(false)
|
const isVerifying = ref(false)
|
||||||
const isResetting = ref(false)
|
const isResetting = ref(false)
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (route.query.email) {
|
if (route.query.email) {
|
||||||
@@ -137,6 +145,21 @@ const resetPassword = async () => {
|
|||||||
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,3 +1,4 @@
|
|||||||
|
<!-- pages/github-callback.vue -->
|
||||||
<template>
|
<template>
|
||||||
<CallbackPage />
|
<CallbackPage />
|
||||||
</template>
|
</template>
|
||||||
@@ -8,9 +9,31 @@ import { githubExchange } from '~/utils/github'
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
const code = url.searchParams.get('code')
|
const code = url.searchParams.get('code') || ''
|
||||||
const state = url.searchParams.get('state')
|
const state = url.searchParams.get('state') || ''
|
||||||
const result = await githubExchange(code, state, '')
|
|
||||||
|
// 从 state 中解析 invite_token(githubAuthorize 已把它放进 state)
|
||||||
|
let inviteToken = ''
|
||||||
|
if (state) {
|
||||||
|
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) {
|
if (result.needReason) {
|
||||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ import { googleAuthWithToken } from '~/utils/google'
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const hash = new URLSearchParams(window.location.hash.substring(1))
|
const hash = new URLSearchParams(window.location.hash.substring(1))
|
||||||
const idToken = hash.get('id_token')
|
const idToken = hash.get('id_token')
|
||||||
|
|
||||||
|
// 优先从 state 中解析
|
||||||
|
let inviteToken = ''
|
||||||
|
const stateStr = hash.get('state') || ''
|
||||||
|
if (stateStr) {
|
||||||
|
const state = new URLSearchParams(stateStr)
|
||||||
|
inviteToken = state.get('invite_token') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:如果之前把 invite_token 放在回调 URL 的查询参数中
|
||||||
|
// if (!inviteToken) {
|
||||||
|
// const query = new URLSearchParams(window.location.search)
|
||||||
|
// inviteToken = query.get('invite_token') || ''
|
||||||
|
// }
|
||||||
|
|
||||||
if (idToken) {
|
if (idToken) {
|
||||||
await googleAuthWithToken(
|
await googleAuthWithToken(
|
||||||
idToken,
|
idToken,
|
||||||
@@ -18,6 +33,7 @@ onMounted(async () => {
|
|||||||
(token) => {
|
(token) => {
|
||||||
navigateTo(`/signup-reason?token=${token}`, { replace: true })
|
navigateTo(`/signup-reason?token=${token}`, { replace: true })
|
||||||
},
|
},
|
||||||
|
{ inviteToken },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
navigateTo('/login', { replace: true })
|
navigateTo('/login', { replace: true })
|
||||||
|
|||||||
+156
-194
@@ -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,15 +106,14 @@
|
|||||||
热门帖子功能开发中,敬请期待。
|
热门帖子功能开发中,敬请期待。
|
||||||
</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 setup>
|
||||||
import { ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||||
import ArticleTags from '~/components/ArticleTags.vue'
|
import ArticleTags from '~/components/ArticleTags.vue'
|
||||||
import CategorySelect from '~/components/CategorySelect.vue'
|
import CategorySelect from '~/components/CategorySelect.vue'
|
||||||
@@ -142,22 +143,32 @@ const selectedTags = ref([])
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const tagOptions = ref([])
|
const tagOptions = ref([])
|
||||||
const categoryOptions = ref([])
|
const categoryOptions = ref([])
|
||||||
const isLoadingPosts = ref(false)
|
|
||||||
|
const isLoadingMore = ref(false)
|
||||||
|
|
||||||
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||||
|
const selectedTopicCookie = useCookie('homeTab')
|
||||||
const selectedTopic = ref(
|
const selectedTopic = ref(
|
||||||
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
|
selectedTopicCookie.value
|
||||||
|
? selectedTopicCookie.value
|
||||||
|
: route.query.view === 'ranking'
|
||||||
|
? '排行榜'
|
||||||
|
: route.query.view === 'latest'
|
||||||
|
? '最新'
|
||||||
|
: '最新回复',
|
||||||
)
|
)
|
||||||
|
if (!selectedTopicCookie.value) selectedTopicCookie.value = selectedTopic.value
|
||||||
const articles = ref([])
|
const articles = ref([])
|
||||||
const page = ref(0)
|
const page = ref(0)
|
||||||
const pageSize = 10
|
const pageSize = 10
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const allLoaded = ref(false)
|
const allLoaded = ref(false)
|
||||||
|
|
||||||
|
/** URL 参数 -> 本地筛选值 **/
|
||||||
const selectedCategorySet = (category) => {
|
const selectedCategorySet = (category) => {
|
||||||
const c = decodeURIComponent(category)
|
const c = decodeURIComponent(category)
|
||||||
selectedCategory.value = isNaN(c) ? c : Number(c)
|
selectedCategory.value = isNaN(c) ? c : Number(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedTagsSet = (tags) => {
|
const selectedTagsSet = (tags) => {
|
||||||
const t = Array.isArray(tags) ? tags.join(',') : tags
|
const t = Array.isArray(tags) ? tags.join(',') : tags
|
||||||
selectedTags.value = t
|
selectedTags.value = t
|
||||||
@@ -167,23 +178,22 @@ const selectedTagsSet = (tags) => {
|
|||||||
.map((v) => (isNaN(v) ? v : Number(v)))
|
.map((v) => (isNaN(v) ? v : Number(v)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 初始化:仅在客户端首渲染时根据路由同步一次 **/
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const query = route.query
|
const { category, tags } = route.query
|
||||||
const category = query.category
|
if (category) selectedCategorySet(category)
|
||||||
const tags = query.tags
|
if (tags) selectedTagsSet(tags)
|
||||||
|
|
||||||
if (category) {
|
const saved = localStorage.getItem('homeTab')
|
||||||
selectedCategorySet(category)
|
if (saved) {
|
||||||
}
|
selectedTopic.value = saved
|
||||||
if (tags) {
|
|
||||||
selectedTagsSet(tags)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 路由变更时同步筛选 **/
|
||||||
watch(
|
watch(
|
||||||
() => route.query,
|
() => route.query,
|
||||||
() => {
|
(query) => {
|
||||||
const query = route.query
|
|
||||||
const category = query.category
|
const category = query.category
|
||||||
const tags = query.tags
|
const tags = query.tags
|
||||||
category && selectedCategorySet(category)
|
category && selectedCategorySet(category)
|
||||||
@@ -191,18 +201,14 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 选项加载(分类/标签名称回填) **/
|
||||||
const loadOptions = async () => {
|
const loadOptions = async () => {
|
||||||
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
|
const res = await fetch(`${API_BASE_URL}/api/categories/`)
|
||||||
if (res.ok) {
|
if (res.ok) categoryOptions.value = [await res.json()]
|
||||||
categoryOptions.value = [await res.json()]
|
} catch {}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedTags.value.length) {
|
if (selectedTags.value.length) {
|
||||||
const arr = []
|
const arr = []
|
||||||
for (const t of selectedTags.value) {
|
for (const t of selectedTags.value) {
|
||||||
@@ -210,74 +216,54 @@ const loadOptions = async () => {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
|
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
|
||||||
if (r.ok) arr.push(await r.json())
|
if (r.ok) arr.push(await r.json())
|
||||||
} catch (e) {
|
} catch {}
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tagOptions.value = arr
|
tagOptions.value = arr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildUrl = () => {
|
/** 列表 API 路径与查询参数 **/
|
||||||
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
|
const baseQuery = computed(() => ({
|
||||||
if (selectedCategory.value) {
|
categoryId: selectedCategory.value || undefined,
|
||||||
url += `&categoryId=${selectedCategory.value}`
|
tagIds: selectedTags.value.length ? selectedTags.value : undefined,
|
||||||
}
|
}))
|
||||||
if (selectedTags.value.length) {
|
const listApiPath = computed(() => {
|
||||||
selectedTags.value.forEach((t) => {
|
if (selectedTopic.value === '排行榜') return '/api/posts/ranking'
|
||||||
url += `&tagIds=${t}`
|
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()
|
||||||
}
|
}
|
||||||
return url
|
const tokenHeader = computed(() => {
|
||||||
}
|
|
||||||
|
|
||||||
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 token = getToken()
|
||||||
const res = await fetch(buildUrl(), {
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
headers: {
|
|
||||||
Authorization: token ? `Bearer ${token}` : '',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
isLoadingPosts.value = false
|
|
||||||
if (!res.ok) return
|
/** —— 首屏数据托管(SSR) —— **/
|
||||||
const data = await res.json()
|
const asyncKey = computed(() => [
|
||||||
articles.value.push(
|
'home:firstpage',
|
||||||
...data.map((p) => ({
|
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,
|
id: p.id,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
description: p.content,
|
description: p.content,
|
||||||
@@ -286,41 +272,41 @@ const fetchPosts = async (reset = false) => {
|
|||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
comments: p.commentCount,
|
comments: p.commentCount,
|
||||||
views: p.views,
|
views: p.views,
|
||||||
time: TimeManager.format(p.createdAt),
|
time: TimeManager.format(
|
||||||
pinned: !!p.pinnedAt,
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
|
),
|
||||||
|
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||||
type: p.type,
|
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
|
server: true,
|
||||||
if (!res.ok) return
|
default: () => [],
|
||||||
const data = await res.json()
|
watch: [selectedTopic, baseQuery],
|
||||||
articles.value.push(
|
},
|
||||||
...data.map((p) => ({
|
)
|
||||||
|
|
||||||
|
/** 首屏/筛选变更:重置分页并灌入 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,
|
id: p.id,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
description: p.content,
|
description: p.content,
|
||||||
@@ -329,87 +315,65 @@ const fetchRanking = async (reset = false) => {
|
|||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
comments: p.commentCount,
|
comments: p.commentCount,
|
||||||
views: p.views,
|
views: p.views,
|
||||||
time: TimeManager.format(p.createdAt),
|
time: TimeManager.format(
|
||||||
pinned: !!p.pinnedAt,
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
|
),
|
||||||
|
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||||
type: p.type,
|
type: p.type,
|
||||||
})),
|
}))
|
||||||
)
|
articles.value.push(...mapped)
|
||||||
if (data.length < pageSize) {
|
if (data.length < pageSize) {
|
||||||
allLoaded.value = true
|
allLoaded.value = true
|
||||||
} else {
|
} else {
|
||||||
page.value += 1
|
page.value = nextPage
|
||||||
}
|
}
|
||||||
} 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
|
.finally(() => {
|
||||||
if (!res.ok) return
|
inflight = null
|
||||||
const data = await res.json()
|
isLoadingMore.value = false
|
||||||
articles.value.push(
|
})
|
||||||
...data.map((p) => ({
|
}
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
/** 绑定滚动加载(避免挂载瞬间触发) **/
|
||||||
description: p.content,
|
let initialReady = false
|
||||||
category: p.category,
|
const loadMoreGuarded = async () => {
|
||||||
tags: p.tags || [],
|
if (!initialReady) return
|
||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
await fetchNextPage()
|
||||||
comments: p.commentCount,
|
}
|
||||||
views: p.views,
|
useScrollLoadMore(loadMoreGuarded)
|
||||||
time: TimeManager.format(p.lastReplyAt || p.createdAt),
|
watch(
|
||||||
pinned: !!p.pinnedAt,
|
articles,
|
||||||
type: p.type,
|
() => {
|
||||||
})),
|
if (!initialReady && articles.value.length) initialReady = true
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
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)
|
|
||||||
|
|
||||||
|
/** 切换分类/标签/Tab:useAsyncData 已 watch,这里只需确保 options 加载 **/
|
||||||
watch([selectedCategory, selectedTags], () => {
|
watch([selectedCategory, selectedTags], () => {
|
||||||
fetchContent(true)
|
loadOptions()
|
||||||
|
})
|
||||||
|
watch(selectedTopic, (val) => {
|
||||||
|
// 仅当需要额外选项时加载
|
||||||
|
loadOptions()
|
||||||
|
selectedTopicCookie.value = val
|
||||||
|
if (process.client) {
|
||||||
|
localStorage.setItem('homeTab', val)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedTopic, () => {
|
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
||||||
fetchContent(true)
|
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)
|
const sanitizeDescription = (text) => stripMarkdown(text)
|
||||||
|
|
||||||
await Promise.all([loadOptions(), fetchContent()])
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -423,8 +387,8 @@ await Promise.all([loadOptions(), fetchContent()])
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
@@ -436,10 +400,6 @@ await Promise.all([loadOptions(), fetchContent()])
|
|||||||
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;
|
||||||
@@ -474,6 +434,7 @@ await Promise.all([loadOptions(), fetchContent()])
|
|||||||
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 {
|
||||||
@@ -593,6 +554,7 @@ await Promise.all([loadOptions(), fetchContent()])
|
|||||||
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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -259,6 +263,11 @@ const loginWithTwitter = () => {
|
|||||||
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;
|
||||||
|
|||||||
+24
-236
@@ -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> 对我的文章
|
||||||
@@ -505,21 +511,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
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'
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const notifications = ref([])
|
|
||||||
const isLoadingMessage = ref(false)
|
|
||||||
const selectedTab = ref(
|
const selectedTab = ref(
|
||||||
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
|
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
|
||||||
)
|
)
|
||||||
@@ -528,234 +539,6 @@ const filteredNotifications = computed(() =>
|
|||||||
selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read),
|
selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read),
|
||||||
)
|
)
|
||||||
|
|
||||||
const markRead = async (id) => {
|
|
||||||
if (!id) return
|
|
||||||
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',
|
|
||||||
LOTTERY_WIN: 'fas fa-trophy',
|
|
||||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
|
||||||
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)
|
|
||||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'REACTION') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
emoji: reactionEmojiMap[n.reactionType],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.fromUser) {
|
|
||||||
markRead(n.id)
|
|
||||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} 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)
|
|
||||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'LOTTERY_WIN') {
|
|
||||||
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 === 'LOTTERY_DRAW') {
|
|
||||||
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_UPDATED') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
src: n.comment.author.avatar,
|
|
||||||
iconClick: () => {
|
|
||||||
markRead(n.id)
|
|
||||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'USER_ACTIVITY') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
src: n.comment.author.avatar,
|
|
||||||
iconClick: () => {
|
|
||||||
markRead(n.id)
|
|
||||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'MENTION') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
icon: iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.fromUser) {
|
|
||||||
markRead(n.id)
|
|
||||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} 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)
|
|
||||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'FOLLOWED_POST') {
|
|
||||||
notifications.value.push({
|
|
||||||
...n,
|
|
||||||
icon: iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.post) {
|
|
||||||
markRead(n.id)
|
|
||||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} 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)
|
|
||||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} 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)
|
|
||||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} 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 () => {
|
const fetchPrefs = async () => {
|
||||||
notificationPrefs.value = await fetchNotificationPreferences()
|
notificationPrefs.value = await fetchNotificationPreferences()
|
||||||
}
|
}
|
||||||
@@ -833,6 +616,8 @@ const formatType = (t) => {
|
|||||||
return '有人申请注册'
|
return '有人申请注册'
|
||||||
case 'ACTIVITY_REDEEM':
|
case 'ACTIVITY_REDEEM':
|
||||||
return '有人申请兑换奶茶'
|
return '有人申请兑换奶茶'
|
||||||
|
case 'POINT_REDEEM':
|
||||||
|
return '有人申请积分兑换'
|
||||||
case 'LOTTERY_WIN':
|
case 'LOTTERY_WIN':
|
||||||
return '抽奖中奖了'
|
return '抽奖中奖了'
|
||||||
case 'LOTTERY_DRAW':
|
case 'LOTTERY_DRAW':
|
||||||
@@ -842,7 +627,7 @@ const formatType = (t) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onActivated(() => {
|
||||||
fetchNotifications()
|
fetchNotifications()
|
||||||
fetchPrefs()
|
fetchPrefs()
|
||||||
})
|
})
|
||||||
@@ -859,6 +644,8 @@ onMounted(() => {
|
|||||||
.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 {
|
||||||
@@ -870,6 +657,7 @@ onMounted(() => {
|
|||||||
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'
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<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 积分',
|
||||||
|
'邀请好友加入可获得 500 积分/次,注意需要使用邀请链接注册',
|
||||||
|
]
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, watchEffect } from 'vue'
|
||||||
import VueEasyLightbox from 'vue-easy-lightbox'
|
import VueEasyLightbox from 'vue-easy-lightbox'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import CommentItem from '~/components/CommentItem.vue'
|
import CommentItem from '~/components/CommentItem.vue'
|
||||||
@@ -268,7 +268,6 @@ const postReactions = ref([])
|
|||||||
const comments = ref([])
|
const comments = ref([])
|
||||||
const status = ref('PUBLISHED')
|
const status = ref('PUBLISHED')
|
||||||
const pinnedAt = ref(null)
|
const pinnedAt = ref(null)
|
||||||
const isWaitingFetchingPost = ref(false)
|
|
||||||
const isWaitingPostingComment = ref(false)
|
const isWaitingPostingComment = ref(false)
|
||||||
const postTime = ref('')
|
const postTime = ref('')
|
||||||
const postItems = ref([])
|
const postItems = ref([])
|
||||||
@@ -392,7 +391,7 @@ const mapComment = (c, parentUserName = '', level = 0) => ({
|
|||||||
avatar: c.author.avatar,
|
avatar: c.author.avatar,
|
||||||
text: c.content,
|
text: c.content,
|
||||||
reactions: c.reactions || [],
|
reactions: c.reactions || [],
|
||||||
pinned: !!c.pinnedAt,
|
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
|
||||||
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
|
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
|
||||||
openReplies: level === 0,
|
openReplies: level === 0,
|
||||||
src: c.author.avatar,
|
src: c.author.avatar,
|
||||||
@@ -455,21 +454,23 @@ const onCommentDeleted = (id) => {
|
|||||||
fetchComments()
|
fetchComments()
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchPost = async () => {
|
const {
|
||||||
try {
|
data: postData,
|
||||||
isWaitingFetchingPost.value = true
|
pending: pendingPost,
|
||||||
const token = getToken()
|
error: postError,
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
refresh: refreshPost,
|
||||||
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
||||||
|
server: true,
|
||||||
|
lazy: false,
|
||||||
})
|
})
|
||||||
isWaitingFetchingPost.value = false
|
|
||||||
if (!res.ok) {
|
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||||
if (res.status === 404 && process.client) {
|
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||||
router.replace('/404')
|
|
||||||
}
|
// 同步到现有的响应式字段
|
||||||
return
|
watchEffect(() => {
|
||||||
}
|
const data = postData.value
|
||||||
const data = await res.json()
|
if (!data) return
|
||||||
postContent.value = data.content
|
postContent.value = data.content
|
||||||
author.value = data.author
|
author.value = data.author
|
||||||
title.value = data.title
|
title.value = data.title
|
||||||
@@ -482,11 +483,12 @@ const fetchPost = async () => {
|
|||||||
postTime.value = TimeManager.format(data.createdAt)
|
postTime.value = TimeManager.format(data.createdAt)
|
||||||
lottery.value = data.lottery || null
|
lottery.value = data.lottery || null
|
||||||
if (lottery.value && lottery.value.endTime) startCountdown()
|
if (lottery.value && lottery.value.endTime) startCountdown()
|
||||||
await nextTick()
|
})
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
// 404 客户端跳转
|
||||||
}
|
// if (postError.value?.statusCode === 404 && process.client) {
|
||||||
}
|
// router.replace('/404')
|
||||||
|
// }
|
||||||
|
|
||||||
const totalPosts = computed(() => comments.value.length + 1)
|
const totalPosts = computed(() => comments.value.length + 1)
|
||||||
const lastReplyTime = computed(() =>
|
const lastReplyTime = computed(() =>
|
||||||
@@ -607,6 +609,7 @@ const approvePost = async () => {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
status.value = 'PUBLISHED'
|
status.value = 'PUBLISHED'
|
||||||
toast.success('已通过审核')
|
toast.success('已通过审核')
|
||||||
|
await refreshPost()
|
||||||
} else {
|
} else {
|
||||||
toast.error('操作失败')
|
toast.error('操作失败')
|
||||||
}
|
}
|
||||||
@@ -620,8 +623,8 @@ const pinPost = async () => {
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
pinnedAt.value = new Date().toISOString()
|
|
||||||
toast.success('已置顶')
|
toast.success('已置顶')
|
||||||
|
await refreshPost()
|
||||||
} else {
|
} else {
|
||||||
toast.error('操作失败')
|
toast.error('操作失败')
|
||||||
}
|
}
|
||||||
@@ -635,8 +638,8 @@ const unpinPost = async () => {
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
pinnedAt.value = null
|
|
||||||
toast.success('已取消置顶')
|
toast.success('已取消置顶')
|
||||||
|
await refreshPost()
|
||||||
} else {
|
} else {
|
||||||
toast.error('操作失败')
|
toast.error('操作失败')
|
||||||
}
|
}
|
||||||
@@ -674,6 +677,7 @@ const rejectPost = async () => {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
status.value = 'REJECTED'
|
status.value = 'REJECTED'
|
||||||
toast.success('已驳回')
|
toast.success('已驳回')
|
||||||
|
await refreshPost()
|
||||||
} else {
|
} else {
|
||||||
toast.error('操作失败')
|
toast.error('操作失败')
|
||||||
}
|
}
|
||||||
@@ -709,7 +713,7 @@ const joinLottery = async () => {
|
|||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
toast.success('已参与抽奖')
|
toast.success('已参与抽奖')
|
||||||
await fetchPost()
|
await refreshPost()
|
||||||
} else {
|
} else {
|
||||||
toast.error('操作失败')
|
toast.error('操作失败')
|
||||||
}
|
}
|
||||||
@@ -780,9 +784,8 @@ onMounted(async () => {
|
|||||||
window.addEventListener('scroll', updateCurrentIndex)
|
window.addEventListener('scroll', updateCurrentIndex)
|
||||||
jumpToHashComment()
|
jumpToHashComment()
|
||||||
})
|
})
|
||||||
|
|
||||||
await fetchPost()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.post-page-container {
|
.post-page-container {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
@@ -864,6 +867,7 @@ await fetchPost()
|
|||||||
direction: ltr;
|
direction: ltr;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -65,12 +72,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
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 { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
||||||
|
import { frostedState, setFrosted } from '~/utils/frosted'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
@@ -87,6 +95,7 @@ const aiFormatLimit = ref(3)
|
|||||||
const registerMode = ref('DIRECT')
|
const registerMode = ref('DIRECT')
|
||||||
const isLoadingPage = ref(false)
|
const isLoadingPage = ref(false)
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
|
const frosted = ref(true)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
isLoadingPage.value = true
|
isLoadingPage.value = true
|
||||||
@@ -105,6 +114,7 @@ onMounted(async () => {
|
|||||||
navigateTo('/login', { replace: true })
|
navigateTo('/login', { replace: true })
|
||||||
}
|
}
|
||||||
isLoadingPage.value = false
|
isLoadingPage.value = false
|
||||||
|
frosted.value = frostedState.enabled
|
||||||
})
|
})
|
||||||
|
|
||||||
const onAvatarChange = (e) => {
|
const onAvatarChange = (e) => {
|
||||||
@@ -118,6 +128,7 @@ const onAvatarChange = (e) => {
|
|||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
watch(frosted, (val) => setFrosted(val))
|
||||||
const onCropped = ({ file, url }) => {
|
const onCropped = ({ file, url }) => {
|
||||||
avatarFile.value = file
|
avatarFile.value = file
|
||||||
avatar.value = url
|
avatar.value = url
|
||||||
@@ -300,6 +311,58 @@ const save = async () => {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const reason = ref('')
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const isWaitingForRegister = ref(false)
|
const isWaitingForRegister = ref(false)
|
||||||
const token = ref('')
|
const token = ref('')
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
token.value = route.query.token || ''
|
token.value = route.query.token || ''
|
||||||
@@ -50,8 +51,8 @@ const submit = async () => {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
token: this.token,
|
token: token.value,
|
||||||
reason: this.reason,
|
reason: reason.value,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
isWaitingForRegister.value = false
|
isWaitingForRegister.value = false
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="other-signup-page-content">
|
<div class="other-signup-page-content">
|
||||||
<div class="signup-page-button" @click="googleAuthorize">
|
<div class="signup-page-button" @click="signupWithGoogle">
|
||||||
<img class="signup-page-button-icon" src="~/assets/icons/google.svg" alt="Google Logo" />
|
<img class="signup-page-button-icon" src="~/assets/icons/google.svg" alt="Google Logo" />
|
||||||
<div class="signup-page-button-text">Google 注册</div>
|
<div class="signup-page-button-text">Google 注册</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +96,9 @@ import { discordAuthorize } from '~/utils/discord'
|
|||||||
import { githubAuthorize } from '~/utils/github'
|
import { githubAuthorize } from '~/utils/github'
|
||||||
import { googleAuthorize } from '~/utils/google'
|
import { googleAuthorize } from '~/utils/google'
|
||||||
import { twitterAuthorize } from '~/utils/twitter'
|
import { twitterAuthorize } from '~/utils/twitter'
|
||||||
|
import { loadCurrentUser, setToken } from '~/utils/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const emailStep = ref(0)
|
const emailStep = ref(0)
|
||||||
@@ -109,9 +112,11 @@ const passwordError = ref('')
|
|||||||
const code = ref('')
|
const code = ref('')
|
||||||
const isWaitingForEmailSent = ref(false)
|
const isWaitingForEmailSent = ref(false)
|
||||||
const isWaitingForEmailVerified = ref(false)
|
const isWaitingForEmailVerified = ref(false)
|
||||||
|
const inviteToken = ref('')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
username.value = route.query.u || ''
|
username.value = route.query.u || ''
|
||||||
|
inviteToken.value = route.query.invite_token || ''
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/config`)
|
const res = await fetch(`${API_BASE_URL}/api/config`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -156,6 +161,7 @@ const sendVerification = async () => {
|
|||||||
username: username.value,
|
username: username.value,
|
||||||
email: email.value,
|
email: email.value,
|
||||||
password: password.value,
|
password: password.value,
|
||||||
|
inviteToken: inviteToken.value,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
isWaitingForEmailSent.value = false
|
isWaitingForEmailSent.value = false
|
||||||
@@ -188,12 +194,19 @@ const verifyCode = async () => {
|
|||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
if (data.reason_code === 'VERIFIED_AND_APPROVED') {
|
||||||
|
toast.success('注册成功')
|
||||||
|
setToken(data.token)
|
||||||
|
loadCurrentUser()
|
||||||
|
navigateTo('/', { replace: true })
|
||||||
|
} else if (data.reason_code === 'VERIFIED') {
|
||||||
if (registerMode.value === 'WHITELIST') {
|
if (registerMode.value === 'WHITELIST') {
|
||||||
navigateTo(`/signup-reason?token=${data.token}`, { replace: true })
|
navigateTo(`/signup-reason?token=${data.token}`, { replace: true })
|
||||||
} else {
|
} else {
|
||||||
toast.success('注册成功,请登录')
|
toast.success('注册成功,请登录')
|
||||||
navigateTo('/login', { replace: true })
|
navigateTo('/login', { replace: true })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || '注册失败')
|
toast.error(data.error || '注册失败')
|
||||||
}
|
}
|
||||||
@@ -203,14 +216,17 @@ const verifyCode = async () => {
|
|||||||
isWaitingForEmailVerified.value = false
|
isWaitingForEmailVerified.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const signupWithGoogle = () => {
|
||||||
|
googleAuthorize(inviteToken.value)
|
||||||
|
}
|
||||||
const signupWithGithub = () => {
|
const signupWithGithub = () => {
|
||||||
githubAuthorize()
|
githubAuthorize(inviteToken.value)
|
||||||
}
|
}
|
||||||
const signupWithDiscord = () => {
|
const signupWithDiscord = () => {
|
||||||
discordAuthorize()
|
discordAuthorize(inviteToken.value)
|
||||||
}
|
}
|
||||||
const signupWithTwitter = () => {
|
const signupWithTwitter = () => {
|
||||||
twitterAuthorize()
|
twitterAuthorize(inviteToken.value)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -35,10 +35,12 @@
|
|||||||
/>
|
/>
|
||||||
<div class="profile-level-target">
|
<div class="profile-level-target">
|
||||||
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
||||||
<i
|
<ToolTip
|
||||||
class="fas fa-info-circle profile-exp-info"
|
content="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||||
title="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
placement="bottom"
|
||||||
></i>
|
>
|
||||||
|
<i class="fas fa-info-circle profile-exp-info"></i>
|
||||||
|
</ToolTip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,12 +206,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
||||||
|
<div class="timeline-tabs">
|
||||||
|
<div
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'all' }]"
|
||||||
|
@click="timelineFilter = 'all'"
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'articles' }]"
|
||||||
|
@click="timelineFilter = 'articles'"
|
||||||
|
>
|
||||||
|
文章
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'comments' }]"
|
||||||
|
@click="timelineFilter = 'comments'"
|
||||||
|
>
|
||||||
|
评论和回复
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<BasePlaceholder
|
<BasePlaceholder
|
||||||
v-if="timelineItems.length === 0"
|
v-if="filteredTimelineItems.length === 0"
|
||||||
text="暂无时间线"
|
text="暂无时间线"
|
||||||
icon="fas fa-inbox"
|
icon="fas fa-inbox"
|
||||||
/>
|
/>
|
||||||
<BaseTimeline :items="timelineItems">
|
<div class="timeline-list">
|
||||||
|
<BaseTimeline :items="filteredTimelineItems">
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<template v-if="item.type === 'post'">
|
<template v-if="item.type === 'post'">
|
||||||
发布了文章
|
发布了文章
|
||||||
@@ -266,6 +289,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</BaseTimeline>
|
</BaseTimeline>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
||||||
<div class="follow-tabs">
|
<div class="follow-tabs">
|
||||||
@@ -324,6 +348,15 @@ const hotPosts = ref([])
|
|||||||
const hotReplies = ref([])
|
const hotReplies = ref([])
|
||||||
const hotTags = ref([])
|
const hotTags = ref([])
|
||||||
const timelineItems = ref([])
|
const timelineItems = ref([])
|
||||||
|
const timelineFilter = ref('all')
|
||||||
|
const filteredTimelineItems = computed(() => {
|
||||||
|
if (timelineFilter.value === 'articles') {
|
||||||
|
return timelineItems.value.filter((item) => item.type === 'post')
|
||||||
|
} else if (timelineFilter.value === 'comments') {
|
||||||
|
return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
|
||||||
|
}
|
||||||
|
return timelineItems.value
|
||||||
|
})
|
||||||
const followers = ref([])
|
const followers = ref([])
|
||||||
const followings = ref([])
|
const followings = ref([])
|
||||||
const medals = ref([])
|
const medals = ref([])
|
||||||
@@ -654,12 +687,6 @@ watch(selectedTab, async (val) => {
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-exp-info {
|
|
||||||
margin-left: 4px;
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-info {
|
.profile-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -700,6 +727,7 @@ watch(selectedTab, async (val) => {
|
|||||||
border-bottom: 1px solid var(--normal-border-color);
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-tabs-item {
|
.profile-tabs-item {
|
||||||
@@ -777,8 +805,24 @@ watch(selectedTab, async (val) => {
|
|||||||
width: 40%;
|
width: 40%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-timeline {
|
.timeline-tabs {
|
||||||
padding: 20px;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list {
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-tab-item {
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-tab-item.selected {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-date {
|
.timeline-date {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { defineNuxtPlugin } from 'nuxt/app'
|
||||||
import ClickOutside from '~/directives/clickOutside.js'
|
import ClickOutside from '~/directives/clickOutside.js'
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineNuxtPlugin } from 'nuxt/app'
|
||||||
|
import { initFrosted } from '~/utils/frosted'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
initFrosted()
|
||||||
|
})
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +0,0 @@
|
|||||||
export default {
|
|
||||||
push(path) {
|
|
||||||
if (process.client) {
|
|
||||||
window.location.href = path
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { toast } from '../main'
|
|||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function discordAuthorize(state = '') {
|
export function discordAuthorize(inviteToken = '') {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
const DISCORD_CLIENT_ID = config.public.discordClientId
|
const DISCORD_CLIENT_ID = config.public.discordClientId
|
||||||
@@ -10,62 +10,60 @@ export function discordAuthorize(state = '') {
|
|||||||
toast.error('Discord 登录不可用')
|
toast.error('Discord 登录不可用')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${WEBSITE_BASE_URL}/discord-callback`
|
const redirectUri = `${WEBSITE_BASE_URL}/discord-callback`
|
||||||
const url = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify%20email&state=${state}`
|
// 用 state 明文携带 invite_token(仅用于回传,不再透传给后端)
|
||||||
|
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`https://discord.com/api/oauth2/authorize` +
|
||||||
|
`?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}` +
|
||||||
|
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||||
|
`&response_type=code` +
|
||||||
|
`&scope=${encodeURIComponent('identify email')}` +
|
||||||
|
`&state=${encodeURIComponent(state)}`
|
||||||
|
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discordExchange(code, state, reason) {
|
export async function discordExchange(code, inviteToken = '', reason = '') {
|
||||||
try {
|
try {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
|
|
||||||
method: 'POST',
|
const payload = {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
code,
|
code,
|
||||||
redirectUri: `${window.location.origin}/discord-callback`,
|
redirectUri: `${window.location.origin}/discord-callback`,
|
||||||
reason,
|
reason,
|
||||||
state,
|
}
|
||||||
}),
|
if (inviteToken) payload.inviteToken = inviteToken // 明文传给后端
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush?.()
|
||||||
return {
|
return { success: true, needReason: false }
|
||||||
success: true,
|
|
||||||
needReason: false,
|
|
||||||
}
|
|
||||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
toast.info('当前为注册审核模式,请填写注册理由')
|
toast.info('当前为注册审核模式,请填写注册理由')
|
||||||
return {
|
return { success: false, needReason: true, token: data.token }
|
||||||
success: false,
|
|
||||||
needReason: true,
|
|
||||||
token: data.token,
|
|
||||||
}
|
|
||||||
} else if (data.reason_code === 'IS_APPROVING') {
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
toast.info('您的注册理由正在审批中')
|
toast.info('您的注册理由正在审批中')
|
||||||
return {
|
return { success: true, needReason: false }
|
||||||
success: true,
|
|
||||||
needReason: false,
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || '登录失败')
|
toast.error(data.error || '登录失败')
|
||||||
return {
|
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||||
success: false,
|
|
||||||
needReason: false,
|
|
||||||
error: data.error || '登录失败',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
toast.error('登录失败')
|
toast.error('登录失败')
|
||||||
return {
|
return { success: false, needReason: false, error: '登录失败' }
|
||||||
success: false,
|
|
||||||
needReason: false,
|
|
||||||
error: '登录失败',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
const FROSTED_KEY = 'frosted-glass'
|
||||||
|
|
||||||
|
export const frostedState = reactive({
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
document.documentElement.dataset.frosted = frostedState.enabled ? 'on' : 'off'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initFrosted() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
const saved = localStorage.getItem(FROSTED_KEY)
|
||||||
|
frostedState.enabled = saved !== 'false'
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFrosted(enabled) {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
frostedState.enabled = enabled
|
||||||
|
localStorage.setItem(FROSTED_KEY, enabled ? 'true' : 'false')
|
||||||
|
apply()
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { toast } from '../main'
|
|||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function githubAuthorize(state = '') {
|
export function githubAuthorize(inviteToken = '') {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
const GITHUB_CLIENT_ID = config.public.githubClientId
|
const GITHUB_CLIENT_ID = config.public.githubClientId
|
||||||
@@ -10,62 +10,58 @@ export function githubAuthorize(state = '') {
|
|||||||
toast.error('GitHub 登录不可用')
|
toast.error('GitHub 登录不可用')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${WEBSITE_BASE_URL}/github-callback`
|
const redirectUri = `${WEBSITE_BASE_URL}/github-callback`
|
||||||
const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email&state=${state}`
|
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`https://github.com/login/oauth/authorize` +
|
||||||
|
`?client_id=${encodeURIComponent(GITHUB_CLIENT_ID)}` +
|
||||||
|
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||||
|
`&scope=${encodeURIComponent('user:email')}` +
|
||||||
|
`&state=${encodeURIComponent(state)}`
|
||||||
|
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function githubExchange(code, state, reason) {
|
export async function githubExchange(code, inviteToken = '', reason = '') {
|
||||||
try {
|
try {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
|
|
||||||
method: 'POST',
|
const payload = {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
code,
|
code,
|
||||||
redirectUri: `${window.location.origin}/github-callback`,
|
redirectUri: `${window.location.origin}/github-callback`,
|
||||||
reason,
|
reason,
|
||||||
state,
|
}
|
||||||
}),
|
if (inviteToken) payload.inviteToken = inviteToken
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush?.()
|
||||||
return {
|
return { success: true, needReason: false }
|
||||||
success: true,
|
|
||||||
needReason: false,
|
|
||||||
}
|
|
||||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||||
toast.info('当前为注册审核模式,请填写注册理由')
|
toast.info('当前为注册审核模式,请填写注册理由')
|
||||||
return {
|
return { success: false, needReason: true, token: data.token }
|
||||||
success: false,
|
|
||||||
needReason: true,
|
|
||||||
token: data.token,
|
|
||||||
}
|
|
||||||
} else if (data.reason_code === 'IS_APPROVING') {
|
} else if (data.reason_code === 'IS_APPROVING') {
|
||||||
toast.info('您的注册理由正在审批中')
|
toast.info('您的注册理由正在审批中')
|
||||||
return {
|
return { success: true, needReason: false }
|
||||||
success: true,
|
|
||||||
needReason: false,
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || '登录失败')
|
toast.error(data.error || '登录失败')
|
||||||
return {
|
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||||
success: false,
|
|
||||||
needReason: false,
|
|
||||||
error: data.error || '登录失败',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
toast.error('登录失败')
|
toast.error('登录失败')
|
||||||
return {
|
return { success: false, needReason: false, error: '登录失败' }
|
||||||
success: false,
|
|
||||||
needReason: false,
|
|
||||||
error: '登录失败',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,43 +21,85 @@ export async function googleGetIdToken() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function googleAuthorize() {
|
export function googleAuthorize(inviteToken = '') {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
||||||
|
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||||
|
|
||||||
if (!GOOGLE_CLIENT_ID) {
|
if (!GOOGLE_CLIENT_ID) {
|
||||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${WEBSITE_BASE_URL}/google-callback`
|
const redirectUri = `${WEBSITE_BASE_URL}/google-callback`
|
||||||
const nonce = Math.random().toString(36).substring(2)
|
const nonce = Math.random().toString(36).slice(2)
|
||||||
const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=id_token&scope=openid%20email%20profile&nonce=${nonce}`
|
|
||||||
|
// 明文放在 state(推荐;Google 会原样回传)
|
||||||
|
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`https://accounts.google.com/o/oauth2/v2/auth` +
|
||||||
|
`?client_id=${encodeURIComponent(GOOGLE_CLIENT_ID)}` +
|
||||||
|
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||||
|
`&response_type=id_token` +
|
||||||
|
`&scope=${encodeURIComponent('openid email profile')}` +
|
||||||
|
`&nonce=${encodeURIComponent(nonce)}` +
|
||||||
|
`&state=${encodeURIComponent(state)}`
|
||||||
|
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) {
|
export async function googleAuthWithToken(
|
||||||
|
idToken,
|
||||||
|
redirect_success,
|
||||||
|
redirect_not_approved,
|
||||||
|
options = {}, // { inviteToken?: string }
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
|
if (!idToken) {
|
||||||
|
toast.error('缺少 id_token')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
|
const payload = { idToken }
|
||||||
|
if (options && options.inviteToken) {
|
||||||
|
payload.inviteToken = options.inviteToken
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
body: JSON.stringify({ idToken }),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
|
||||||
if (res.ok && data.token) {
|
const data = await res.json().catch(() => ({}))
|
||||||
|
|
||||||
|
if (res.ok && data && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush?.()
|
||||||
if (redirect_success) redirect_success()
|
if (typeof redirect_success === 'function') redirect_success()
|
||||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
return
|
||||||
toast.info('当前为注册审核模式,请填写注册理由')
|
|
||||||
if (redirect_not_approved) redirect_not_approved(data.token)
|
|
||||||
} else if (data.reason_code === 'IS_APPROVING') {
|
|
||||||
toast.info('您的注册理由正在审批中')
|
|
||||||
if (redirect_success) redirect_success()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data && data.reason_code === 'NOT_APPROVED') {
|
||||||
|
toast.info('当前为注册审核模式,请填写注册理由')
|
||||||
|
if (typeof redirect_not_approved === 'function') redirect_not_approved(data.token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && data.reason_code === 'IS_APPROVING') {
|
||||||
|
toast.info('您的注册理由正在审批中')
|
||||||
|
if (typeof redirect_success === 'function') redirect_success()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error(data?.message || '登录失败')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
toast.error('登录失败')
|
toast.error('登录失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user