mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-09 16:41:04 +08:00
Compare commits
1 Commits
feature/re
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5ec0348a9 |
23
.github/workflows/deploy-staging.yml
vendored
23
.github/workflows/deploy-staging.yml
vendored
@@ -1,23 +0,0 @@
|
||||
name: Staging CI & CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: Deploy
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to Server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: bash /opt/openisle/deploy-staging.sh
|
||||
|
||||
20
.github/workflows/deploy.yml
vendored
20
.github/workflows/deploy.yml
vendored
@@ -1,9 +1,9 @@
|
||||
name: CI & CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
@@ -13,6 +13,22 @@ jobs:
|
||||
steps:
|
||||
- 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
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
|
||||
25
README.md
25
README.md
@@ -10,7 +10,7 @@
|
||||
|
||||
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
|
||||
|
||||
## 🚧 开发
|
||||
## 🚀 部署
|
||||
|
||||
### 后端
|
||||
|
||||
@@ -20,26 +20,9 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
||||
|
||||
### 前端
|
||||
|
||||
1. 进入前端目录
|
||||
```bash
|
||||
cd frontend_nuxt
|
||||
```
|
||||
2. 安装依赖
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
3. 启动开发服务
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
生产版本使用如下命令编译:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
会在 `.output` 目录生成文件,配合线上网站方式部署
|
||||
1. `cd open-isle-cli`
|
||||
2. 执行 `npm install`
|
||||
3. `npm run serve`可在本地启动开发服务,产品环境使用 `npm run build`生成 `dist/` 文件,配合线上网站方式部署
|
||||
|
||||
## ✨ 项目特点
|
||||
|
||||
|
||||
@@ -3,12 +3,6 @@ MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&
|
||||
MYSQL_USER=<数据库用户名>
|
||||
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_API_KEY=<你的resend-api-key>
|
||||
@@ -36,4 +30,4 @@ OPENAI_API_KEY=<你的openai-api-key>
|
||||
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
|
||||
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
|
||||
|
||||
# LOG_LEVEL=DEBUG
|
||||
# LOG_LEVEL=DEBUG
|
||||
@@ -6,8 +6,6 @@ import com.openisle.repository.ActivityRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -24,16 +22,5 @@ public class ActivityInitializer implements CommandLineRunner {
|
||||
a.setContent("为了有利于建站推广以及激励发布内容,我们推出了建站送奶茶的活动,前50名达到level 1的用户,可以联系站长获取奶茶/咖啡一杯");
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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,16 +75,14 @@ public class SecurityConfig {
|
||||
cfg.setAllowedOrigins(List.of(
|
||||
"http://127.0.0.1:8080",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:3001",
|
||||
"http://127.0.0.1",
|
||||
"http://localhost:8080",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"http://localhost",
|
||||
"http://30.211.97.238:3000",
|
||||
"http://30.211.97.238",
|
||||
"http://192.168.7.98",
|
||||
"http://192.168.7.98:3000",
|
||||
"http://192.168.7.70",
|
||||
"http://192.168.7.70:8080",
|
||||
websiteUrl,
|
||||
websiteUrl.replace("://www.", "://")
|
||||
));
|
||||
@@ -119,8 +117,6 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/activities/**").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/tags/**").authenticated()
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN")
|
||||
@@ -153,7 +149,6 @@ public class SecurityConfig {
|
||||
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
|
||||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
|
||||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
|
||||
uri.startsWith("/api/point-goods") ||
|
||||
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals"));
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
|
||||
@@ -29,7 +29,6 @@ public class AuthController {
|
||||
private final RegisterModeService registerModeService;
|
||||
private final NotificationService notificationService;
|
||||
private final UserRepository userRepository;
|
||||
private final InviteService inviteService;
|
||||
|
||||
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
@@ -46,26 +45,6 @@ public class AuthController {
|
||||
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
|
||||
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(
|
||||
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
|
||||
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
|
||||
@@ -79,26 +58,10 @@ public class AuthController {
|
||||
public ResponseEntity<?> verify(@RequestBody VerifyRequest req) {
|
||||
boolean ok = userService.verifyCode(req.getUsername(), req.getCode());
|
||||
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(
|
||||
"message", "Verified",
|
||||
"reason_code", "VERIFIED",
|
||||
"token", jwtService.generateReasonToken(req.getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Verified",
|
||||
"token", jwtService.generateReasonToken(req.getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code"));
|
||||
}
|
||||
@@ -143,42 +106,27 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/google")
|
||||
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = googleAuthService.authenticate(
|
||||
req.getIdToken(),
|
||||
registerModeService.getRegisterMode(),
|
||||
viaInvite);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(req.getInviteToken());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
));
|
||||
}
|
||||
Optional<User> user = googleAuthService.authenticate(req.getIdToken(), registerModeService.getRegisterMode());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid google token",
|
||||
@@ -217,44 +165,28 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/github")
|
||||
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = githubAuthService.authenticate(
|
||||
req.getCode(),
|
||||
registerModeService.getRegisterMode(),
|
||||
req.getRedirectUri(),
|
||||
viaInvite);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(req.getInviteToken());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
));
|
||||
}
|
||||
Optional<User> user = githubAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
// 已填写注册理由
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid github code",
|
||||
@@ -264,43 +196,27 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/discord")
|
||||
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = discordAuthService.authenticate(
|
||||
req.getCode(),
|
||||
registerModeService.getRegisterMode(),
|
||||
req.getRedirectUri(),
|
||||
viaInvite);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(req.getInviteToken());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
));
|
||||
}
|
||||
Optional<User> user = discordAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid discord code",
|
||||
@@ -310,44 +226,31 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/twitter")
|
||||
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = twitterAuthService.authenticate(
|
||||
Optional<User> user = twitterAuthService.authenticate(
|
||||
req.getCode(),
|
||||
req.getCodeVerifier(),
|
||||
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"
|
||||
));
|
||||
}
|
||||
req.getRedirectUri());
|
||||
if (user.isPresent()) {
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||
if (!user.get().isApproved()) {
|
||||
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
"token", jwtService.generateReasonToken(user.get().getUsername())
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid twitter code",
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,4 @@ import lombok.Data;
|
||||
public class DiscordLoginRequest {
|
||||
private String code;
|
||||
private String redirectUri;
|
||||
private String inviteToken;
|
||||
}
|
||||
|
||||
@@ -7,5 +7,4 @@ import lombok.Data;
|
||||
public class GithubLoginRequest {
|
||||
private String code;
|
||||
private String redirectUri;
|
||||
private String inviteToken;
|
||||
}
|
||||
|
||||
@@ -6,5 +6,4 @@ import lombok.Data;
|
||||
@Data
|
||||
public class GoogleLoginRequest {
|
||||
private String idToken;
|
||||
private String inviteToken;
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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,5 +9,4 @@ public class RegisterRequest {
|
||||
private String email;
|
||||
private String password;
|
||||
private String captcha;
|
||||
private String inviteToken;
|
||||
}
|
||||
|
||||
@@ -8,5 +8,4 @@ public class TwitterLoginRequest {
|
||||
private String code;
|
||||
private String redirectUri;
|
||||
private String codeVerifier;
|
||||
private String inviteToken;
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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,6 +3,5 @@ package com.openisle.model;
|
||||
/** Activity type enumeration. */
|
||||
public enum ActivityType {
|
||||
NORMAL,
|
||||
MILK_TEA,
|
||||
INVITE_POINTS
|
||||
MILK_TEA
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -32,8 +32,6 @@ public enum NotificationType {
|
||||
REGISTER_REQUEST,
|
||||
/** A user redeemed an activity reward */
|
||||
ACTIVITY_REDEEM,
|
||||
/** A user redeemed a point good */
|
||||
POINT_REDEEM,
|
||||
/** You won a lottery post */
|
||||
LOTTERY_WIN,
|
||||
/** Your lottery post was drawn */
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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> {
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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:}")
|
||||
private String clientSecret;
|
||||
|
||||
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
|
||||
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
|
||||
try {
|
||||
String tokenUrl = "https://discord.com/api/oauth2/token";
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
@@ -67,13 +67,13 @@ public class DiscordAuthService {
|
||||
if (email == null) {
|
||||
email = (username != null ? username : id) + "@users.noreply.discord.com";
|
||||
}
|
||||
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
|
||||
return Optional.of(processUser(email, username, avatar, mode));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
Optional<User> existing = userRepository.findByEmail(email);
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
@@ -82,7 +82,7 @@ public class DiscordAuthService {
|
||||
user.setVerificationCode(null);
|
||||
userRepository.save(user);
|
||||
}
|
||||
return new AuthResult(user, false);
|
||||
return user;
|
||||
}
|
||||
String baseUsername = username != null ? username : email.split("@")[0];
|
||||
String finalUsername = baseUsername;
|
||||
@@ -96,12 +96,12 @@ public class DiscordAuthService {
|
||||
user.setPassword("");
|
||||
user.setRole(Role.USER);
|
||||
user.setVerified(true);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
|
||||
}
|
||||
return new AuthResult(userRepository.save(user), true);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ public class GithubAuthService {
|
||||
@Value("${github.client-secret:}")
|
||||
private String clientSecret;
|
||||
|
||||
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
|
||||
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
|
||||
try {
|
||||
String tokenUrl = "https://github.com/login/oauth/access_token";
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
@@ -86,13 +86,13 @@ public class GithubAuthService {
|
||||
if (email == null) {
|
||||
email = username + "@users.noreply.github.com";
|
||||
}
|
||||
return Optional.of(processUser(email, username, avatarUrl, mode, viaInvite));
|
||||
return Optional.of(processUser(email, username, avatarUrl, mode));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
Optional<User> existing = userRepository.findByEmail(email);
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
@@ -101,7 +101,7 @@ public class GithubAuthService {
|
||||
user.setVerificationCode(null);
|
||||
userRepository.save(user);
|
||||
}
|
||||
return new AuthResult(user, false);
|
||||
return user;
|
||||
}
|
||||
String baseUsername = username != null ? username : email.split("@")[0];
|
||||
String finalUsername = baseUsername;
|
||||
@@ -115,12 +115,12 @@ public class GithubAuthService {
|
||||
user.setPassword("");
|
||||
user.setRole(Role.USER);
|
||||
user.setVerified(true);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar(avatarGenerator.generate(finalUsername));
|
||||
}
|
||||
return new AuthResult(userRepository.save(user), true);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public class GoogleAuthService {
|
||||
@Value("${google.client-id:}")
|
||||
private String clientId;
|
||||
|
||||
public Optional<AuthResult> authenticate(String idTokenString, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||
public Optional<User> authenticate(String idTokenString, com.openisle.model.RegisterMode mode) {
|
||||
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory())
|
||||
.setAudience(Collections.singletonList(clientId))
|
||||
.build();
|
||||
@@ -38,13 +38,13 @@ public class GoogleAuthService {
|
||||
String email = payload.getEmail();
|
||||
String name = (String) payload.get("name");
|
||||
String picture = (String) payload.get("picture");
|
||||
return Optional.of(processUser(email, name, picture, mode, viaInvite));
|
||||
return Optional.of(processUser(email, name, picture, mode));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private AuthResult processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||
private User processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
Optional<User> existing = userRepository.findByEmail(email);
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
@@ -53,7 +53,8 @@ public class GoogleAuthService {
|
||||
user.setVerificationCode(null);
|
||||
userRepository.save(user);
|
||||
}
|
||||
return new AuthResult(user, false);
|
||||
|
||||
return user;
|
||||
}
|
||||
User user = new User();
|
||||
String baseUsername = email.split("@")[0];
|
||||
@@ -67,12 +68,12 @@ public class GoogleAuthService {
|
||||
user.setPassword("");
|
||||
user.setRole(Role.USER);
|
||||
user.setVerified(true);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar(avatarGenerator.generate(username));
|
||||
}
|
||||
return new AuthResult(userRepository.save(user), true);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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,9 +24,6 @@ public class JwtService {
|
||||
@Value("${app.jwt.reset-secret}")
|
||||
private String resetSecret;
|
||||
|
||||
@Value("${app.jwt.invite-secret}")
|
||||
private String inviteSecret;
|
||||
|
||||
@Value("${app.jwt.expiration}")
|
||||
private long expiration;
|
||||
|
||||
@@ -73,17 +70,6 @@ public class JwtService {
|
||||
.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) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKeyForSecret(secret))
|
||||
@@ -110,13 +96,4 @@ public class JwtService {
|
||||
.getBody();
|
||||
return claims.getSubject();
|
||||
}
|
||||
|
||||
public String validateAndGetSubjectForInvite(String token) {
|
||||
Claims claims = Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKeyForSecret(inviteSecret))
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
return claims.getSubject();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,19 +141,6 @@ 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) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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,11 +26,6 @@ public class PointService {
|
||||
return addPoint(user, 30);
|
||||
}
|
||||
|
||||
public int awardForInvite(String userName) {
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
return addPoint(user, 500);
|
||||
}
|
||||
|
||||
private PointLog getTodayLog(User user) {
|
||||
LocalDate today = LocalDate.now();
|
||||
return pointLogRepository.findByUserAndLogDate(user, today)
|
||||
|
||||
@@ -33,12 +33,11 @@ public class TwitterAuthService {
|
||||
@Value("${twitter.client-secret:}")
|
||||
private String clientSecret;
|
||||
|
||||
public Optional<AuthResult> authenticate(
|
||||
public Optional<User> authenticate(
|
||||
String code,
|
||||
String codeVerifier,
|
||||
RegisterMode mode,
|
||||
String redirectUri,
|
||||
boolean viaInvite) {
|
||||
String redirectUri) {
|
||||
|
||||
logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier);
|
||||
|
||||
@@ -107,10 +106,10 @@ public class TwitterAuthService {
|
||||
// Twitter v2 默认拿不到 email;如果你申请到 email.scope,可改用 /2/users/:id?user.fields=email
|
||||
String email = username + "@twitter.com";
|
||||
logger.debug("Processing user {} with email {}", username, email);
|
||||
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
|
||||
return Optional.of(processUser(email, username, avatar, mode));
|
||||
}
|
||||
|
||||
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
|
||||
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
|
||||
Optional<User> existing = userRepository.findByEmail(email);
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
@@ -120,7 +119,7 @@ public class TwitterAuthService {
|
||||
userRepository.save(user);
|
||||
}
|
||||
logger.debug("Existing user {} authenticated", user.getUsername());
|
||||
return new AuthResult(user, false);
|
||||
return user;
|
||||
}
|
||||
String baseUsername = username != null ? username : email.split("@")[0];
|
||||
String finalUsername = baseUsername;
|
||||
@@ -134,13 +133,13 @@ public class TwitterAuthService {
|
||||
user.setPassword("");
|
||||
user.setRole(Role.USER);
|
||||
user.setVerified(true);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
|
||||
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
|
||||
}
|
||||
logger.debug("Creating new user {}", finalUsername);
|
||||
return new AuthResult(userRepository.save(user), true);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,13 +74,6 @@ public class UserService {
|
||||
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() {
|
||||
return String.format("%06d", new Random().nextInt(1000000));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ spring.jpa.hibernate.ddl-auto=update
|
||||
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
||||
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
|
||||
app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec}
|
||||
app.jwt.invite-secret=${JWT_INVITE_SECRET:jwt_invite_sec}
|
||||
# 30 days
|
||||
app.jwt.expiration=${JWT_EXPIRATION:2592000000}
|
||||
# Password strength: LOW, MEDIUM or HIGH
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE notifications
|
||||
MODIFY COLUMN type VARCHAR(50) NOT NULL;
|
||||
@@ -144,30 +144,6 @@ class NotificationServiceTest {
|
||||
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
|
||||
void createNotificationSendsEmailForCommentReply() {
|
||||
NotificationRepository nRepo = mock(NotificationRepository.class);
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
; 本地部署后端
|
||||
; 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_GOOGLE_CLIENT_ID=777830451304-xxx.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
; 本地部署后端
|
||||
; 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
|
||||
@@ -15,77 +15,62 @@
|
||||
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
|
||||
<NuxtPage keepalive />
|
||||
</div>
|
||||
|
||||
<div v-if="showNewPostIcon && isMobile" class="app-new-post-icon" @click="goToNewPost">
|
||||
<i class="fas fa-edit"></i>
|
||||
</div>
|
||||
</div>
|
||||
<GlobalPopups />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script>
|
||||
import HeaderComponent from '~/components/HeaderComponent.vue'
|
||||
import MenuComponent from '~/components/MenuComponent.vue'
|
||||
|
||||
import GlobalPopups from '~/components/GlobalPopups.vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const menuVisible = ref(!isMobile.value)
|
||||
export default {
|
||||
name: 'App',
|
||||
components: { HeaderComponent, MenuComponent, GlobalPopups },
|
||||
setup() {
|
||||
const isMobile = useIsMobile()
|
||||
const menuVisible = ref(!isMobile.value)
|
||||
const hideMenu = computed(() => {
|
||||
return [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/404',
|
||||
'/signup-reason',
|
||||
'/github-callback',
|
||||
'/twitter-callback',
|
||||
'/discord-callback',
|
||||
'/forgot-password',
|
||||
'/google-callback',
|
||||
].includes(useRoute().path)
|
||||
})
|
||||
|
||||
const showNewPostIcon = computed(() => useRoute().path === '/')
|
||||
const header = useTemplateRef('header')
|
||||
|
||||
const hideMenu = computed(() => {
|
||||
return [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/404',
|
||||
'/signup-reason',
|
||||
'/github-callback',
|
||||
'/twitter-callback',
|
||||
'/discord-callback',
|
||||
'/forgot-password',
|
||||
'/google-callback',
|
||||
].includes(useRoute().path)
|
||||
})
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
menuVisible.value = window.innerWidth > 768
|
||||
}
|
||||
})
|
||||
|
||||
const header = useTemplateRef('header')
|
||||
const handleMenuOutside = (event) => {
|
||||
const btn = header.value.$refs.menuBtn
|
||||
if (btn && (btn === event.target || btn.contains(event.target))) {
|
||||
return // 如果是菜单按钮的点击,不处理关闭
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
menuVisible.value = window.innerWidth > 768
|
||||
}
|
||||
})
|
||||
if (isMobile.value) {
|
||||
menuVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleMenuOutside = (event) => {
|
||||
const btn = header.value.$refs.menuBtn
|
||||
if (btn && (btn === event.target || btn.contains(event.target))) {
|
||||
return // 如果是菜单按钮的点击,不处理关闭
|
||||
}
|
||||
|
||||
if (isMobile.value) {
|
||||
menuVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goToNewPost = () => {
|
||||
navigateTo('/new-post', { replace: false })
|
||||
return { menuVisible, hideMenu, handleMenuOutside, header }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style src="~/assets/global.css"></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 {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -118,24 +103,6 @@ const goToNewPost = () => {
|
||||
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) {
|
||||
.content,
|
||||
.content.menu-open {
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/* 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,35 +2,27 @@
|
||||
--primary-color-hover: rgb(9, 95, 105);
|
||||
--primary-color: rgb(10, 110, 120);
|
||||
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||
--header-height: 60px;
|
||||
--header-background-color: white;
|
||||
--header-border-color: lightgray;
|
||||
--header-text-color: black;
|
||||
--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;
|
||||
--menu-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;
|
||||
--normal-border-color: lightgray;
|
||||
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
|
||||
--menu-text-color: black;
|
||||
--scroller-background-color: rgba(130, 175, 180, 0.5);
|
||||
/* --normal-background-color: rgb(241, 241, 241); */
|
||||
--normal-background-color: white;
|
||||
--normal-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-hover: #e0e0e0;
|
||||
--text-color: black;
|
||||
--blockquote-text-color: #6a737d;
|
||||
--menu-width: 200px;
|
||||
--page-max-width: 1400px;
|
||||
--page-max-width: 1200px;
|
||||
--page-max-width-mobile: 900px;
|
||||
--article-info-background-color: #f0f0f0;
|
||||
--activity-card-background-color: #fafafa;
|
||||
@@ -41,9 +33,8 @@
|
||||
--header-border-color: #555;
|
||||
--primary-color: rgb(17, 182, 197);
|
||||
--primary-color-hover: rgb(13, 137, 151);
|
||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||
--header-text-color: white;
|
||||
--app-menu-background-color: #333;
|
||||
--menu-background-color: #333;
|
||||
--background-color: #333;
|
||||
/* --background-color-blur: #333333a4; */
|
||||
--background-color-blur: var(--background-color);
|
||||
@@ -51,10 +42,8 @@
|
||||
--normal-border-color: #555;
|
||||
--menu-selected-background-color: rgba(255, 255, 255, 0.1);
|
||||
--menu-text-color: white;
|
||||
/* --normal-background-color: #000000; */
|
||||
--normal-background-color: #333;
|
||||
--normal-background-color: #000000;
|
||||
--lottery-background-color: #4e4e4e;
|
||||
--code-highlight-background-color: #262b35;
|
||||
--login-background-color: #575757;
|
||||
--login-background-color-hover: #717171;
|
||||
--text-color: #eee;
|
||||
@@ -63,15 +52,6 @@
|
||||
--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 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -151,43 +131,13 @@ body {
|
||||
}
|
||||
|
||||
.info-content-text pre {
|
||||
display: flex;
|
||||
background-color: var(--code-highlight-background-color);
|
||||
background-color: var(--normal-background-color);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
@@ -206,13 +156,20 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.about-content a,
|
||||
.info-content-text code {
|
||||
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 {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.about-content a:hover,
|
||||
.info-content-text a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -310,7 +267,7 @@ body {
|
||||
}
|
||||
|
||||
.info-content-text pre {
|
||||
line-height: 1.5;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vditor-panel {
|
||||
@@ -319,29 +276,6 @@ 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 {
|
||||
pointer-events: none;
|
||||
|
||||
@@ -41,8 +41,8 @@ export default {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
backdrop-filter: var(--blur-2);
|
||||
-webkit-backdrop-filter: var(--blur-2);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
.popup-content {
|
||||
position: relative;
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
<div class="info-content-header-left">
|
||||
<span class="user-name">{{ comment.userName }}</span>
|
||||
<i class="fas fa-medal medal-icon"></i>
|
||||
<NuxtLink
|
||||
<router-link
|
||||
v-if="comment.medal"
|
||||
class="medal-name"
|
||||
:to="`/users/${comment.userId}?tab=achievements`"
|
||||
>{{ getMedalTitle(comment.medal) }}</NuxtLink
|
||||
>{{ getMedalTitle(comment.medal) }}</router-link
|
||||
>
|
||||
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
|
||||
<span v-if="level >= 2">
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
|
||||
export default {
|
||||
@@ -312,7 +312,7 @@ export default {
|
||||
border: none;
|
||||
outline: none;
|
||||
margin-left: 5px;
|
||||
background-color: var(--app-menu-background-color);
|
||||
background-color: var(--menu-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ export default {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--app-menu-background-color);
|
||||
background-color: var(--menu-background-color);
|
||||
z-index: 1300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -3,24 +3,22 @@
|
||||
<div class="dropdown-trigger" @click="toggle">
|
||||
<slot name="trigger"></slot>
|
||||
</div>
|
||||
<Transition name="dropdown-menu">
|
||||
<div v-if="visible" class="dropdown-menu-container">
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
class="dropdown-item"
|
||||
:style="{ color: item.color || 'inherit' }"
|
||||
@click="handle(item)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</div>
|
||||
<div v-if="visible" class="dropdown-menu-container">
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
class="dropdown-item"
|
||||
:style="{ color: item.color || 'inherit' }"
|
||||
@click="handle(item)"
|
||||
>
|
||||
{{ item.text }}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
export default {
|
||||
name: 'DropdownMenu',
|
||||
props: {
|
||||
@@ -63,28 +61,17 @@ export default {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background-color: var(--app-menu-background-color);
|
||||
background-color: var(--menu-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
@@ -95,9 +82,7 @@ export default {
|
||||
.dropdown-item {
|
||||
padding: 8px 16px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
@@ -8,13 +8,6 @@
|
||||
/>
|
||||
<NotificationSettingPopup :visible="showNotificationPopup" @close="closeNotificationPopup" />
|
||||
<MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" />
|
||||
|
||||
<ActivityPopup
|
||||
:visible="showInviteCodePopup"
|
||||
:icon="inviteCodeIcon"
|
||||
text="邀请码活动开始了,速来参与大伙们🔥🔥🔥"
|
||||
@close="closeInviteCodePopup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,10 +21,7 @@ const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const showMilkTeaPopup = ref(false)
|
||||
const showInviteCodePopup = ref(false)
|
||||
const milkTeaIcon = ref('')
|
||||
const inviteCodeIcon = ref('')
|
||||
|
||||
const showNotificationPopup = ref(false)
|
||||
const showMedalPopup = ref(false)
|
||||
const newMedals = ref([])
|
||||
@@ -40,9 +30,6 @@ onMounted(async () => {
|
||||
await checkMilkTeaActivity()
|
||||
if (showMilkTeaPopup.value) return
|
||||
|
||||
await checkInviteCodeActivity()
|
||||
if (showInviteCodePopup.value) return
|
||||
|
||||
await checkNotificationSetting()
|
||||
if (showNotificationPopup.value) return
|
||||
|
||||
@@ -66,38 +53,12 @@ const checkMilkTeaActivity = async () => {
|
||||
// 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 = () => {
|
||||
if (!process.client) return
|
||||
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||
showMilkTeaPopup.value = false
|
||||
checkNotificationSetting()
|
||||
}
|
||||
|
||||
const checkNotificationSetting = async () => {
|
||||
if (!process.client) return
|
||||
if (!authState.loggedIn) return
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</button>
|
||||
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
||||
</div>
|
||||
<NuxtLink class="logo-container" :to="`/`" @click="refrechData">
|
||||
<NuxtLink class="logo-container" :to="`/`">
|
||||
<img
|
||||
alt="OpenIsle"
|
||||
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
||||
@@ -20,22 +20,11 @@
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<div class="header-content-right">
|
||||
<div v-if="isLogin" class="header-content-right">
|
||||
<div v-if="isMobile" class="search-icon" @click="search">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<DropdownMenu ref="userMenu" :items="headerMenuItems">
|
||||
<template #trigger>
|
||||
<div class="avatar-container">
|
||||
<img class="avatar-img" :src="avatar" alt="avatar" />
|
||||
@@ -43,11 +32,14 @@
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div v-if="!isLogin" class="auth-btns">
|
||||
<div class="header-content-item-main" @click="goToLogin">登录</div>
|
||||
<div class="header-content-item-secondary" @click="goToSignup">注册</div>
|
||||
<div v-else class="header-content-right">
|
||||
<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-secondary" @click="goToSignup">注册</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
|
||||
@@ -60,13 +52,10 @@
|
||||
import { ClientOnly } from '#components'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||
import ToolTip from '~/components/ToolTip.vue'
|
||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||
|
||||
const props = defineProps({
|
||||
showMenuBtn: {
|
||||
type: Boolean,
|
||||
@@ -124,33 +113,12 @@ const goToLogout = () => {
|
||||
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(() => [
|
||||
{ text: '设置', onClick: goToSettings },
|
||||
{ text: '个人主页', onClick: goToProfile },
|
||||
{ text: '退出', onClick: goToLogout },
|
||||
])
|
||||
|
||||
/** 其余逻辑保持不变 */
|
||||
const iconClass = computed(() => {
|
||||
switch (themeState.mode) {
|
||||
case ThemeMode.DARK:
|
||||
return 'fas fa-moon'
|
||||
case ThemeMode.LIGHT:
|
||||
return 'fas fa-sun'
|
||||
default:
|
||||
return 'fas fa-desktop'
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const updateAvatar = async () => {
|
||||
if (authState.loggedIn) {
|
||||
@@ -178,17 +146,25 @@ onMounted(async () => {
|
||||
await updateUnread()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value.fullPath,
|
||||
() => {
|
||||
if (userMenu.value) userMenu.value.close()
|
||||
showSearch.value = false
|
||||
},
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--header-height);
|
||||
background-color: var(--background-color-blur);
|
||||
backdrop-filter: var(--blur-10);
|
||||
backdrop-filter: blur(10px);
|
||||
color: var(--header-text-color);
|
||||
border-bottom: 1px solid var(--header-border-color);
|
||||
}
|
||||
@@ -207,10 +183,11 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
max-width: var(--page-max-width);
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.header-content-left {
|
||||
@@ -220,14 +197,6 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.header-content-right {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.auth-btns {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -309,13 +278,7 @@ onMounted(async () => {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.search-icon,
|
||||
.theme-icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-post-icon {
|
||||
.search-icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
<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 class="invite-code-link-content-text">
|
||||
邀请链接:{{ 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;
|
||||
}
|
||||
|
||||
.invite-code-link-content-text {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.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;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
backdrop-filter: var(--blur-4);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,195 +1,168 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<nav v-if="visible" class="menu">
|
||||
<div class="menu-content">
|
||||
<div class="menu-item-container">
|
||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||
<span class="menu-item-text">话题</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/new-post"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-edit"></i>
|
||||
<span class="menu-item-text">发帖</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/message"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-envelope"></i>
|
||||
<span class="menu-item-text">我的消息</span>
|
||||
<span v-if="unreadCount > 0" class="unread-container">
|
||||
<span class="unread"> {{ showUnreadCount }} </span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-info-circle"></i>
|
||||
<span class="menu-item-text">关于</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/activities"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-gift"></i>
|
||||
<span class="menu-item-text">🔥 活动</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="shouldShowStats"
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about/stats"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||
<span class="menu-item-text">站点统计</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="authState.loggedIn"
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/points"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-coins"></i>
|
||||
<span class="menu-item-text">
|
||||
积分商城
|
||||
<span v-if="myPoint !== null" class="point-count">{{ myPoint }}</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="menu-item-container">
|
||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||
<span class="menu-item-text">话题</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/message"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-envelope"></i>
|
||||
<span class="menu-item-text">我的消息</span>
|
||||
<span v-if="unreadCount > 0" class="unread-container">
|
||||
<span class="unread"> {{ showUnreadCount }} </span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-info-circle"></i>
|
||||
<span class="menu-item-text">关于</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/activities"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-gift"></i>
|
||||
<span class="menu-item-text">🔥 活动</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="shouldShowStats"
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/about/stats"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||
<span class="menu-item-text">站点统计</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="menu-item"
|
||||
exact-active-class="selected"
|
||||
to="/new-post"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-edit"></i>
|
||||
<span class="menu-item-text">发帖</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||
<span>类别</span>
|
||||
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-if="categoryOpen" class="section-items">
|
||||
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="c in categoryData"
|
||||
:key="c.id"
|
||||
class="section-item"
|
||||
@click="gotoCategory(c)"
|
||||
>
|
||||
<template v-if="c.smallIcon || c.icon">
|
||||
<img
|
||||
v-if="isImageIcon(c.smallIcon || c.icon)"
|
||||
:src="c.smallIcon || c.icon"
|
||||
class="section-item-icon"
|
||||
:alt="c.name"
|
||||
/>
|
||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||
</template>
|
||||
<span class="section-item-text">
|
||||
{{ c.name }}
|
||||
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||
<span>类别</span>
|
||||
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||
<span>tag</span>
|
||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
<div v-if="categoryOpen" class="section-items">
|
||||
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-if="tagOpen" class="section-items">
|
||||
<div v-if="isLoadingTag" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
||||
<div
|
||||
v-else
|
||||
v-for="c in categoryData"
|
||||
:key="c.id"
|
||||
class="section-item"
|
||||
@click="gotoCategory(c)"
|
||||
>
|
||||
<template v-if="c.smallIcon || c.icon">
|
||||
<img
|
||||
v-if="isImageIcon(t.smallIcon || t.icon)"
|
||||
:src="t.smallIcon || t.icon"
|
||||
v-if="isImageIcon(c.smallIcon || c.icon)"
|
||||
:src="c.smallIcon || c.icon"
|
||||
class="section-item-icon"
|
||||
:alt="t.name"
|
||||
:alt="c.name"
|
||||
/>
|
||||
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
||||
<span class="section-item-text"
|
||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||
>
|
||||
</div>
|
||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||
</template>
|
||||
<span class="section-item-text">
|
||||
{{ c.name }}
|
||||
<span class="section-item-text-count" v-if="c.count >= 0">x {{ c.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 解决动态样式的水合错误 -->
|
||||
<ClientOnly v-if="!isMobile">
|
||||
<div class="menu-footer">
|
||||
<div class="menu-footer-btn" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||
<span>tag</span>
|
||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-if="tagOpen" class="section-items">
|
||||
<div v-if="isLoadingTag" class="menu-loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
|
||||
<img
|
||||
v-if="isImageIcon(t.smallIcon || t.icon)"
|
||||
:src="t.smallIcon || t.icon"
|
||||
class="section-item-icon"
|
||||
:alt="t.name"
|
||||
/>
|
||||
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
||||
<span class="section-item-text"
|
||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<div class="menu-footer">
|
||||
<div class="menu-footer-btn" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { authState, fetchCurrentUser } from '~/utils/auth'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||
import { authState } from '~/utils/auth'
|
||||
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 API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: true },
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['item-click'])
|
||||
|
||||
const categoryOpen = ref(true)
|
||||
const tagOpen = ref(true)
|
||||
const myPoint = ref(null)
|
||||
const isLoadingCategory = ref(false)
|
||||
const isLoadingTag = ref(false)
|
||||
const categoryData = ref([])
|
||||
const tagData = ref([])
|
||||
|
||||
/** ✅ 用 useAsyncData 替换原生 fetch,避免 SSR+CSR 二次请求 */
|
||||
const {
|
||||
data: categoryData,
|
||||
pending: isLoadingCategory,
|
||||
error: categoryError,
|
||||
} = await useAsyncData(
|
||||
// 稳定 key:避免 hydration 期误判
|
||||
'menu:categories',
|
||||
() => $fetch(`${API_BASE_URL}/api/categories`),
|
||||
{
|
||||
server: true, // SSR 预取
|
||||
default: () => [], // 初始默认值,减少空判断
|
||||
// 5 分钟内复用缓存,避免路由往返重复请求
|
||||
staleTime: 5 * 60 * 1000,
|
||||
},
|
||||
)
|
||||
const fetchCategoryData = async () => {
|
||||
isLoadingCategory.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories`)
|
||||
const data = await res.json()
|
||||
categoryData.value = data
|
||||
isLoadingCategory.value = false
|
||||
}
|
||||
|
||||
const {
|
||||
data: tagData,
|
||||
pending: isLoadingTag,
|
||||
error: tagError,
|
||||
} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), {
|
||||
server: true,
|
||||
default: () => [],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
const fetchTagData = async () => {
|
||||
isLoadingTag.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/tags?limit=10`)
|
||||
const data = await res.json()
|
||||
tagData.value = data
|
||||
isLoadingTag.value = false
|
||||
}
|
||||
|
||||
/** 其余逻辑保持不变 */
|
||||
const iconClass = computed(() => {
|
||||
switch (themeState.mode) {
|
||||
case ThemeMode.DARK:
|
||||
@@ -205,15 +178,6 @@ const unreadCount = computed(() => notificationState.unreadCount)
|
||||
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
||||
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
||||
|
||||
const loadPoint = async () => {
|
||||
if (authState.loggedIn) {
|
||||
const user = await fetchCurrentUser()
|
||||
myPoint.value = user ? user.point : null
|
||||
} else {
|
||||
myPoint.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const updateCount = async () => {
|
||||
if (authState.loggedIn) {
|
||||
await fetchUnreadCount()
|
||||
@@ -223,15 +187,8 @@ const updateCount = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([updateCount(), loadPoint()])
|
||||
// 登录态变化时再拉一次未读数和积分;与 useAsyncData 无关
|
||||
watch(
|
||||
() => authState.loggedIn,
|
||||
() => {
|
||||
updateCount()
|
||||
loadPoint()
|
||||
},
|
||||
)
|
||||
await updateCount()
|
||||
watch(() => authState.loggedIn, updateCount)
|
||||
})
|
||||
|
||||
const handleItemClick = () => {
|
||||
@@ -254,35 +211,28 @@ const gotoTag = (t) => {
|
||||
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||
handleItemClick()
|
||||
}
|
||||
|
||||
await Promise.all([fetchCategoryData(), fetchTagData()])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.menu {
|
||||
position: sticky;
|
||||
top: var(--header-height);
|
||||
width: 220px;
|
||||
background-color: var(--app-menu-background-color);
|
||||
width: 200px;
|
||||
background-color: var(--menu-background-color);
|
||||
height: calc(100vh - 20px - var(--header-height));
|
||||
border-right: 1px solid var(--menu-border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 10px 0 10px;
|
||||
.menu-item-container {
|
||||
}
|
||||
|
||||
/* .menu-item-container { */
|
||||
/**/
|
||||
/* } */
|
||||
|
||||
.menu-item {
|
||||
padding: 4px 10px;
|
||||
text-decoration: none;
|
||||
@@ -321,12 +271,6 @@ const gotoTag = (t) => {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.point-count {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.menu-item-icon {
|
||||
margin-right: 10px;
|
||||
opacity: 0.5;
|
||||
@@ -334,8 +278,10 @@ const gotoTag = (t) => {
|
||||
}
|
||||
|
||||
.menu-footer {
|
||||
position: relation;
|
||||
position: fixed;
|
||||
height: 30px;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
@@ -423,10 +369,6 @@ const gotoTag = (t) => {
|
||||
background-color: var(--background-color-blur);
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition:
|
||||
|
||||
@@ -40,22 +40,30 @@
|
||||
兑换
|
||||
</div>
|
||||
<div v-else class="redeem-button disabled">兑换</div>
|
||||
<RedeemPopup
|
||||
:visible="dialogVisible"
|
||||
v-model="contact"
|
||||
:loading="loading"
|
||||
@close="closeDialog"
|
||||
@submit="submitRedeem"
|
||||
/>
|
||||
<BasePopup :visible="dialogVisible" @close="closeDialog">
|
||||
<div class="redeem-dialog-content">
|
||||
<BaseInput
|
||||
textarea=""
|
||||
rows="5"
|
||||
v-model="contact"
|
||||
placeholder="联系方式 (手机号/Email/微信/instagram/telegram等, 务必注明来源)"
|
||||
/>
|
||||
<div class="redeem-actions">
|
||||
<div class="redeem-submit-button" @click="submitRedeem" :disabled="loading">提交</div>
|
||||
<div class="redeem-cancel-button" @click="closeDialog">取消</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePopup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
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 ProgressBar from '~/components/ProgressBar.vue'
|
||||
import RedeemPopup from '~/components/RedeemPopup.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -177,6 +185,56 @@ const submitRedeem = async () => {
|
||||
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 {
|
||||
opacity: 0.8;
|
||||
font-size: 12px;
|
||||
@@ -189,5 +247,9 @@ const submitRedeem = async () => {
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.redeem-dialog-content {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
<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>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { ref, watch } from 'vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -110,10 +110,6 @@ watch(selected, (val) => {
|
||||
selected.value = null
|
||||
keyword.value = ''
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
toggle,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -135,7 +131,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.text-input {
|
||||
background-color: var(--app-menu-background-color);
|
||||
background-color: var(--menu-background-color);
|
||||
color: var(--text-color);
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
<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,5 +1 @@
|
||||
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,10 +12,9 @@ export default defineNuxtConfig({
|
||||
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
||||
},
|
||||
},
|
||||
// 确保 Vditor 样式在 global.css 覆盖前加载
|
||||
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||
// Ensure Vditor styles load before our overrides in global.css
|
||||
css: ['vditor/dist/index.css', '~/assets/global.css'],
|
||||
app: {
|
||||
pageTransition: { name: 'page', mode: 'out-in' },
|
||||
head: {
|
||||
script: [
|
||||
{
|
||||
@@ -27,31 +26,7 @@ export default defineNuxtConfig({
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const theme = mode === 'dark' || mode === 'light' ? mode : (prefersDark ? 'dark' : 'light');
|
||||
document.documentElement.dataset.theme = theme;
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
`,
|
||||
},
|
||||
@@ -85,27 +60,4 @@ export default defineNuxtConfig({
|
||||
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
build: {
|
||||
// increase warning limit and split large libraries into separate chunks
|
||||
chunkSizeWarningLimit: 1024,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('vditor')) {
|
||||
return 'vditor'
|
||||
}
|
||||
if (id.includes('echarts')) {
|
||||
return 'echarts'
|
||||
}
|
||||
if (id.includes('highlight.js')) {
|
||||
return 'highlight'
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
4950
frontend_nuxt/package-lock.json
generated
4950
frontend_nuxt/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
<div class="not-found-page">
|
||||
<h1>404 - 页面不存在</h1>
|
||||
<p>你访问的页面不存在或已被删除</p>
|
||||
<NuxtLink to="/">返回首页</NuxtLink>
|
||||
<router-link to="/">返回首页</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<MilkTeaActivityComponent v-if="a.type === 'MILK_TEA'" />
|
||||
<InviteCodeActivityComponent v-if="a.type === 'INVITE_POINTS'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,7 +32,6 @@
|
||||
<script setup>
|
||||
import TimeManager from '~/utils/time'
|
||||
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
|
||||
import InviteCodeActivityComponent from '~/components/InviteCodeActivityComponent.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -77,7 +75,6 @@ onMounted(async () => {
|
||||
background-color: var(--activity-card-background-color);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.activity-card-left-avatar-img {
|
||||
@@ -144,10 +141,6 @@ onMounted(async () => {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.activity-card-normal-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.activity-card-left-avatar-img {
|
||||
width: 80px;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- pages/discord-callback.vue -->
|
||||
<template>
|
||||
<CallbackPage />
|
||||
</template>
|
||||
@@ -9,30 +8,9 @@ import { discordExchange } from '~/utils/discord'
|
||||
|
||||
onMounted(async () => {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get('code') || ''
|
||||
const stateStr = url.searchParams.get('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, '')
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const result = await discordExchange(code, state, '')
|
||||
|
||||
if (result.needReason) {
|
||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<div class="forgot-page">
|
||||
<div class="forgot-content">
|
||||
<div class="forgot-title">找回密码</div>
|
||||
|
||||
<div v-if="step === 0" class="step-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||
@@ -20,10 +19,6 @@
|
||||
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
|
||||
<div class="primary-button disabled" v-else>提交中...</div>
|
||||
</div>
|
||||
<div class="hint-message">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
使用 Google 注册的用户可使用对应的邮箱进行找回密码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -31,8 +26,6 @@
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -46,7 +39,6 @@ const passwordError = ref('')
|
||||
const isSending = ref(false)
|
||||
const isVerifying = ref(false)
|
||||
const isResetting = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.email) {
|
||||
@@ -145,21 +137,6 @@ const resetPassword = async () => {
|
||||
font-size: 24px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- pages/github-callback.vue -->
|
||||
<template>
|
||||
<CallbackPage />
|
||||
</template>
|
||||
@@ -9,31 +8,9 @@ import { githubExchange } from '~/utils/github'
|
||||
|
||||
onMounted(async () => {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get('code') || ''
|
||||
const state = url.searchParams.get('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, '')
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const result = await githubExchange(code, state, '')
|
||||
|
||||
if (result.needReason) {
|
||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||
|
||||
@@ -9,21 +9,6 @@ import { googleAuthWithToken } from '~/utils/google'
|
||||
onMounted(async () => {
|
||||
const hash = new URLSearchParams(window.location.hash.substring(1))
|
||||
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) {
|
||||
await googleAuthWithToken(
|
||||
idToken,
|
||||
@@ -33,7 +18,6 @@ onMounted(async () => {
|
||||
(token) => {
|
||||
navigateTo(`/signup-reason?token=${token}`, { replace: true })
|
||||
},
|
||||
{ inviteToken },
|
||||
)
|
||||
} else {
|
||||
navigateTo('/login', { replace: true })
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<div v-if="!isMobile" class="search-container">
|
||||
<div class="search-title">一切可能,从此刻启航,在此遇见灵感与共鸣</div>
|
||||
<div class="search-title">一切可能,从此刻启航</div>
|
||||
<div class="search-subtitle">
|
||||
愿你在此遇见灵感与共鸣。若有疑惑,欢迎发问,亦可在知识的海洋中搜寻答案。
|
||||
</div>
|
||||
<SearchDropdown />
|
||||
</div>
|
||||
|
||||
@@ -113,7 +116,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ref, watch, watchEffect, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||
import ArticleTags from '~/components/ArticleTags.vue'
|
||||
import CategorySelect from '~/components/CategorySelect.vue'
|
||||
@@ -147,17 +150,9 @@ const categoryOptions = ref([])
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||
const selectedTopicCookie = useCookie('homeTab')
|
||||
const selectedTopic = ref(
|
||||
selectedTopicCookie.value
|
||||
? selectedTopicCookie.value
|
||||
: route.query.view === 'ranking'
|
||||
? '排行榜'
|
||||
: route.query.view === 'latest'
|
||||
? '最新'
|
||||
: '最新回复',
|
||||
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
|
||||
)
|
||||
if (!selectedTopicCookie.value) selectedTopicCookie.value = selectedTopic.value
|
||||
const articles = ref([])
|
||||
const page = ref(0)
|
||||
const pageSize = 10
|
||||
@@ -183,11 +178,6 @@ onMounted(() => {
|
||||
const { category, tags } = route.query
|
||||
if (category) selectedCategorySet(category)
|
||||
if (tags) selectedTagsSet(tags)
|
||||
|
||||
const saved = localStorage.getItem('homeTab')
|
||||
if (saved) {
|
||||
selectedTopic.value = saved
|
||||
}
|
||||
})
|
||||
|
||||
/** 路由变更时同步筛选 **/
|
||||
@@ -205,7 +195,7 @@ watch(
|
||||
const loadOptions = async () => {
|
||||
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories/`)
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
|
||||
if (res.ok) categoryOptions.value = [await res.json()]
|
||||
} catch {}
|
||||
}
|
||||
@@ -275,7 +265,7 @@ const {
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
),
|
||||
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||
pinned: !!p.pinnedAt,
|
||||
type: p.type,
|
||||
}))
|
||||
},
|
||||
@@ -318,7 +308,7 @@ const fetchNextPage = async () => {
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
),
|
||||
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||
pinned: !!p.pinnedAt,
|
||||
type: p.type,
|
||||
}))
|
||||
articles.value.push(...mapped)
|
||||
@@ -353,13 +343,9 @@ watch(
|
||||
watch([selectedCategory, selectedTags], () => {
|
||||
loadOptions()
|
||||
})
|
||||
watch(selectedTopic, (val) => {
|
||||
watch(selectedTopic, () => {
|
||||
// 仅当需要额外选项时加载
|
||||
loadOptions()
|
||||
selectedTopicCookie.value = val
|
||||
if (process.client) {
|
||||
localStorage.setItem('homeTab', val)
|
||||
}
|
||||
})
|
||||
|
||||
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
||||
@@ -368,8 +354,6 @@ if (import.meta.server) {
|
||||
}
|
||||
onMounted(() => {
|
||||
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
|
||||
|
||||
window.addEventListener('refresh-home', refreshFirst)
|
||||
})
|
||||
|
||||
/** 其他工具函数 **/
|
||||
@@ -387,8 +371,8 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-top: 32px;
|
||||
padding: 20px 20px 32px;
|
||||
margin-top: 100px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -400,6 +384,10 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.search-subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -434,7 +422,6 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.topic-item-container {
|
||||
@@ -554,7 +541,6 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
font-size: 14px;
|
||||
color: gray;
|
||||
display: -webkit-box;
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -27,10 +27,6 @@
|
||||
>找回密码</a
|
||||
>
|
||||
</div>
|
||||
<div class="hint-message">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
使用右侧第三方OAuth注册/登录的用户可使用对应的邮箱进行重设密码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -263,11 +259,6 @@ const loginWithTwitter = () => {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.hint-message {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.login-page {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -66,61 +66,61 @@
|
||||
<span class="notif-type">
|
||||
<template v-if="item.type === 'COMMENT_REPLY' && item.parentComment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.comment.author.id}`"
|
||||
>{{ item.comment.author.username }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
对我的评论
|
||||
<span>
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.parentComment.content, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
</span>
|
||||
回复了
|
||||
<span>
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
</span>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'COMMENT_REPLY' && !item.parentComment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.comment.author.id}`"
|
||||
>{{ item.comment.author.username }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
对我的文章
|
||||
<span>
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
</span>
|
||||
回复了
|
||||
<span>
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
</span>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
@@ -130,320 +130,314 @@
|
||||
申请进行奶茶兑换,联系方式是:{{ item.content }}
|
||||
</NotificationContainer>
|
||||
</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">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章
|
||||
<span>
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
</span>
|
||||
进行了表态
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'REACTION' && item.comment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.fromUser.id}`"
|
||||
>{{ item.fromUser.username }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
对我的评论
|
||||
<span>
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
</span>
|
||||
进行了表态
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_VIEWED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.fromUser.id}`"
|
||||
>
|
||||
{{ item.fromUser.username }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
查看了您的帖子
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'LOTTERY_WIN'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
恭喜你在抽奖贴
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
中获奖
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'LOTTERY_DRAW'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您的抽奖贴
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
已开奖
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您关注的帖子
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
下面有新评论
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'USER_ACTIVITY' && item.parentComment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
你关注的
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.comment.author.id}`"
|
||||
>
|
||||
{{ item.comment.author.username }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
在 对评论
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.parentComment.content, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
回复了
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'USER_ACTIVITY'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
你关注的
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.comment.author.id}`"
|
||||
>
|
||||
{{ item.comment.author.username }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
在文章
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
下面评论了
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'MENTION' && item.comment">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.fromUser.id}`"
|
||||
>
|
||||
{{ item.fromUser.username }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
在评论中提到了你:
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'MENTION'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.fromUser.id}`"
|
||||
>
|
||||
{{ item.fromUser.username }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
在帖子
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
中提到了你
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'USER_FOLLOWED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.fromUser.id}`"
|
||||
>
|
||||
{{ item.fromUser.username }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
开始关注你了
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'USER_UNFOLLOWED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.fromUser.id}`"
|
||||
>
|
||||
{{ item.fromUser.username }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
取消关注你了
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'FOLLOWED_POST'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
你关注的
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.fromUser.id}`"
|
||||
>
|
||||
{{ item.fromUser.username }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
发布了文章
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_SUBSCRIBED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.fromUser.id}`"
|
||||
>
|
||||
{{ item.fromUser.username }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
订阅了你的文章
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_UNSUBSCRIBED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.fromUser.id}`"
|
||||
>
|
||||
{{ item.fromUser.username }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
取消订阅了你的文章
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_REVIEW_REQUEST' && item.fromUser">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.fromUser.id}`"
|
||||
>
|
||||
{{ item.fromUser.username }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
发布了帖子
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
,请审核
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_REVIEW_REQUEST'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您发布的帖子
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
已提交审核
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
@@ -472,26 +466,26 @@
|
||||
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您发布的帖子
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
已审核通过
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved === false">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您发布的帖子
|
||||
<NuxtLink
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
已被管理员拒绝
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
@@ -511,26 +505,21 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.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 { authState, getToken } from '~/utils/auth'
|
||||
import { stripMarkdownLength } from '~/utils/markdown'
|
||||
import {
|
||||
fetchNotifications,
|
||||
fetchUnreadCount,
|
||||
isLoadingMessage,
|
||||
markRead,
|
||||
notifications,
|
||||
markAllRead,
|
||||
} from '~/utils/notification'
|
||||
import TimeManager from '~/utils/time'
|
||||
|
||||
import { reactionEmojiMap } from '~/utils/reactions'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const route = useRoute()
|
||||
const notifications = ref([])
|
||||
const isLoadingMessage = ref(false)
|
||||
const selectedTab = ref(
|
||||
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
|
||||
)
|
||||
@@ -539,6 +528,234 @@ const filteredNotifications = computed(() =>
|
||||
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 () => {
|
||||
notificationPrefs.value = await fetchNotificationPreferences()
|
||||
}
|
||||
@@ -616,8 +833,6 @@ const formatType = (t) => {
|
||||
return '有人申请注册'
|
||||
case 'ACTIVITY_REDEEM':
|
||||
return '有人申请兑换奶茶'
|
||||
case 'POINT_REDEEM':
|
||||
return '有人申请积分兑换'
|
||||
case 'LOTTERY_WIN':
|
||||
return '抽奖中奖了'
|
||||
case 'LOTTERY_DRAW':
|
||||
@@ -627,7 +842,7 @@ const formatType = (t) => {
|
||||
}
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
onMounted(() => {
|
||||
fetchNotifications()
|
||||
fetchPrefs()
|
||||
})
|
||||
@@ -644,8 +859,6 @@ onActivated(() => {
|
||||
.message-page {
|
||||
background-color: var(--background-color);
|
||||
overflow-x: hidden;
|
||||
height: calc(100vh - var(--header-height));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message-page-header {
|
||||
@@ -657,7 +870,6 @@ onActivated(() => {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.message-page-header-right {
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script>
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import FlatPickr from 'vue-flatpickr-component'
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
<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: 0 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>
|
||||
@@ -52,11 +52,11 @@
|
||||
<div class="user-name">
|
||||
{{ author.username }}
|
||||
<i class="fas fa-medal medal-icon"></i>
|
||||
<NuxtLink
|
||||
<router-link
|
||||
v-if="author.displayMedal"
|
||||
class="user-medal"
|
||||
:to="`/users/${author.id}?tab=achievements`"
|
||||
>{{ getMedalTitle(author.displayMedal) }}</NuxtLink
|
||||
>{{ getMedalTitle(author.displayMedal) }}</router-link
|
||||
>
|
||||
</div>
|
||||
<div class="post-time">{{ postTime }}</div>
|
||||
@@ -68,11 +68,11 @@
|
||||
<div class="user-name">
|
||||
{{ author.username }}
|
||||
<i class="fas fa-medal medal-icon"></i>
|
||||
<NuxtLink
|
||||
<router-link
|
||||
v-if="author.displayMedal"
|
||||
class="user-medal"
|
||||
:to="`/users/${author.id}?tab=achievements`"
|
||||
>{{ getMedalTitle(author.displayMedal) }}</NuxtLink
|
||||
>{{ getMedalTitle(author.displayMedal) }}</router-link
|
||||
>
|
||||
</div>
|
||||
<div class="post-time">{{ postTime }}</div>
|
||||
@@ -232,7 +232,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, watchEffect } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import VueEasyLightbox from 'vue-easy-lightbox'
|
||||
import { useRoute } from 'vue-router'
|
||||
import CommentItem from '~/components/CommentItem.vue'
|
||||
@@ -268,6 +268,7 @@ const postReactions = ref([])
|
||||
const comments = ref([])
|
||||
const status = ref('PUBLISHED')
|
||||
const pinnedAt = ref(null)
|
||||
const isWaitingFetchingPost = ref(false)
|
||||
const isWaitingPostingComment = ref(false)
|
||||
const postTime = ref('')
|
||||
const postItems = ref([])
|
||||
@@ -391,7 +392,7 @@ const mapComment = (c, parentUserName = '', level = 0) => ({
|
||||
avatar: c.author.avatar,
|
||||
text: c.content,
|
||||
reactions: c.reactions || [],
|
||||
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
|
||||
pinned: !!c.pinnedAt,
|
||||
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
|
||||
openReplies: level === 0,
|
||||
src: c.author.avatar,
|
||||
@@ -454,41 +455,38 @@ const onCommentDeleted = (id) => {
|
||||
fetchComments()
|
||||
}
|
||||
|
||||
const {
|
||||
data: postData,
|
||||
pending: pendingPost,
|
||||
error: postError,
|
||||
refresh: refreshPost,
|
||||
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
||||
server: true,
|
||||
lazy: false,
|
||||
})
|
||||
|
||||
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||
|
||||
// 同步到现有的响应式字段
|
||||
watchEffect(() => {
|
||||
const data = postData.value
|
||||
if (!data) return
|
||||
postContent.value = data.content
|
||||
author.value = data.author
|
||||
title.value = data.title
|
||||
category.value = data.category
|
||||
tags.value = data.tags || []
|
||||
postReactions.value = data.reactions || []
|
||||
subscribed.value = !!data.subscribed
|
||||
status.value = data.status
|
||||
pinnedAt.value = data.pinnedAt
|
||||
postTime.value = TimeManager.format(data.createdAt)
|
||||
lottery.value = data.lottery || null
|
||||
if (lottery.value && lottery.value.endTime) startCountdown()
|
||||
})
|
||||
|
||||
// 404 客户端跳转
|
||||
// if (postError.value?.statusCode === 404 && process.client) {
|
||||
// router.replace('/404')
|
||||
// }
|
||||
const fetchPost = async () => {
|
||||
try {
|
||||
isWaitingFetchingPost.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||||
})
|
||||
isWaitingFetchingPost.value = false
|
||||
if (!res.ok) {
|
||||
if (res.status === 404 && process.client) {
|
||||
router.replace('/404')
|
||||
}
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
postContent.value = data.content
|
||||
author.value = data.author
|
||||
title.value = data.title
|
||||
category.value = data.category
|
||||
tags.value = data.tags || []
|
||||
postReactions.value = data.reactions || []
|
||||
subscribed.value = !!data.subscribed
|
||||
status.value = data.status
|
||||
pinnedAt.value = data.pinnedAt
|
||||
postTime.value = TimeManager.format(data.createdAt)
|
||||
lottery.value = data.lottery || null
|
||||
if (lottery.value && lottery.value.endTime) startCountdown()
|
||||
await nextTick()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPosts = computed(() => comments.value.length + 1)
|
||||
const lastReplyTime = computed(() =>
|
||||
@@ -609,7 +607,6 @@ const approvePost = async () => {
|
||||
if (res.ok) {
|
||||
status.value = 'PUBLISHED'
|
||||
toast.success('已通过审核')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -623,8 +620,8 @@ const pinPost = async () => {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
pinnedAt.value = new Date().toISOString()
|
||||
toast.success('已置顶')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -638,8 +635,8 @@ const unpinPost = async () => {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
pinnedAt.value = null
|
||||
toast.success('已取消置顶')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -677,7 +674,6 @@ const rejectPost = async () => {
|
||||
if (res.ok) {
|
||||
status.value = 'REJECTED'
|
||||
toast.success('已驳回')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -713,7 +709,7 @@ const joinLottery = async () => {
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success('已参与抽奖')
|
||||
await refreshPost()
|
||||
await fetchPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -784,8 +780,9 @@ onMounted(async () => {
|
||||
window.addEventListener('scroll', updateCurrentIndex)
|
||||
jumpToHashComment()
|
||||
})
|
||||
</script>
|
||||
|
||||
await fetchPost()
|
||||
</script>
|
||||
<style>
|
||||
.post-page-container {
|
||||
background-color: var(--background-color);
|
||||
@@ -867,7 +864,6 @@ onMounted(async () => {
|
||||
direction: ltr;
|
||||
height: 300px;
|
||||
width: 2px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -36,13 +36,6 @@
|
||||
<BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." />
|
||||
<div class="setting-description">自我介绍会出现在你的个人主页,可以简要介绍自己</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 v-if="role === 'ADMIN'" class="admin-section">
|
||||
<h3>管理员设置</h3>
|
||||
@@ -72,13 +65,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { toast } from '~/main'
|
||||
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
||||
import { frostedState, setFrosted } from '~/utils/frosted'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const username = ref('')
|
||||
@@ -95,7 +87,6 @@ const aiFormatLimit = ref(3)
|
||||
const registerMode = ref('DIRECT')
|
||||
const isLoadingPage = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const frosted = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
isLoadingPage.value = true
|
||||
@@ -114,7 +105,6 @@ onMounted(async () => {
|
||||
navigateTo('/login', { replace: true })
|
||||
}
|
||||
isLoadingPage.value = false
|
||||
frosted.value = frostedState.enabled
|
||||
})
|
||||
|
||||
const onAvatarChange = (e) => {
|
||||
@@ -128,7 +118,6 @@ const onAvatarChange = (e) => {
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
watch(frosted, (val) => setFrosted(val))
|
||||
const onCropped = ({ file, url }) => {
|
||||
avatarFile.value = file
|
||||
avatar.value = url
|
||||
@@ -311,58 +300,6 @@ const save = async () => {
|
||||
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 {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ const reason = ref('')
|
||||
const error = ref('')
|
||||
const isWaitingForRegister = ref(false)
|
||||
const token = ref('')
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(async () => {
|
||||
token.value = route.query.token || ''
|
||||
@@ -51,8 +50,8 @@ const submit = async () => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: token.value,
|
||||
reason: reason.value,
|
||||
token: this.token,
|
||||
reason: this.reason,
|
||||
}),
|
||||
})
|
||||
isWaitingForRegister.value = false
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
|
||||
<div class="other-signup-page-content">
|
||||
<div class="signup-page-button" @click="signupWithGoogle">
|
||||
<div class="signup-page-button" @click="googleAuthorize">
|
||||
<img class="signup-page-button-icon" src="~/assets/icons/google.svg" alt="Google Logo" />
|
||||
<div class="signup-page-button-text">Google 注册</div>
|
||||
</div>
|
||||
@@ -96,9 +96,6 @@ import { discordAuthorize } from '~/utils/discord'
|
||||
import { githubAuthorize } from '~/utils/github'
|
||||
import { googleAuthorize } from '~/utils/google'
|
||||
import { twitterAuthorize } from '~/utils/twitter'
|
||||
import { loadCurrentUser, setToken } from '~/utils/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const emailStep = ref(0)
|
||||
@@ -112,11 +109,9 @@ const passwordError = ref('')
|
||||
const code = ref('')
|
||||
const isWaitingForEmailSent = ref(false)
|
||||
const isWaitingForEmailVerified = ref(false)
|
||||
const inviteToken = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
username.value = route.query.u || ''
|
||||
inviteToken.value = route.query.invite_token || ''
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/config`)
|
||||
if (res.ok) {
|
||||
@@ -161,7 +156,6 @@ const sendVerification = async () => {
|
||||
username: username.value,
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
inviteToken: inviteToken.value,
|
||||
}),
|
||||
})
|
||||
isWaitingForEmailSent.value = false
|
||||
@@ -194,18 +188,11 @@ const verifyCode = async () => {
|
||||
})
|
||||
const data = await res.json()
|
||||
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') {
|
||||
navigateTo(`/signup-reason?token=${data.token}`, { replace: true })
|
||||
} else {
|
||||
toast.success('注册成功,请登录')
|
||||
navigateTo('/login', { replace: true })
|
||||
}
|
||||
if (registerMode.value === 'WHITELIST') {
|
||||
navigateTo(`/signup-reason?token=${data.token}`, { replace: true })
|
||||
} else {
|
||||
toast.success('注册成功,请登录')
|
||||
navigateTo('/login', { replace: true })
|
||||
}
|
||||
} else {
|
||||
toast.error(data.error || '注册失败')
|
||||
@@ -216,17 +203,14 @@ const verifyCode = async () => {
|
||||
isWaitingForEmailVerified.value = false
|
||||
}
|
||||
}
|
||||
const signupWithGoogle = () => {
|
||||
googleAuthorize(inviteToken.value)
|
||||
}
|
||||
const signupWithGithub = () => {
|
||||
githubAuthorize(inviteToken.value)
|
||||
githubAuthorize()
|
||||
}
|
||||
const signupWithDiscord = () => {
|
||||
discordAuthorize(inviteToken.value)
|
||||
discordAuthorize()
|
||||
}
|
||||
const signupWithTwitter = () => {
|
||||
twitterAuthorize(inviteToken.value)
|
||||
twitterAuthorize()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -35,12 +35,10 @@
|
||||
/>
|
||||
<div class="profile-level-target">
|
||||
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
||||
<ToolTip
|
||||
content="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||
placement="bottom"
|
||||
>
|
||||
<i class="fas fa-info-circle profile-exp-info"></i>
|
||||
</ToolTip>
|
||||
<i
|
||||
class="fas fa-info-circle profile-exp-info"
|
||||
title="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,26 +128,26 @@
|
||||
<BaseTimeline :items="hotReplies">
|
||||
<template #item="{ item }">
|
||||
在
|
||||
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
<template v-if="item.comment.parentComment">
|
||||
下对
|
||||
<NuxtLink
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
回复了
|
||||
</template>
|
||||
<template v-else> 下评论了 </template>
|
||||
<NuxtLink
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
<div class="timeline-date">
|
||||
{{ formatDate(item.comment.createdAt) }}
|
||||
</div>
|
||||
@@ -165,9 +163,9 @@
|
||||
<div class="summary-content" v-if="hotPosts.length > 0">
|
||||
<BaseTimeline :items="hotPosts">
|
||||
<template #item="{ item }">
|
||||
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
{{ item.post.title }}
|
||||
</NuxtLink>
|
||||
</router-link>
|
||||
<div class="timeline-snippet">
|
||||
{{ stripMarkdown(item.post.snippet) }}
|
||||
</div>
|
||||
@@ -206,89 +204,67 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-if="filteredTimelineItems.length === 0"
|
||||
v-if="timelineItems.length === 0"
|
||||
text="暂无时间线"
|
||||
icon="fas fa-inbox"
|
||||
/>
|
||||
<div class="timeline-list">
|
||||
<BaseTimeline :items="filteredTimelineItems">
|
||||
<template #item="{ item }">
|
||||
<template v-if="item.type === 'post'">
|
||||
发布了文章
|
||||
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
{{ item.post.title }}
|
||||
</NuxtLink>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'comment'">
|
||||
在
|
||||
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</NuxtLink>
|
||||
下评论了
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</NuxtLink>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'reply'">
|
||||
在
|
||||
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</NuxtLink>
|
||||
下对
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||
</NuxtLink>
|
||||
回复了
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</NuxtLink>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'tag'">
|
||||
创建了标签
|
||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||
</span>
|
||||
<div class="timeline-snippet" v-if="item.tag.description">
|
||||
{{ item.tag.description }}
|
||||
</div>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<BaseTimeline :items="timelineItems">
|
||||
<template #item="{ item }">
|
||||
<template v-if="item.type === 'post'">
|
||||
发布了文章
|
||||
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
{{ item.post.title }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
<template v-else-if="item.type === 'comment'">
|
||||
在
|
||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</router-link>
|
||||
下评论了
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'reply'">
|
||||
在
|
||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</router-link>
|
||||
下对
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||
</router-link>
|
||||
回复了
|
||||
<router-link
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</router-link>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'tag'">
|
||||
创建了标签
|
||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||
</span>
|
||||
<div class="timeline-snippet" v-if="item.tag.description">
|
||||
{{ item.tag.description }}
|
||||
</div>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
||||
@@ -348,15 +324,6 @@ const hotPosts = ref([])
|
||||
const hotReplies = ref([])
|
||||
const hotTags = 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 followings = ref([])
|
||||
const medals = ref([])
|
||||
@@ -687,6 +654,12 @@ watch(selectedTab, async (val) => {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.profile-exp-info {
|
||||
margin-left: 4px;
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -727,7 +700,6 @@ watch(selectedTab, async (val) => {
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
scrollbar-width: none;
|
||||
overflow-x: auto;
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.profile-tabs-item {
|
||||
@@ -805,24 +777,8 @@ watch(selectedTab, async (val) => {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.timeline-tabs {
|
||||
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);
|
||||
.profile-timeline {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { defineNuxtPlugin } from 'nuxt/app'
|
||||
import { initFrosted } from '~/utils/frosted'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
initFrosted()
|
||||
})
|
||||
9
frontend_nuxt/plugins/soft-manifest.client.ts
Normal file
9
frontend_nuxt/plugins/soft-manifest.client.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineNuxtPlugin } from 'nuxt/app'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
// 覆盖默认行为:收到 manifest 更新时,不立刻在路由切换里刷新
|
||||
nuxtApp.hooks.hook('app:manifest:update', () => {
|
||||
// todo 选择:弹个提示,让用户点击刷新;或延迟到页面隐藏时再刷新
|
||||
// 例如:document.addEventListener('visibilitychange', () => { if (document.hidden) location.reload() })
|
||||
})
|
||||
})
|
||||
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.
@@ -2,7 +2,7 @@ import { toast } from '../main'
|
||||
import { setToken, loadCurrentUser } from './auth'
|
||||
import { registerPush } from './push'
|
||||
|
||||
export function discordAuthorize(inviteToken = '') {
|
||||
export function discordAuthorize(state = '') {
|
||||
const config = useRuntimeConfig()
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
const DISCORD_CLIENT_ID = config.public.discordClientId
|
||||
@@ -10,60 +10,62 @@ export function discordAuthorize(inviteToken = '') {
|
||||
toast.error('Discord 登录不可用')
|
||||
return
|
||||
}
|
||||
|
||||
const redirectUri = `${WEBSITE_BASE_URL}/discord-callback`
|
||||
// 用 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)}`
|
||||
|
||||
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}`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
export async function discordExchange(code, inviteToken = '', reason = '') {
|
||||
export async function discordExchange(code, state, reason) {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const payload = {
|
||||
code,
|
||||
redirectUri: `${window.location.origin}/discord-callback`,
|
||||
reason,
|
||||
}
|
||||
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),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
redirectUri: `${window.location.origin}/discord-callback`,
|
||||
reason,
|
||||
state,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
await loadCurrentUser()
|
||||
toast.success('登录成功')
|
||||
registerPush?.()
|
||||
return { success: true, needReason: false }
|
||||
registerPush()
|
||||
return {
|
||||
success: true,
|
||||
needReason: false,
|
||||
}
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
toast.info('当前为注册审核模式,请填写注册理由')
|
||||
return { success: false, needReason: true, token: data.token }
|
||||
return {
|
||||
success: false,
|
||||
needReason: true,
|
||||
token: data.token,
|
||||
}
|
||||
} else if (data.reason_code === 'IS_APPROVING') {
|
||||
toast.info('您的注册理由正在审批中')
|
||||
return { success: true, needReason: false }
|
||||
return {
|
||||
success: true,
|
||||
needReason: false,
|
||||
}
|
||||
} else {
|
||||
toast.error(data.error || '登录失败')
|
||||
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||
return {
|
||||
success: false,
|
||||
needReason: false,
|
||||
error: data.error || '登录失败',
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error('登录失败')
|
||||
return { success: false, needReason: false, error: '登录失败' }
|
||||
return {
|
||||
success: false,
|
||||
needReason: false,
|
||||
error: '登录失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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 { registerPush } from './push'
|
||||
|
||||
export function githubAuthorize(inviteToken = '') {
|
||||
export function githubAuthorize(state = '') {
|
||||
const config = useRuntimeConfig()
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
const GITHUB_CLIENT_ID = config.public.githubClientId
|
||||
@@ -10,58 +10,62 @@ export function githubAuthorize(inviteToken = '') {
|
||||
toast.error('GitHub 登录不可用')
|
||||
return
|
||||
}
|
||||
|
||||
const redirectUri = `${WEBSITE_BASE_URL}/github-callback`
|
||||
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)}`
|
||||
|
||||
const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email&state=${state}`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
export async function githubExchange(code, inviteToken = '', reason = '') {
|
||||
export async function githubExchange(code, state, reason) {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const payload = {
|
||||
code,
|
||||
redirectUri: `${window.location.origin}/github-callback`,
|
||||
reason,
|
||||
}
|
||||
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),
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
redirectUri: `${window.location.origin}/github-callback`,
|
||||
reason,
|
||||
state,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
await loadCurrentUser()
|
||||
toast.success('登录成功')
|
||||
registerPush?.()
|
||||
return { success: true, needReason: false }
|
||||
registerPush()
|
||||
return {
|
||||
success: true,
|
||||
needReason: false,
|
||||
}
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
toast.info('当前为注册审核模式,请填写注册理由')
|
||||
return { success: false, needReason: true, token: data.token }
|
||||
return {
|
||||
success: false,
|
||||
needReason: true,
|
||||
token: data.token,
|
||||
}
|
||||
} else if (data.reason_code === 'IS_APPROVING') {
|
||||
toast.info('您的注册理由正在审批中')
|
||||
return { success: true, needReason: false }
|
||||
return {
|
||||
success: true,
|
||||
needReason: false,
|
||||
}
|
||||
} else {
|
||||
toast.error(data.error || '登录失败')
|
||||
return { success: false, needReason: false, error: data.error || '登录失败' }
|
||||
return {
|
||||
success: false,
|
||||
needReason: false,
|
||||
error: data.error || '登录失败',
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error('登录失败')
|
||||
return { success: false, needReason: false, error: '登录失败' }
|
||||
return {
|
||||
success: false,
|
||||
needReason: false,
|
||||
error: '登录失败',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,85 +21,43 @@ export async function googleGetIdToken() {
|
||||
})
|
||||
}
|
||||
|
||||
export function googleAuthorize(inviteToken = '') {
|
||||
export function googleAuthorize() {
|
||||
const config = useRuntimeConfig()
|
||||
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
|
||||
if (!GOOGLE_CLIENT_ID) {
|
||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||
return
|
||||
}
|
||||
|
||||
const redirectUri = `${WEBSITE_BASE_URL}/google-callback`
|
||||
const nonce = Math.random().toString(36).slice(2)
|
||||
|
||||
// 明文放在 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)}`
|
||||
|
||||
const nonce = Math.random().toString(36).substring(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}`
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
export async function googleAuthWithToken(
|
||||
idToken,
|
||||
redirect_success,
|
||||
redirect_not_approved,
|
||||
options = {}, // { inviteToken?: string }
|
||||
) {
|
||||
export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) {
|
||||
try {
|
||||
if (!idToken) {
|
||||
toast.error('缺少 id_token')
|
||||
return
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ idToken }),
|
||||
})
|
||||
|
||||
const data = await res.json().catch(() => ({}))
|
||||
|
||||
if (res.ok && data && data.token) {
|
||||
const data = await res.json()
|
||||
if (res.ok && data.token) {
|
||||
setToken(data.token)
|
||||
await loadCurrentUser()
|
||||
toast.success('登录成功')
|
||||
registerPush?.()
|
||||
if (typeof redirect_success === 'function') redirect_success()
|
||||
return
|
||||
}
|
||||
|
||||
if (data && data.reason_code === 'NOT_APPROVED') {
|
||||
registerPush()
|
||||
if (redirect_success) redirect_success()
|
||||
} else if (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') {
|
||||
if (redirect_not_approved) redirect_not_approved(data.token)
|
||||
} else if (data.reason_code === 'IS_APPROVING') {
|
||||
toast.info('您的注册理由正在审批中')
|
||||
if (typeof redirect_success === 'function') redirect_success()
|
||||
return
|
||||
if (redirect_success) redirect_success()
|
||||
}
|
||||
toast.error(data?.message || '登录失败')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error('登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import hljs from 'highlight.js'
|
||||
if (typeof window !== 'undefined') {
|
||||
const theme =
|
||||
document.documentElement.dataset.theme ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
if (theme === 'dark') {
|
||||
import('highlight.js/styles/atom-one-dark.css')
|
||||
} else {
|
||||
import('highlight.js/styles/atom-one-light.css')
|
||||
}
|
||||
}
|
||||
import 'highlight.js/styles/github.css'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { toast } from '../main'
|
||||
import { tiebaEmoji } from './tiebaEmoji'
|
||||
@@ -95,11 +86,7 @@ const md = new MarkdownIt({
|
||||
} else {
|
||||
code = hljs.highlightAuto(str).value
|
||||
}
|
||||
const lineNumbers = code
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(() => `<div class="line-number"></div>`)
|
||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><div class="line-numbers">${lineNumbers.join('')}</div><code class="hljs language-${lang || ''}">${code.trim()}</code></pre>`
|
||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><code class="hljs language-${lang || ''}">${code}</code></pre>`
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,33 +1,10 @@
|
||||
import { navigateTo, useRuntimeConfig } from 'nuxt/app'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { toast } from '~/composables/useToast'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { reactionEmojiMap } from '~/utils/reactions'
|
||||
import { getToken } from './auth'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const notificationState = reactive({
|
||||
unreadCount: 0,
|
||||
})
|
||||
|
||||
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',
|
||||
POINT_REDEEM: 'fas fa-gift',
|
||||
LOTTERY_WIN: 'fas fa-trophy',
|
||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||
MENTION: 'fas fa-at',
|
||||
}
|
||||
|
||||
export async function fetchUnreadCount() {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
@@ -110,235 +87,3 @@ export async function updateNotificationPreference(type, enabled) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理信息的高阶函数
|
||||
* @returns
|
||||
*/
|
||||
function createFetchNotifications() {
|
||||
const notifications = ref([])
|
||||
const isLoadingMessage = ref(false)
|
||||
const fetchNotifications = async () => {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
if (isLoadingMessage && notifications && markRead) {
|
||||
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 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('已读所有消息')
|
||||
}
|
||||
}
|
||||
return {
|
||||
fetchNotifications,
|
||||
markRead,
|
||||
notifications,
|
||||
isLoadingMessage,
|
||||
markRead,
|
||||
markAllRead,
|
||||
}
|
||||
}
|
||||
|
||||
export const { fetchNotifications, markRead, notifications, isLoadingMessage, markAllRead } =
|
||||
createFetchNotifications()
|
||||
|
||||
@@ -14,46 +14,19 @@ export const themeState = reactive({
|
||||
})
|
||||
|
||||
function apply(mode) {
|
||||
if (!import.meta.client) return
|
||||
if (!process.client) return
|
||||
const root = document.documentElement
|
||||
let newMode =
|
||||
mode === ThemeMode.SYSTEM
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: mode
|
||||
if (root.dataset.theme === newMode) return
|
||||
root.dataset.theme = newMode
|
||||
|
||||
// 更新 meta 标签
|
||||
const androidMeta = document.querySelector('meta[name="theme-color"]')
|
||||
const iosMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]')
|
||||
const themeColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--background-color')
|
||||
.trim()
|
||||
const themeStatus = newMode === 'dark' ? 'black-translucent' : 'default'
|
||||
|
||||
if (androidMeta) {
|
||||
androidMeta.content = themeColor
|
||||
if (mode === ThemeMode.SYSTEM) {
|
||||
root.dataset.theme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
} else {
|
||||
const newAndroidMeta = document.createElement('meta')
|
||||
newAndroidMeta.name = 'theme-color'
|
||||
newAndroidMeta.content = themeColor
|
||||
document.head.appendChild(newAndroidMeta)
|
||||
}
|
||||
|
||||
if (iosMeta) {
|
||||
iosMeta.content = themeStatus
|
||||
} else {
|
||||
const newIosMeta = document.createElement('meta')
|
||||
newIosMeta.name = 'apple-mobile-web-app-status-bar-style'
|
||||
newIosMeta.content = themeStatus
|
||||
document.head.appendChild(newIosMeta)
|
||||
root.dataset.theme = mode
|
||||
}
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
if (!import.meta.client) return
|
||||
if (!process.client) return
|
||||
const saved = localStorage.getItem(THEME_KEY)
|
||||
if (saved && Object.values(ThemeMode).includes(saved)) {
|
||||
themeState.mode = saved
|
||||
@@ -62,117 +35,15 @@ export function initTheme() {
|
||||
}
|
||||
|
||||
export function setTheme(mode) {
|
||||
if (!import.meta.client) return
|
||||
if (!process.client) return
|
||||
if (!Object.values(ThemeMode).includes(mode)) return
|
||||
themeState.mode = mode
|
||||
localStorage.setItem(THEME_KEY, mode)
|
||||
apply(mode)
|
||||
}
|
||||
|
||||
function getCircle(event) {
|
||||
if (!import.meta.client) return undefined
|
||||
|
||||
let x, y
|
||||
if (event.touches?.length) {
|
||||
x = event.touches[0].clientX
|
||||
y = event.touches[0].clientY
|
||||
} else if (event.changedTouches?.length) {
|
||||
x = event.changedTouches[0].clientX
|
||||
y = event.changedTouches[0].clientY
|
||||
} else {
|
||||
x = event.clientX
|
||||
y = event.clientY
|
||||
}
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
radius: Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y)),
|
||||
}
|
||||
}
|
||||
|
||||
function withViewTransition(event, applyFn, direction = true) {
|
||||
if (typeof document !== 'undefined' && document.startViewTransition) {
|
||||
const transition = document.startViewTransition(async () => {
|
||||
applyFn()
|
||||
await nextTick()
|
||||
})
|
||||
|
||||
transition.ready
|
||||
.then(() => {
|
||||
const { x, y, radius } = getCircle(event)
|
||||
|
||||
const clipPath = [`circle(0 at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`]
|
||||
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath: direction ? clipPath : [...clipPath].reverse(),
|
||||
},
|
||||
{
|
||||
duration: 400,
|
||||
easing: 'ease-in-out',
|
||||
pseudoElement: direction
|
||||
? '::view-transition-new(root)'
|
||||
: '::view-transition-old(root)',
|
||||
},
|
||||
)
|
||||
})
|
||||
.catch(console.warn)
|
||||
} else {
|
||||
fallbackThemeTransition(applyFn)
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackThemeTransition(applyFn) {
|
||||
if (!import.meta.client) return
|
||||
|
||||
const root = document.documentElement
|
||||
const computedStyle = getComputedStyle(root)
|
||||
|
||||
// 获取当前背景色用于过渡
|
||||
const currentBg = computedStyle.getPropertyValue('--background-color').trim()
|
||||
|
||||
// 创建过渡元素
|
||||
const transitionElement = document.createElement('div')
|
||||
transitionElement.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: ${currentBg};
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
backdrop-filter: var(--blur-1);
|
||||
`
|
||||
document.body.appendChild(transitionElement)
|
||||
|
||||
// 使用 Web Animations API 实现淡出动画
|
||||
const animation = transitionElement.animate([{ opacity: 1 }, { opacity: 0 }], {
|
||||
duration: 300,
|
||||
easing: 'ease-out',
|
||||
})
|
||||
|
||||
// 应用主题变更
|
||||
applyFn()
|
||||
|
||||
// 动画完成后清理
|
||||
animation.finished
|
||||
.then(() => {
|
||||
document.body.removeChild(transitionElement)
|
||||
})
|
||||
.catch(() => {
|
||||
// 降级处理
|
||||
document.body.removeChild(transitionElement)
|
||||
})
|
||||
}
|
||||
|
||||
function getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function cycleTheme(event) {
|
||||
if (!import.meta.client) return
|
||||
export function cycleTheme() {
|
||||
if (!process.client) return
|
||||
const modes = [ThemeMode.SYSTEM, ThemeMode.LIGHT, ThemeMode.DARK]
|
||||
const index = modes.indexOf(themeState.mode)
|
||||
const next = modes[(index + 1) % modes.length]
|
||||
@@ -183,31 +54,10 @@ export function cycleTheme(event) {
|
||||
} else {
|
||||
toast.success('🌙 已经切换到暗色主题')
|
||||
}
|
||||
// 获取当前真实主题
|
||||
const currentTheme = themeState.mode === ThemeMode.SYSTEM ? getSystemTheme() : themeState.mode
|
||||
|
||||
// 获取新主题的真实表现
|
||||
const nextTheme = next === ThemeMode.SYSTEM ? getSystemTheme() : next
|
||||
|
||||
// 如果新旧主题相同,不用过渡动画
|
||||
if (currentTheme === nextTheme) {
|
||||
setTheme(next)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算新主题是否是暗色
|
||||
const newThemeIsDark = nextTheme === 'dark'
|
||||
|
||||
withViewTransition(
|
||||
event,
|
||||
() => {
|
||||
setTheme(next)
|
||||
},
|
||||
!newThemeIsDark,
|
||||
)
|
||||
setTheme(next)
|
||||
}
|
||||
|
||||
if (import.meta.client && window.matchMedia) {
|
||||
if (process.client && window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (themeState.mode === ThemeMode.SYSTEM) {
|
||||
apply(ThemeMode.SYSTEM)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user